/* STONIC — themed UI. The display font + colour vars below are the HOUSE PRINT SHOP
   defaults; themes.js overrides --disp / palette / @font-face per active theme.
   THE ONE MATERIAL RULE: every surface is INK ON PAPER. Over the film (HUD, banners,
   chevrons, tutorial) type is knockout — --white/--gold fill, ink stroke, var(--shadow)
   offset. On paper (any card) type is SOLID INK — zero text-stroke, tracking ≥ .04em.
   The only knockout on paper is the h1 STONIC marquee. */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* FIRST PAINT IS VINTAGE. The journey is pinned to the 1930s look, so the CSS
   defaults ARE that look — the loader must never flash another game's palette
   while scripts are still downloading. themes.js re-applies (a no-op for
   vintage) and dev ?theme= overrides still repaint on top. */
:root {
  /* WHERE THE INTERFACE MAY SAFELY START. On the web this is the device's own
     safe area. Inside Telegram, tg.js publishes --tg-top/--tg-bottom which
     ALREADY include the device inset plus Telegram's overlaid controls — so take
     the LARGER of the two rather than adding them, or the notch counts twice.
     Unset on the web, where max() collapses to env() and nothing changes. */
  --ui-top: max(env(safe-area-inset-top, 0px), var(--tg-top, 0px));
  --ui-bottom: max(env(safe-area-inset-bottom, 0px), var(--tg-bottom, 0px));
  /* the same law sideways. tg.js has published --tg-left/--tg-right all
     along and nothing consumed them, so anything anchored to a side edge was
     ignoring Telegram's own margins. */
  --ui-left: max(env(safe-area-inset-left, 0px), var(--tg-left, 0px));
  --ui-right: max(env(safe-area-inset-right, 0px), var(--tg-right, 0px));
  /* ── GAME 2's TOP STACK (founder 2026-08-28) ──────────────────────────────
     Three rows that must never collide and must move together: the NOTES, the
     STRIP (controls + map), and the events BANNER. Derived here rather than in
     JS so that a rotation, a notch and Telegram's own header all move the whole
     stack by the same arithmetic without a resize handler being right.

       notes   sits level with Telegram's back button when there IS a reserved
               band, and just under the top edge when there is not — hence the
               max(): 4px is the web floor, ui-top - 28px the Telegram case.
       strip   right below the system buttons, which is ui-top + 4 — floored at
               34px so on the plain web it still clears the notes above it.

     There is no banner row any more: the events moved INTO the notes row
     (founder 2026-08-28), so the whole top of the screen is two rows deep.

     The strip's WIDTH is what the map and the banner share, so his "same width
     as our events progress bar" holds by construction: the map is what is left
     between the two button groups (2 x 58px plus their gaps = 168px), capped at
     the 340px he set, and floored so a very narrow phone degrades to a short
     bar rather than to buttons sitting on top of it. */
  --g2strip-w: min(340px, max(140px,
    calc(100vw - var(--ui-left) - var(--ui-right) - 200px)));
  /* THE ONE CENTRE LINE. The strip lives inside the side insets, so its middle
     is not the viewport's middle whenever those insets differ — and under
     Telegram they do. Everything in the stack is centred on THIS, so the notes,
     the map and the banner cannot part company. */
  --g2mid: calc(50% + (var(--ui-left) - var(--ui-right)) / 2);
  /* THE ROW'S HEIGHT, and its LINE.
     HEIGHT: big enough for the countdown's own label to sit inside the bar,
     but never taller than Telegram's back button beside it (founder
     2026-08-28). --tg-head-h is the header band; the button is smaller than
     its band, so take a little over three quarters of it. Unset off Telegram,
     where the fallback makes this exactly the 28px the web wants.
     LINE: --tg-head-mid is the line Telegram centres its own controls on, so
     sitting half a row above it centres this row on the BACK BUTTON rather
     than merely inside the reserved band. Zero off Telegram, where the max()
     falls back to the old 4px and nothing moves. */
  --g2row-h: min(28px, calc(var(--tg-head-h, 36px) * 0.78));
  --g2notes-top: max(4px, calc(var(--tg-head-mid, 0px) - var(--g2row-h) / 2));
  /* THE NOTES ROW LIVES IN TELEGRAM'S OWN BAND, so it has to leave room for
     what Telegram draws there: BACK on the left, and the collapse and menu
     controls on the right. Measured off the founder's screenshots, each of
     those needs about 96px. The map below has no such neighbour and keeps the
     full strip width, so this is a second cap rather than a change to the
     first — on the plain web --g2strip-w is the narrower of the two anyway and
     nothing moves. */
  --g2notes-w: min(var(--g2strip-w), calc(100vw - 192px));
  /* the strip clears the notes row by construction, whatever height that row
     turns out to be — it grew from 24 to 28 the moment the countdown moved
     into it, and a hard-coded 34px floor left a 2px gap */
  --g2strip-top: max(calc(var(--g2notes-top) + var(--g2row-h) + 8px),
                     calc(var(--ui-top) + 4px));

  --sky:    #8f8a7e;
  --sky-dk: #5a554b;
  --gold:   #e7e1d2;
  --gold-dk:#8a8377;
  /* THE KIT (round 21): the chrome tokens below carry game 2's palette —
     values MUST equal game2-ui.js COLORS (the audit gate cross-checks
     style.css :root <-> themes.js palette <-> game2-ui.js C). Stage- and
     in-run tokens (--sky*, --gold*, --green, --red, --page*) are MEASURED
     off baked art / certified in-run visuals and stay — audit-allowlisted. */
  --ink:    #12130f;          /* outline / shadow — game 2's dark ink */
  --white:  #f5f2ea;
  --green:  #7d786c;
  --red:    #3a352e;
  --warm:   #ffd9a0;                           /* game 2: warm = interactive/active */
  --danger: rgba(194,59,46,.9);                /* game 2's one red (wrong-code) */
  --ink-faint: rgba(245,242,234,.45);          /* disabled borders — game 2's grammar */
  --pop:    0 3px 0 rgba(0,0,0,.45);           /* the hard cartoon drop (game 2, not 0 5px) */
  --float:  0 10px 34px rgba(0,0,0,.55);       /* floating surfaces (the modal card) */
  --stage-rule: rgba(26,26,24,.24);            /* faint printed ink ON THE LIGHT STAGE / in-run paper (certified look) */
  /* THE SAFE-AREA FILL. Both edge fades and the browser's own bars take these,
     so the strips above and below the picture read as more STAGE rather than a
     cream band. Both values are MEASURED off cc-stage.png's own edges: the top
     from its curtain/wings band, the bottom from its floorboards, which is why
     the fill and the painting meet without a seam. --page-bot defaults to
     --page, so the other seven themes are untouched by this. */
  --page:   #131312;          /* curtain tone, measured rgb(19,19,18) */
  --page-bot: #080808;        /* floorboard tone, measured rgb(8,8,8) */
  --card:   #2e2e2e;          /* OPAQUE — game 2's modal fill (a printed plate hides what is behind it) */
  --card-border: 4px solid #e8e5dc;   /* game 2's outer frame */
  --card-radius: 20px;
  --chip:   #f5f2ea;          /* OPAQUE — game 2's pill grammar: ink on cream */
  --chip-text: #12130f;
  --chip-accent: #12130f;     /* mirrored from themes.js — the mirror law */
  --chip-you: #f5f2ea;        /* YOUR row = the cream pill, loudest on the dark card */
  --chip-you-text: #12130f;
  --shadow: rgba(0,0,0,.45);
  --muted:  #a8a49b;          /* game 2's dim (foot, captions) */
  --body:   Georgia, "Iowan Old Style", "Times New Roman", serif;   /* THE SERIF SUB-VOICE — game 2's iris sub-line reads this; never G2Body here */
  --chat:   "G2Body", "Comic Sans MS", cursive, sans-serif;   /* THE KIT: game 2's body voice — modal copy, speech bubbles */
  --credit: "G2Credit", Didot, "Bodoni 72", "Times New Roman", serif;   /* THE KIT: the studio Didone — idents/credits only */
  --btn:    linear-gradient(180deg, #f5f2ea, #f5f2ea);       /* BTN_BASE primary: flat cream (kept an image — grain sites layer it) */
  --btn-ghost: linear-gradient(180deg, transparent, transparent);   /* BTN_BASE secondary: the card shows through */
  --overlay: rgba(8,8,7,.78);  /* game 2's modal scrim — flat film black */
  --glow:   rgba(40,38,34,.26);
  --disp: "G2Card", "Arial Black", sans-serif;   /* THE KIT: game 2's display voice (Foo) — mirrored in themes.js font descriptor */
  /* print-shop tokens — mirrored into every themes.js palette */
  --accent-on-paper: #f5f2ea;                  /* links/accents now sit on the dark card — cream ink */
  --stroke-card: 0px;                          /* vintage: 0. dark-card themes: 2px */
  --screen: #0b0b09;                           /* film black — endCard bg + iris (game.js reads activeStyle.screen) */
  --shadow-drop: rgba(0,0,0,.45);              /* hard offset box-shadows — game 2's pop black */
  --well: rgba(11,11,9,.55);                   /* meter track wells — game 2's chrome surface */
  --rule-faint: rgba(232,229,220,.34);         /* hairlines on the dark card — game 2's inner-frame ivory */
  --rule-half: rgba(232,229,220,.5);           /* leader crosshair */
  --dim-strong: rgba(8,8,7,.62);               /* tut spotlight — film black family */
  --dim-soft: rgba(8,8,7,.32);
  --dim-faint: rgba(8,8,7,.18);
  /* THE LOBBY MARGIN — strip, prop columns and deck all inset by this, so nothing
     sits on the frame's edge. On a phone it is a flat 18px; past 676px of width it
     grows so the furniture stays a phone-shaped column instead of stretching a
     50px-tall strip across a desktop. ONE number, so no edge can ever drift. */
  --lobby-pad: max(18px, calc((100vw - 640px) / 2));
  /* THE PROP SIZE — one box for every illustrated button, deck and side column
     alike, so the two columns can never read as different tiers by accident.
     Bounded on BOTH axes: in landscape the side column hangs above ROUTE in the
     deck, and a width-only size grew until the two collided. */
  /* The chip row was 107px of a 701px screen with 81px of art. Together with
     the plaques that gave a quarter of the lobby to furniture, and put the
     deck's top edge above the stage floor so the buttons stood on the pilots'
     feet. Still well over the 44px touch floor. */
  --prop-art: clamp(44px, min(13vw, 8.4vh), 62px);
  --paper-grain: none;
  /* ── THE UI KIT's defaults (round 34) ──────────────────────────────────────
     Vintage's furniture, so a first paint before any world is applied is
     already right. themes.js overwrites these per world from identity.js, and
     tools/identity-check.mjs holds the two together. Only APPEARANCE lives
     here: every geometric rule stays where it is, because layout is the
     platform's in every world and only the LOOK is the world's own. */
  --k-btn-radius: 16px;
  --k-btn-border: 4px;
  --k-btn-press: translateY(2px);
  --k-card-radius: 11px 9px 10px 9px / 9px 11px 9px 10px;
  --k-card-border: 4px;
  --k-grip-radius: 6px 4px 5px 4px / 4px 6px 4px 5px;
  --k-plate-fill: #17130e;
  --k-live-fill: rgba(12,11,9,.78);
  --k-badge-fill: rgba(23,19,14,.92);
  --k-badge-rule: 1.5px solid rgba(247,244,236,.35);
  --k-badge-tilt: -5deg;
  --k-bubble-radius: 48% 52% 50% 46% / 68% 60% 72% 58%;
  --k-bubble-border: 3px;
  --k-bubble-tail: 51deg;
  --k-btn-fill: var(--btn);
  --k-btn-frame: var(--white);
  --k-btn-ink: var(--chip-text);
  --k-btn-shadow: var(--pop);
  --k-btn-ghost-ink: var(--white);
  --k-plaque-fill: var(--chip);
  --k-plaque-frame: var(--ink);
  --k-grip-ink: var(--ink);
  --k-grip-fill: var(--chip);
  --k-card-selected: var(--chip);
  --k-plus-fill: var(--chip);
  --k-plus-frame: var(--ink);
  --k-plus-ink: var(--ink);
  --k-stack-word: clamp(14px, 4.1vw, 19px);
  --k-pair-fill: var(--ink);
  --k-stack-track: .05em;
  --k-bubble-fill: var(--chip);
  --k-bubble-ink: var(--ink);
  --k-bubble-text: var(--chip-text);
  --k-bubble-shadow: 0 3px 0 var(--stage-rule);
  --k-tag-fill: #fff;
  --k-tag-ink: var(--ink);
  --k-tag-border: 3px;
  --k-grip-border: 2px;
  --k-tag-radius: 44% 56% 48% 52% / 60% 64% 58% 66%;
  --k-star-op: 0;
  --logo-k: 1;
  color-scheme: dark;
}
html, body {
  height: 100%; width: 100%;
  background: var(--page, #131312);
  color: var(--white);
  font-family: var(--disp);
  overflow: hidden;
  overscroll-behavior: none;
  touch-action: none;
  user-select: none; -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
/* Full-bleed stage: 100dvh tracks the DYNAMIC mobile viewport (iOS URL bar / chrome),
   so the game truly covers the screen; vh is the legacy fallback. The canvas fills the
   stage box (JS reads the stage's real rendered size — not window.innerHeight, which
   excludes iOS browser chrome). UI keeps itself in the safe zones via env() paddings. */
/* THE STAGE STOPS WHERE TELEGRAM'S CHROME BEGINS. We ask Telegram for
   fullscreen, which draws its Close/menu controls OVER our content — so the
   playfield must be inset by the insets tg.js publishes, or the marquee hides
   under the header and the canvas sizes itself taller than the visible area
   (which is why the cat and the backdrop rendered too large).
   --tg-* are UNSET on the web and fall back to 0px, so the web page computes
   byte-for-byte what it always did. One rule, both doorways. */
/* THE PICTURE FILLS THE SCREEN. An earlier attempt inset the STAGE by
   Telegram's insets, which letterboxed the artwork behind black bands top and
   bottom — wrong for a full-bleed game. The canvas always runs edge to edge; it
   is the UI that steps out of the way of Telegram's controls, via --ui-top and
   --ui-bottom. */
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; height: var(--app-h, 100dvh); }
#game, #filmfx { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#filmfx { z-index: 1; pointer-events: none; }
/* THE TV (founder, 3 Sept). The matrix: the phosphor grille and the scanlines,
   drawn once at device pixels by tvglass.js and MULTIPLIED over the whole
   page as its topmost layer — the picture, the chrome, the loading screen,
   every panel ("make it truly on top layer"). Its opacity is the TV's master:
   hidden at zero, so a run pays nothing for it. The glass: the lobby's DOM
   containers bend by the shader's own barrel through #tvWarp while the TV is
   on; the class comes and goes with the master, so a run's HUD never carries
   a filter. */
#tvMatrix { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 200; pointer-events: none;
            mix-blend-mode: multiply; opacity: 0; visibility: hidden; image-rendering: pixelated; }
/* ONE FILTER PER LEAF (round 3, the founder's phone). WebKit renders a
   reference filter in software and only on content it paints itself: a running
   transform or opacity transition, an animation, will-change, a scroller or a
   fixed element takes its content to the GPU and out of the filter — that was
   the flat blink on every tap and swipe — and a filter on the same element as
   a transform paints twice (the ghost cards). So the filters sit on the pieces
   INSIDE the drawer, the strip and the panels' scrollers, never on them; the
   containers keep their GPU layers and carry the painted bend along. Chrome
   composes the same picture either way. tvglass.js keeps every filter's map
   offset at minus that piece's place on the screen. */
body.tv-on #ccLogo { filter: url(#tvWarp-mark) drop-shadow(0 3px 0 var(--shadow)); }   /* the image, not the transformed .marquee; its own shadow kept in the list */
body.tv-on #playSheet .play-stack { filter: url(#tvWarp-stack); }
body.tv-on #gameCards { filter: url(#tvWarp-cards); }
body.tv-on #catBubble { filter: url(#tvWarp-bub); }   body.tv-on #catTag { filter: url(#tvWarp-tag); }   body.tv-on #catName { filter: url(#tvWarp-name); }
body.tv-on #peerBubble { filter: url(#tvWarp-pbub); } body.tv-on #peerTag { filter: url(#tvWarp-ptag); } body.tv-on #peerName { filter: url(#tvWarp-pname); }
body.tv-on #chairBtn { filter: url(#tvWarp-chair); }  body.tv-on #chairNote { filter: url(#tvWarp-note); }
/* THE SEATS (the seats round): each bubble-family element sits in a seat that
   carries its place, its slide and its breath; the element inside is a plain
   box a filter can bend. The transforms below are the ones the elements
   themselves used to wear, verbatim. */
.seat { position: absolute; left: var(--cat-cx, 50%); top: calc(var(--cat-top, 40%) - 14px); width: max-content; transition: left .18s linear; --bob: var(--cat-bob, 0px); }   /* max-content: a seat is as wide as what sits in it, never the room left of its `left` (the bubble's own max-width still caps it) */
.seat-bubble { z-index: 4; transform: translate(-50%, calc(-100% - var(--name-h, 0px) + var(--name-gap) + var(--bob, 0px))); }
.seat-tag    { z-index: 3; transform: translate(-50%, calc(-100% - var(--name-h, 0px) + var(--name-gap) - var(--bub-h, 0px) + var(--bub-lap) + var(--bob, 0px))); }
.seat-name   { z-index: 5; pointer-events: none; transform: translate(-50%, calc(-100% + var(--bob, 0px))); }
.seat-note   { z-index: 2; transform: translate(-50%, calc(-100% - var(--name-h, 0px) + var(--name-gap) - var(--bub-h, 0px) - var(--tag-h, 0px) + var(--bub-lap) * 2 + var(--bob, 0px))); }
.seat-peer   { left: var(--chair-cx, 30%); top: calc(var(--chair-top, 40%) - 14px); --bob: var(--chair-bob, 0px); }
.seat-peer.seat-tag    { transform: translate(-50%, calc(-100% - var(--peer-name-h, 0px) - var(--name-gap) - var(--peer-bub-h, 0px) + var(--bub-lap) + var(--bob, 0px))); }
.seat-peer.seat-bubble { transform: translate(-50%, calc(-100% - var(--peer-name-h, 0px) - var(--name-gap) + var(--bob, 0px))); }
.seat-chair  { left: var(--chair-cx, 30%); top: var(--chair-top, 40%); height: var(--chair-h, 30%); width: var(--chair-w, 20%); transform-origin: 50% 100%; transform: translateX(-50%); z-index: 6; transition: none; }
.seat:has(> .hidden) { display: none; }
body.tv-on #dailyPanel .card-skin { filter: url(#tvWarp-daily); } body.tv-on #missionsPanel .card-skin { filter: url(#tvWarp-miss); }
body.tv-on #lbPanel .card-skin { filter: url(#tvWarp-lb); }       body.tv-on #invitePanel .card-skin { filter: url(#tvWarp-inv); }
/* THE PARTY CARD (3 Sept): game 2's modal component builds it with inline
   styles and no lobby classes, so coop.js tags the two elements the glass
   needs — the card bends, and the scrim it floats in stops being a scroller
   once the card fits, because iOS composites a scroller and a composited box
   cannot carry a software bend (round 6's law, in the shared component). */
body.tv-on .tv-bend { filter: url(#tvWarp-party); }
body.tv-on .tv-scrim.fits { overflow: hidden !important; -webkit-overflow-scrolling: auto; }   /* !important: the component writes overflow-y INLINE, and an inline style outranks a rule */
/* NOTHING INSIDE A BENT PIECE ANIMATES A TRANSFORM OR OPACITY while the TV is
   on: the press is instant, the chair's + does not slide, the bubble does not
   pop — WebKit would lift each of those onto the GPU for the animation's
   length and drop the filter for exactly that long. */
/* THE LOADER'S COVER (round 3): the stage copies the loader's picture and the
   DOM loader goes invisible, so what the opaque loader used to hide must hide
   itself while booting — the run's HUD is the one thing standing there. */
body.booting #hud { visibility: hidden; }
body.tv-on .gc-cover { transition: none; }
/* (round 5) the chair and the bubbles are not filtered any more — their press and pop are back */
/* THE PANEL CARD'S SKIN (founder's pick): the card stays the scroller iOS
   composites, and the skin inside it wears the frame, the fill and the
   filter — the whole card bends and still scrolls. The card's own
   declarations move to the skin only while the TV is on. */
body.tv-on .overlay-card:has(> .card-skin) { border: 0; background: none; padding: 0; box-shadow: none; }
body.tv-on .overlay-card.fits { overflow: hidden; -webkit-overflow-scrolling: auto; }   /* no scroller when it fits: iOS composites scrollers and drops the skin's filter (round 6) */
body.tv-on .overlay-card > .card-skin {
  text-align: center; width: min(360px, 92vw); padding: 20px 18px 18px;
  border: var(--card-border, 4px solid #e8e5dc);
  border-radius: var(--card-radius, 20px);
  background-color: var(--card);
  color: var(--white);
  box-shadow: inset 0 0 0 8px var(--card), /* gap to the hairline */ inset 0 0 0 10px var(--rule-faint), /* game 2's inner frame — 2px ivory hairline */ var(--float);
}
/* Safari's status/URL bars can't be painted (no Fullscreen API on iPhone) — instead the
   game DISSOLVES into them: top/bottom fades to var(--page), the same colour the bars are
   tinted with (theme-color meta tracks --page per zone), so phone + game read as one surface. */
/* On real devices the page extends UNDER the bars into the safe areas — a fixed-height fade
   ends up submerged beneath the bar with only its weak end visible. Stay SOLID through the
   safe-area strip, then fade across the visible 80px. env() = 0 where there are no insets.
   Fades target var(--page) — the ACTIVE style's page colour, live per zone/style — so the
   game area always matches its own current look (user call, round 8). Safari's own bars keep
   the boot tint (it samples once and never re-tints); that seam is outside our control. */
#stage::before, #stage::after {
  content: ""; position: absolute; left: 0; right: 0;
  z-index: 2; pointer-events: none;
  display: none;             /* desktop: no browser bars / notch to dissolve into — no fades */
}
/* Touch devices only (phones + tablets, incl. Dynamic-Island iPhones): hover:none +
   pointer:coarse is the standard primary-input test — mouse/trackpad desktops report
   hover:hover + pointer:fine and keep the full play area edge-to-edge. */
@media (hover: none) and (pointer: coarse) {
  #stage::before, #stage::after { display: block; }
}
/* The visible fade must READ during play on every style: 80px of linear falloff let
   bright sprites stay near-full brightness until ~40px from the edge, which looks like
   no gradient at all on dark themes. 120px + an eased mid-stop (color-mix, with a plain
   3-stop fallback for engines without it) dissolves sprites clearly before the edge.
   env() lives in height only; stops use calc(100% - Npx), which equals the safe-area
   inset exactly: solid through the bar strip, fade across the visible 120px. */
/* THE TOP BAND STOPS JUST PAST THE SAFE AREA (founder, 31 Aug). It ran 120px
   BELOW it — in Telegram, where --ui-top is ~100px, a ~220px band whose tail
   lay across the curtain and the top of the spotlight as a visible haze.

   It keeps its own length, --fade-top, rather than borrowing --fade: that same
   variable also sets the BOTTOM dissolve into the home indicator, which is not
   what he was looking at and has further to travel. One complaint, one edge.
   A short tail rather than zero, so the band still dissolves into Telegram's
   header instead of meeting the painting on a hard line — which is the whole
   reason the fade exists. */
#stage::before {
  top: 0; height: calc(var(--fade-top, 28px) + var(--ui-top));
  background: linear-gradient(to bottom,
    var(--page), var(--page) calc(100% - var(--fade-top, 28px)), transparent);
  background: linear-gradient(to bottom,
    var(--page), var(--page) calc(100% - var(--fade-top, 28px)),
    color-mix(in srgb, var(--page) 55%, transparent) calc(100% - var(--fade-top, 28px) * 0.46),
    transparent);
}
#stage::after {
  bottom: 0; height: calc(var(--fade, 120px) + var(--ui-bottom));
  background: linear-gradient(to top,
    var(--page-bot, var(--page)), var(--page-bot, var(--page)) calc(100% - var(--fade, 120px)), transparent);
  background: linear-gradient(to top,
    var(--page-bot, var(--page)), var(--page-bot, var(--page)) calc(100% - var(--fade, 120px)),
    color-mix(in srgb, var(--page-bot, var(--page)) 55%, transparent) calc(100% - var(--fade, 120px) * 0.46),
    transparent);
}

/* WITH A CARD UP the veil (.overlay, z-20) used to bury these fades (z-2), so the
   bar strip met the dimmed scene on a hard line — the bands looked like flat fill
   with the dissolve gone. Lift them over the veil, and shorten the falloff to a
   short lip: the seam stays dissolved while a centred card is never reached. */
/* A CARD, NOT THE LOBBY (founder, 31 Aug). #startScreen is itself an .overlay,
   so body.overlay-up is true for the WHOLE of the lobby — which lifted these
   fades to z-21, above the lobby's own marquee (z-20), and Telegram's tall
   --ui-top then laid the solid end of the top band straight across the STONIC
   wordmark. It looked like the safe-area gradient was eating the logo because
   it was. The lift is only ever wanted under a DIMMING card, so it now keys on
   a class that means exactly that; body.overlay-up keeps its own job (pausing
   occluded animations) untouched. */
body.card-up #stage::before,
body.card-up #stage::after { z-index: 21; --fade: 30px; }

/* ---------- HUD (over-film: knockout type, var(--shadow) offsets) ---------- */
.hud {
  position: absolute; inset: 0; z-index: 5; pointer-events: none;
  display: flex; flex-direction: column; justify-content: space-between;
  /* BELOW THE PLATFORM ROW (founder, 5 Sept: "The Sound and lobby buttons are
     overlaid with score"). Two systems were both anchoring to the top-left and
     neither knew about the other: .hud padded to --ui-top while strip.js fixes
     its buttons at --g2strip-top. The HUD now starts under the row's 40px box,
     off the same variable, so the collision cannot come back on a different
     phone. */
  padding: calc(var(--g2strip-top, 12px) + 46px) 20px max(20px, calc(var(--ui-bottom) + 14px));
}
.hud-top { display: flex; justify-content: space-between; align-items: flex-start; }
/* THE BOTTOM ROW SITS ON ONE LINE (founder, 5 Sept: "Steam and best result should
   be bottom aligned, currently best result is lower then steam"). Both rows
   shared `align-items: flex-start`, which was fine while BEST and STEAM were the
   same shape — a label over a number, and a label over a bar. Removing STEAM's
   label made its column 22px shorter, so top-aligning left the bar floating
   above BEST's baseline. They align to the FLOOR now, which is the line the eye
   actually reads along at the bottom of a screen. */
.hud-bottom { display: flex; justify-content: space-between; align-items: flex-end; }
/* METRES-ONLY IS PERMANENT (round 20), so this block is never shown — but
   `visibility` left it holding flex width, which is half of why the ruler was
   off centre. display:none instead. The NODES stay: countUp() and bare
   getElementById('tokens') writes crash without them (round 20's invariant). */
#ringsHud { display: none !important; }
.hud-label { font-size: 12px; letter-spacing: .16em; color: var(--gold); -webkit-text-stroke: 2px var(--ink); paint-order: stroke fill; text-shadow: 0 2px 0 var(--shadow); }
.hud-num { font-size: 40px; line-height: .92; transform: skewX(-6deg); display: inline-block;
  color: var(--white); -webkit-text-stroke: 3px var(--ink); paint-order: stroke fill; text-shadow: 0 4px 0 var(--shadow); }
.hud-num.small { font-size: 22px; -webkit-text-stroke: 2px var(--ink); }
.hud-num.gold { color: var(--gold); }
.hud-num .unit { font-size: .42em; margin-left: 1px; -webkit-text-stroke: 1.5px var(--ink); }

.hud-score, .hud-best { display: flex; flex-direction: column; gap: 1px; }
.hud-rings { display: flex; align-items: center; gap: 10px; }
.hud-rings.pop { animation: ringpop .28s cubic-bezier(.2,1.6,.4,1); }
.ring-icon {
  width: 30px; height: 30px; border-radius: 50%;
  border: 8px solid var(--gold);
  box-shadow: 0 0 0 3px var(--ink), inset 0 0 0 3px #fff3b0, 0 3px 0 var(--shadow);
  background: transparent; flex: 0 0 auto;
  position: relative; overflow: hidden;
}
/* the coin's glint sweeps INSIDE the ring — the old rotation spun a radially
   symmetric shape, so the only visible motion was its drop shadow orbiting */
.ring-icon::after {
  content: ""; position: absolute; inset: -8px;
  background: conic-gradient(rgba(255,255,255,.7) 0 14deg, transparent 14deg);
  animation: spin 2.4s linear infinite; will-change: transform;
}
.hud-boost { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.boost-meter { width: 110px; height: 12px; border-radius: 8px; background: var(--well); border: 3px solid var(--ink); overflow: hidden; }
.boost-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--gold), #ff8a1e); box-shadow: 0 0 12px rgba(255,160,40,.7); transition: width .08s linear; }

.zone-label {
  font-size: 16px; letter-spacing: .16em; text-align: center; align-self: center; flex: 1;
  color: var(--white); -webkit-text-stroke: 2.5px var(--ink); paint-order: stroke fill; text-shadow: 0 3px 0 var(--shadow);
  opacity: 0; transition: opacity .3s ease; transform: skewX(-6deg);
}
.zone-label.show { opacity: .95; }

.combo {
  align-self: center; font-size: 34px; transform: skewX(-8deg);
  color: var(--gold); -webkit-text-stroke: 3px var(--ink); paint-order: stroke fill;
  text-shadow: 0 4px 0 var(--shadow);
  opacity: 0; transition: opacity .12s ease;
}
.combo.show { opacity: 1; animation: pop .22s cubic-bezier(.2,1.7,.4,1); }
[data-theme="vintage"] .combo.show { animation: pop-steps .22s steps(2); }   /* pop carries the skew every frame; vintage (skew stripped) pops on twos */

/* ---------- ring-collect "+1" popups ---------- */
.ringpop-fx {
  position: absolute; z-index: 6; pointer-events: none;
  font-family: var(--disp); font-size: 26px; color: var(--gold);
  -webkit-text-stroke: 3px var(--ink); paint-order: stroke fill; transform: skewX(-6deg);
  animation: floatup .7s ease-out forwards; will-change: transform, opacity;
}
.ringpop-fx.idle { opacity: 0; animation: none; }
.ringpop-fx.rp2 { animation-name: floatup2; }

/* ---------- banner ---------- */
/* P1: the lower-third band — fight-scoped shouts print BELOW the keeper
   (the boss occupies ~4-44% of frame; a banner at 30% sat on its face). */
.banner.low { top: 58%; }
.banner {
  position: absolute; top: 30%; left: 0; right: 0; text-align: center; z-index: 4;
  font-size: clamp(30px, 10vw, 60px); transform: skewX(-7deg);
  color: var(--white); -webkit-text-stroke: 4px var(--ink); paint-order: stroke fill; text-shadow: 0 6px 0 var(--shadow);
  pointer-events: none; opacity: 0;
}
.banner.play { animation: bannerPop 2.1s ease forwards; }
.banner.play2 { animation: bannerPop2 2.1s ease forwards; }
.banner .sub-banner { display: block; font-size: .36em; letter-spacing: .12em; color: var(--gold); margin-top: 8px; -webkit-text-stroke: 2px var(--ink);
  max-width: 86vw; margin-left: auto; margin-right: auto; }   /* the chain line must never touch the frame edges */

/* ---------- boss bar ---------- */
/* ══ ONE BAR, THREE JOBS (round 42) ═════════════════════════════════════════
   Game 2's event countdown, the boss's HP and the STEAM meter are the same
   object doing different work, so they are now the same object. The geometry is
   game 2's, read out of game2-app.js and expressed in this sheet's own tokens
   (--screen IS #0b0b09, its border; --white IS #f5f2ea, its fill), so the two
   games match by construction rather than by two numbers happening to agree.

   THE LABEL IS DRAWN TWICE, one copy over the other in the same box: cream
   underneath, ink on top clipped to the fill. Where the fill still covers the
   track the ink copy shows and the text reads black-on-cream; where the fill has
   drained the ink is clipped away and the cream copy shows against the dark. The
   letters never move — only which of the two is visible at each point. It is why
   game 2 can put a name ON a draining bar and keep it readable at both ends, and
   it is the single device that will make the two HUDs read as one product.

   `clip-path: inset()` rather than a width-clipped wrapper, so both layers keep
   byte-identical geometry and the two texts cannot drift apart by a sub-pixel. */
.k-bar {
  position: relative; height: 22px; border-radius: 7px;
  border: 3px solid var(--screen); background: var(--well);
  overflow: hidden; box-sizing: border-box;
}
.k-bar-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: var(--white); transition: width .1s linear; }
.k-bar-lbl, .k-bar-ink {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-family: var(--disp); font-size: 13px; letter-spacing: .09em;
  white-space: nowrap; pointer-events: none;
}
.k-bar-lbl { color: var(--white); }
.k-bar-ink { color: var(--ink); }

/* THE ROUND-21 GAUGE OVERRIDES MUST NOT FIGHT IT. Those rules gave .boss-bar and
   .boost-meter a printed paper-and-ink look (radius 3, ticks, 18px, an ink fill)
   and were founder-certified then; this round supersedes them for these two
   instruments only, at the founder's word, so the three bars across both games
   are one object. The rules stay in the sheet for anything else that wears the
   classes; here .k-bar simply wins. */
[data-theme="vintage"] .k-bar.boss-bar, [data-theme="vintage"] .k-bar.boost-meter {
  height: 22px; border-radius: 7px; padding: 0;
  border: 3px solid var(--screen); background: var(--well);
  background-image: none; box-shadow: none;
}
[data-theme="vintage"] .k-bar .k-bar-fill { background: var(--white); border-radius: 0; box-shadow: none; }
/* the redline rattle is kept — it is the one thing the meter did say clearly */
.k-bar.boost-meter { width: 132px; }

/* THE FLIGHT RULER — the tower's map, translated for a journey with no top.
   Game 2's ruler works because the tower ends; a flight does not, so the scale
   is the RECORD: the bar runs 0 -> your best, the pin marks it, and once you
   pass it the scale grows with you and the pin slides back down the bar. Before
   there is a record there is no pin, because there is nothing true to draw. */
/* CENTRED ON THE TRUE CENTRE, exactly as game 2's map is (founder, 5 Sept: "the
   progress bar is not cetnted"). It was a flex child of .hud-top, so it sat in
   whatever space SCORE and the rings left it — and the rings are permanently
   hidden yet still reserved width, which pushed it off centre a second time.
   Out of the flow entirely now: --g2mid is the same variable the tower pins its
   map to, so the two games' bars land on the same line by construction. Its box
   is the tower's row (--g2strip-top, 40px tall) so it sits between the buttons
   rather than beside the score. */
.flight-ruler {
  position: absolute; left: var(--g2mid, 50%); transform: translateX(-50%);
  top: calc(var(--g2strip-top, 12px) + 9px);
  width: var(--g2notes-w, min(340px, 60vw)); margin: 0; height: 22px;
}
.ruler-track { position: absolute; inset: 0; }
/* THE PILOT RIDES ON THE BAR (founder, 5 Sept: "on the Ruler I don't see the
   user's avatar, in game 2 we have it"). Game 2's law, quoted from its own map:
   "the teardrop is gone and with it the whole question of where a pointer aims:
   the coin's own centre IS the climber's height". Same here — the coin's centre
   is the distance flown. It is the SAME generator (Game2UI.coin, already loaded
   for game 1 at index.html:775), given a face descriptor rather than a tier
   word, which is the extension point that function documents for exactly this. */
.ruler-coin {
  position: absolute; top: 50%; left: 0; transform: translate(-50%, -50%);
  pointer-events: none; transition: left .12s linear; z-index: 2;
}
/* ══ THE KEEPER'S HEARTS ════════════════════════════════════════════════════
   Founder, 5 Sept: "You didn't use the space above the ruler for boss's health.
   In game 2 there are events and they go above the height bar... come up with a
   game-true element of health per boss. Like a heart... ensure this component
   has exactly same positioning and rules as in game 2... DON'T REINVENT, COPY
   EXACTLY THIS LOGICS."

   So the geometry below is COPIED, not derived — it is game2-app.js's own notes
   row, character for character: left --g2mid, top --g2notes-top, width
   --g2notes-w, height --g2row-h, translateX(-50%), centred both ways. That row
   is the gold the founder measured to sit between Telegram's back button and its
   three dots, and its width already accounts for the space those two leave. It
   is not re-measured here, because re-measuring is how two things that must
   agree start to disagree.

   The RULE is copied too: game 2's row shows one thing at a time and the event
   outranks a note. A keeper's hearts outrank the party line for the same reason —
   during a fight there is only one thing worth that row.

   A heart rather than a bar because a bar is a spreadsheet and this is a 1930s
   cartoon: hearts are the oldest health readout there is, they read at a glance
   at any size, and they say how many hits are LEFT rather than what fraction has
   been dealt. */
.boss-hearts {
  position: absolute; left: var(--g2mid, 50%); top: var(--g2notes-top, 4px);
  transform: translateX(-50%);
  width: var(--g2notes-w, min(340px, 60vw)); height: var(--g2row-h, 28px);
  display: flex; align-items: center; justify-content: center; gap: 6px;
  /* above #lobbyStrip's z-index:4 layer, which is body-fixed and therefore in a
     stacking context of its own — matching game 2's notes row at z 13 */
  z-index: 13; pointer-events: none;
}
.boss-hearts svg { display: block; }
/* a spent heart stays in its place — the row must not reflow as the fight runs */
.boss-hearts .spent { opacity: .34; }
/* the last heart beats, because the last heart always should */
.boss-hearts .last { animation: heartBeat .62s ease-in-out infinite; }
@keyframes heartBeat { 0%,100% { transform: scale(1); } 45% { transform: scale(1.16); } }
@media (prefers-reduced-motion: reduce) { .boss-hearts .last { animation: none; } }

.ruler-pin {
  position: absolute; top: -2px; bottom: -2px; width: 2px;
  background: var(--white); opacity: .85; pointer-events: none; display: none;
  box-shadow: 0 0 0 1px var(--screen);
}

.boss-ui { margin: 8px auto 0; width: min(86%, 360px); text-align: center; }
/* the name is ON the bar now (see .k-bar) — the separate line above it is gone */
.boss-name { display: none; }
.boss-bar { height: 14px; border-radius: 9px; background: var(--well); border: 3px solid var(--ink); overflow: hidden; }
.boss-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--red), var(--gold)); transition: width .1s linear; }

/* ---------- overlays (title / results) ---------- */
.overlay {
  position: absolute; inset: 0; z-index: 20; display: grid; place-items: center;
  background: var(--overlay);
  animation: fadein .25s ease;
}
/* The card prints its own paper: the film shader never grades z-20 (filmfx is z-1),
   so grain, hand-cut corners, the double ink rule and the printer's quads are CSS.
   Ornaments live in background (not pseudos): the card is its own scroll container
   and backgrounds stay glued to the box while absolute pseudos scroll away. */
/* ONE PLATE WIDTH for every panel: the cards used to shrink-to-fit, so MISSIONS
   printed 40px narrower than TOP PILOTS and the ledger rows overflowed the paper
   margin. A definite width gives the whole house one measure — and one content
   column (width minus the side padding) that plates, rows and lists all share. */
.overlay-card {
  /* THE KIT: game 2's modal card verbatim (game2-ui.js anatomy) — dark fill,
     4px ivory frame, r20, the inner hairline drawn as INSET SHADOWS so it can
     never sit above the card's buttons and swallow a tap (the hit-test law),
     and it stays glued to the box while the card scrolls. */
  text-align: center; width: min(360px, 92vw); padding: 20px 18px 18px;
  max-height: calc(100svh - 24px); overflow-y: auto; -webkit-overflow-scrolling: touch;
  border: var(--card-border, 4px solid #e8e5dc);
  border-radius: var(--card-radius, 20px);
  background-color: var(--card);
  color: var(--white);
  box-shadow:
    inset 0 0 0 8px var(--card),        /* gap to the hairline */
    inset 0 0 0 10px var(--rule-faint), /* game 2's inner frame — 2px ivory hairline */
    var(--float);
}

/* STONIC logo — the one knockout allowed on paper */
.overlay-card h1 {
  /* THE CARD IS A FIXED MEASURE, SO THE TITLE MUST BE TOO. 17vw overran it and
     the loader's STONIC was clipped at both ends; 14.6vw fixed that at PHONE
     widths but the 110px ceiling still overran on desktop — the card stops
     growing at 356px while vw keeps climbing, so at 1600px the title rendered
     110px / 537px wide inside a 302px column and read "STON". The ceiling has
     to be the column, not a guess: 56px is the largest that fits 302px. */
  font-family: var(--disp); font-size: clamp(38px, 14.6vw, 56px); line-height: .9;
  transform: skewX(-8deg); letter-spacing: .01em;
  color: var(--gold);
  -webkit-text-stroke: 6px var(--ink); paint-order: stroke fill;
  text-shadow: 0 8px 0 var(--shadow), 0 0 30px var(--glow);
  animation: logobounce 2.4s ease-in-out infinite;
}
/* T2 card title: solid ink on paper — no stroke, no skew, no shadow. 8vw keeps
   TODAY'S ROUTE on ONE line inside the card's measure (8.4vw broke it in two). */
.overlay-card h2 {
  /* THE KIT: game 2's modal title voice — G2Card 27px, .03em, tight leading */
  font-family: var(--disp); font-size: clamp(22px, 7vw, 27px); line-height: 1.1;
  letter-spacing: .03em; color: var(--white); text-wrap: balance;
}
/* game 2's hand-inked ornament rule under every card title (g2ui/rule.png —
   the same asset Game2UI.modal draws; replaces the Oxford rule) */
.overlay-card h2::after {
  content: ""; display: block; width: 78%; height: 12px;
  margin: 10px auto 0; border: 0; opacity: .72;
  background: url('g2ui/rule.png') center / contain no-repeat;
}
.overlay-card h2.win-title { color: var(--accent-on-paper); }
#deathScreen h2 { color: var(--danger); }   /* danger red belongs to CRASHED alone — game 2's one red */
.sub { font-size: 14px; letter-spacing: .26em; color: var(--muted); margin-top: 6px; text-transform: uppercase; }
/* body copy: 1.9 leading floated two short lines apart with no paragraph to hold
   them; 1.5 sets them as one block, and balance stops the one-word orphan line */
.hint {
  font-size: 14px; line-height: 1.5; letter-spacing: .04em; text-wrap: balance;
  color: var(--muted); margin: 14px 0 12px;
  font-family: var(--chat); font-weight: 500;
}
.hint b { color: var(--accent-on-paper); font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.tiny { font-size: 12.5px; line-height: 1.45; color: var(--muted); margin-top: 12px; font-family: var(--chat); font-weight: 600; text-wrap: balance; }
.tiny b { color: var(--accent-on-paper); }

/* ---------- first-time tutorial freeze layer (over-film grammar) ---------- */
.tut { position: absolute; inset: 0; z-index: 8; pointer-events: none; }   /* pointer-events:none is LOAD-BEARING — canvas must keep receiving taps */
.tut-dim {   /* radial spotlight on the threat (dodge/orb) */
  position: absolute; inset: 0;
  background: radial-gradient(circle at var(--sx, 50%) var(--sy, 40%),
    transparent calc(var(--sr, 90px) * 0.8), var(--dim-soft) var(--sr, 90px), var(--dim-strong) calc(var(--sr, 90px) * 1.9));
  animation: fadein .2s ease;
}
.tut.lane .tut-dim {   /* full-height lane band (guardian tell) */
  background: linear-gradient(90deg,
    var(--dim-strong) calc(var(--lx0, 33%) - 6px), var(--dim-faint) var(--lx0, 33%),
    var(--dim-faint) var(--lx1, 66%), var(--dim-strong) calc(var(--lx1, 66%) + 6px));
}
.tut-card {
  position: absolute; top: 16%; left: 50%; transform: translateX(-50%);
  text-align: center; width: max-content; max-width: 86vw;
  animation: pop .35s cubic-bezier(.2,1.7,.4,1);
}
.tut-card h3 {
  font-family: var(--disp); font-size: clamp(28px, 8vw, 44px); color: var(--gold);
  transform: skewX(-6deg); -webkit-text-stroke: 3.5px var(--ink); paint-order: stroke fill;
  text-shadow: 0 5px 0 var(--shadow);
}
.tut-card p {
  margin-top: 8px; font-size: 15px; font-weight: 700; font-family: var(--chat);
  color: var(--white); text-shadow: 0 2px 4px var(--shadow);
}
.tut-card p b { color: var(--gold); }
[data-theme="vintage"] .tut-card h3 { transform: none; }   /* vintage strips skew (flat 1930s title card) */
[data-theme="vintage"] .tut-card { animation: pop-steps-center .3s steps(2) both; }   /* pops on twos; keyframes must carry the translateX centering — fill 'both' holds the last frame forever */
.tut-hand {
  position: absolute; bottom: 22%; width: 76px; height: 76px;
  display: grid; place-items: center;
  font-family: var(--disp); font-size: 17px; color: var(--white);
  -webkit-text-stroke: 2px var(--ink); paint-order: stroke fill;
}
.tut-hand.l { left: 14%; } .tut-hand.r { right: 14%; }
.tut-ring {   /* pulsing tap ripple */
  position: absolute; inset: 0; border-radius: 50%;
  border: 4px solid var(--gold); box-shadow: 0 0 18px var(--glow);
  animation: tutring 1.1s ease-out infinite;
}
@keyframes tutring { 0% { transform: scale(.55); opacity: 1; } 70% { transform: scale(1.15); opacity: .25; } 100% { transform: scale(1.25); opacity: 0; } }

/* ---------- fading edge chevrons (early-runs tap affordance) ---------- */
.chev {
  position: absolute; top: 66%; transform: translateY(-50%); z-index: 5; pointer-events: none;
  line-height: 0;
  filter: drop-shadow(0 2px 0 var(--shadow));
  animation: chevpulse 1.4s steps(6) infinite;   /* on the exposure clock, not eased */
}
/* the LEFT hint points LEFT: mirror the glove (the SVG is drawn pointing right) */
.chev.l svg { transform: scaleX(-1); }
.chev.l { left: calc(10px + env(safe-area-inset-left, 0px)); }
.chev.r { right: calc(10px + env(safe-area-inset-right, 0px)); }
@keyframes chevpulse { 0%, 100% { transform: translateY(-50%) scale(1); } 50% { transform: translateY(-50%) scale(1.22); } }

/* results stats — a printed table: T3 ink numerals, T7 labels, ledger rules.
   THREE EQUAL COLUMNS: as a flex row the cells were as wide as their own labels,
   so the numerals landed at uneven intervals and the rules never fell on the
   thirds. A grid rules the table properly. */
.death-stats { display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; margin: 16px 0 14px; }
.death-stats div { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.death-stats div + div { border-left: 1.5px solid var(--rule-faint); }
.death-stats span { font-size: 34px; line-height: 1.05; letter-spacing: .02em; color: var(--white); }
.death-stats small { font-family: var(--chat); font-size: 12px; letter-spacing: .1em; color: var(--muted); }
.new-best { font-size: 22px; letter-spacing: .04em; color: var(--accent-on-paper);
  -webkit-text-stroke: var(--stroke-card) var(--ink); paint-order: stroke fill;
  margin-bottom: 6px; animation: pop-steps .34s steps(2); }

/* ---------- the letterpress plate — primary action ----------
   THE BILLING LADDER. Plate width used to be whatever the label measured, so
   PRACTICE FLIGHT printed wider than FLY THE ROUTE and STOKE THE FURNACE bullied
   RESTART off its own card. Now the primary owns the card's full content column
   and the ghost steps in from both edges at a smaller size — the ladder reads the
   same on every panel, whatever the words happen to be. */
.play-btn {
  /* THE PLATE IS A FIXED SIZE. It used to be min-height, so any label long
     enough to wrap grew the box — POST THE CROSSING made the win card's
     primary 85px tall while every other primary was 58px, and stacked above a
     50px ghost and 44px links it read as three mismatched buttons. Fixed
     height + nowrap means a plate is the same plate on every screen; fitPlate()
     shrinks an over-long label to fit rather than letting it wrap or spill. */
  /* THE KIT: game 2's BTN_BASE — the death-card / wallet-card button, the
     founder's exemplar. Cream 4px frame, r16, G2Card uppercase, the hard
     0 3px 0 pop. Primary = cream fill + dark ink type. */
  position: relative; display: flex; align-items: center; justify-content: center;
  width: 100%; margin-left: auto; margin-right: auto;
  height: 58px; min-height: 58px; padding: 12px 22px; white-space: nowrap;
  font-family: var(--disp); font-size: clamp(19px, 5.4vw, 21px); letter-spacing: .05em;
  text-transform: uppercase;
  line-height: 1; color: var(--k-btn-ink);
  background-image: var(--paper-grain), var(--k-btn-fill);
  border: var(--k-btn-border) solid var(--k-btn-frame); border-radius: var(--k-btn-radius);
  cursor: pointer; pointer-events: auto;
  box-shadow: var(--k-btn-shadow);
  transition: none;   /* a press is a discrete state change — steps(1), no easing */
}
.play-btn:active { transform: var(--k-btn-press); }   /* game 2's press: a 2px sit, shadow kept */
/* ── THE CORNER STAR ────────────────────────────────────────────────────────
   The second world's signature: a panel is a drawn line on black with a
   four-point star at two DIAGONALLY OPPOSED corners, and a selected item gains
   one as its cursor. Drawn with clip-path, not a glyph, so it cannot depend on
   whichever face happens to be resident. Both pseudo-elements exist in every
   world and the world's own --k-star-op decides whether they are visible, so
   there is no per-world selector and no second code path. They are
   pointer-events:none and 9px, so the hit test is exactly what it was — the
   frame-swallows-the-click hazard is why this is corners and never an inset
   overlay. */
.play-btn::before, .play-btn::after,
.play-plaque::before, .play-plaque::after {
  content: ""; position: absolute; width: 9px; height: 9px;
  pointer-events: none; opacity: var(--k-star-op, 0);
  background: var(--k-btn-frame);
  clip-path: polygon(50% 0%, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0% 50%, 42% 42%);
}
/* INSIDE the corner, not hung off it: .play-btn, .play-plaque and .play-word
   all carry overflow:hidden (the ellipsis rule), so anything at a negative
   offset is simply clipped away and the star never appears. */
.play-plaque::before, .play-plaque::after { background: var(--k-plaque-frame); }
.play-btn::before, .play-plaque::before { top: 4px; right: 4px; }
.play-btn::after,  .play-plaque::after  { bottom: 4px; left: 4px; }
/* disabled — game 2's grammar: dimmed and faint-framed, never hidden */
.play-btn[disabled] { background-image: var(--paper-grain), var(--btn-ghost); color: var(--k-btn-ghost-ink); border-color: var(--ink-faint); opacity: .45; box-shadow: none; pointer-events: none; }
/* second billing: narrower, shorter, smaller type, single rule, shallower shadow */
.play-btn.ghost {
  /* THE SAME WIDTH AS THE PLATE ABOVE IT. This was 88%, so any card stacking a
     primary over a ghost — COPY LINK over SEND IT over CANCEL — printed three
     buttons in two widths, which reads as a mistake rather than a hierarchy.
     Ghosts are already distinguished by weight, fill and shadow; they do not
     need to be narrower as well. */
  width: 100%; height: 50px; min-height: 50px; padding: 11px 14px;
  font-size: clamp(15px, 4.2vw, 17px);
  color: var(--k-btn-ghost-ink);   /* secondary: the card shows through, cream type */
  border-width: 3px; border-radius: 14px;   /* game 2's modal-button weight */
  background-image: var(--paper-grain), var(--btn-ghost); box-shadow: var(--k-btn-shadow);
}
.play-btn.ghost:active { transform: translateY(2px); }
.win-buttons { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 16px; }
/* the quiet tier rides ONE row, on the win card exactly as on the death card */
.card-links { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }

/* the one hidden utility — never re-asserted, never outranked */
.hidden { display: none !important; }

/* ---------- keyframes ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes ringpop { 0%{transform:scale(1)} 40%{transform:scale(1.35)} 100%{transform:scale(1)} }
@keyframes pop { 0%{transform:scale(.5) skewX(-8deg)} 60%{transform:scale(1.18) skewX(-8deg)} 100%{transform:scale(1) skewX(-8deg)} }
@keyframes floatup { 0%{opacity:0; transform:skewX(-6deg) translateY(8px) scale(.7)} 25%{opacity:1; transform:skewX(-6deg) translateY(-6px) scale(1.1)} 100%{opacity:0; transform:skewX(-6deg) translateY(-56px) scale(1)} }
@keyframes floatup2 { 0%{opacity:0; transform:skewX(-6deg) translateY(8px) scale(.7)} 25%{opacity:1; transform:skewX(-6deg) translateY(-6px) scale(1.1)} 100%{opacity:0; transform:skewX(-6deg) translateY(-56px) scale(1)} }
@keyframes logobounce { 0%,100%{transform:skewX(-8deg) translateY(0) rotate(-1deg)} 50%{transform:skewX(-8deg) translateY(-12px) rotate(1deg)} }
@keyframes fadein { from{opacity:0} to{opacity:1} }
@keyframes bannerPop { 0%{opacity:0; transform:skewX(-7deg) scale(.8) translateY(10px)} 16%{opacity:1; transform:skewX(-7deg) scale(1) translateY(0)} 78%{opacity:1; transform:skewX(-7deg) scale(1) translateY(0)} 100%{opacity:0; transform:skewX(-7deg) scale(1.05) translateY(-10px)} }
@keyframes bannerPop2 { 0%{opacity:0; transform:skewX(-7deg) scale(.8) translateY(10px)} 16%{opacity:1; transform:skewX(-7deg) scale(1) translateY(0)} 78%{opacity:1; transform:skewX(-7deg) scale(1) translateY(0)} 100%{opacity:0; transform:skewX(-7deg) scale(1.05) translateY(-10px)} }

/* ---------- task rows — the burn card, printed in-system ---------- */
.tasks-list { display: flex; flex-direction: column; gap: 8px; margin: 16px 0 0; }
.task-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 58px;
  /* THE KIT: game 2's list-row surface (the party-row / shop-card grammar) —
     dark well, faint ivory frame, cream type, warm accents */
  text-align: left; padding: 10px 12px; cursor: pointer; pointer-events: auto;
  background-color: var(--well);
  color: var(--white); border: 2.5px solid var(--ink-faint);
  border-radius: 12px;
  box-shadow: var(--pop); font-family: var(--chat); transition: none; }
.task-row:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--shadow-drop); }
.hint + .tasks-list { margin-top: 8px; }   /* the paragraph already left its own air */
/* on the revive card the burn row IS the primary action, so it prints as a plate
   (ink drop, 3px rule) instead of a list chip — the ghost below it is the exit */
#revivePanel .task-row { min-height: 62px; border-width: 3px; border-color: var(--white); box-shadow: var(--pop); }
#revivePanel .task-row:active { transform: translateY(2px); }
#revivePanel .task-body b { font-size: 16.5px; }
.task-ic     { font-size: 20px; width: 26px; text-align: center; color: var(--white); flex: 0 0 auto; }
.task-body { flex: 1; display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.task-body b { font-family: var(--disp); font-size: 15px; letter-spacing: .06em;
               text-transform: uppercase; font-weight: 400; color: var(--white); }
.task-body small { font-size: 11.5px; color: var(--muted); }
.task-reward { font-family: var(--disp); font-size: 12px; letter-spacing: .08em;
               color: var(--warm); flex: 0 0 auto; white-space: nowrap; }   /* warm = the thing you get */
.task-row.validating { opacity: .75; }
.task-row.validating .task-reward { animation: taskpulse .9s steps(2) infinite; }  /* stepped blink, not a fade */
.task-row.done { border-color: var(--warm); }   /* game 2: warm marks the achieved/active thing */
.task-row.done .task-reward, .task-row.done .task-ic { color: var(--warm); }
@keyframes taskpulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }

/* ---------- Vintage Toon: flatter title-card UI ---------- */
[data-theme="vintage"] .overlay-card h1 { transform: none; animation: vintbob 2.6s ease-in-out infinite; text-shadow: 0 6px 0 var(--shadow); }
[data-theme="vintage"] .sub, [data-theme="vintage"] .hud-num,
[data-theme="vintage"] .banner, [data-theme="vintage"] .combo, [data-theme="vintage"] .zone-label { transform: none; }
[data-theme="vintage"] .chev { color: var(--ink); -webkit-text-stroke: 3px var(--white); text-shadow: none; }
@keyframes vintbob { 0%,100%{transform:translateY(0) rotate(-1deg)} 50%{transform:translateY(-8px) rotate(1deg)} }


/* — HUD: cockpit readouts (upright, flat print shadows) — */

/* — gauges: segmented cockpit meters (boost / boss) — */

/* targeting brackets: ONE pseudo, 8 corner gradients — on NON-clipping wrappers */

/* — zone banner: OVA episode title card (letterbox strip, thin rules, EPISODE numeral) — */

/* — overlays: static title-card logo, italic episode-end titles — */

/* fansub subtitles: bold sans, thick black outline (the VHS-fansub look) */
/* VCR OSD small print */

/* buttons: flat cel print — no shine, no pulse, hard offset shadow */

/* results stats + quest rows: anime-OS console list */

/* tutorial: bracket-framed cockpit callout, clean cut-in */

/* chevrons: flat print, no glow (pulse kept — tap affordance) */

/* A period production dims the house lights; it does not frost the glass —
   the blur lives ONLY off the flagship look (vintage must never pay for it). */
html:not([data-theme="vintage"]) .overlay { backdrop-filter: blur(2px); }

/* DEV style switch (local review only, ?dev=1) — exempt from the token law */

/* revive flow + death card: stacked plates, printed rhythm (16/10/8) */
#deathScreen .play-btn + .play-btn { margin-top: 10px; }
#deathScreen .rule-link.lg + .play-btn { margin-top: 10px; }
#deathScreen .revive-used + .rule-link { margin-top: 8px; }
#revivePanel .tasks-list + .play-btn { margin-top: 14px; }

/* ── quiet text control — the typeset rule-link (acts ⇒ underlined 2px/4px;
   informs ⇒ never underlined: .tiny/.fineprint/.revive-used) ── */
.rule-link { background: none; border: none; cursor: pointer; pointer-events: auto;
  min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
  padding: 10px 14px; font-family: var(--disp); font-size: 15px; letter-spacing: .08em;
  line-height: 1.35; text-transform: uppercase; color: var(--accent-on-paper);
  -webkit-text-stroke: var(--stroke-card) var(--ink); paint-order: stroke fill;
  text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 2px; }
/* the rank pull is a SENTENCE, not a headline: at 17px it ran three underlined
   lines and out-shouted the plate under it. A measure + balance keeps it a quiet
   two-line pull, and a 1.5px rule stops three stacked underlines reading as bars. */
.rule-link.lg { display: block; width: 100%; margin: 4px auto 0; padding: 12px 14px;
  font-size: 15.5px; text-wrap: balance; text-underline-offset: 4px;
  text-decoration-thickness: 1.5px; animation: rankpop-steps .3s steps(2); }
.rule-link.sm { font-family: var(--chat); font-size: 12.5px; letter-spacing: .14em; }
.rule-link:active { transform: translateY(1px); }                   /* discrete */
.rule-link[disabled] { color: var(--muted); text-decoration: none; pointer-events: none; }
@keyframes rankpop-steps { from { transform: scale(.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }

/* SHARE's WEIGHT is a function of whether the run EARNED a brag (resolveBrag in
   game.js), not a fixed rung. THREE TIERS, ONE LOUD SLOT: quiet = this bare
   rule-link, ghost = .primary-share, plate = .share-plate. RESTART is still
   FIRST and instant in the reading order — on the plate tier it steps down to a
   ghost exactly as it already does for a loud revive, and in a letterpress
   billing ladder the biggest type wins the eye wherever it sits. A loud REVIVE
   always outranks a loud SHARE: two plates on one card is the shakedown look. */
#deathShare.primary-share {   /* same metrics as .play-btn.ghost — one ghost plate in the house */
  display: block; width: 88%; margin: 10px auto 0;
  min-height: 50px; padding: 11px 14px;
  border: 3px solid var(--white); border-radius: 14px;
  background-image: var(--paper-grain), var(--btn-ghost);
  color: var(--white); -webkit-text-stroke: 0;
  font-size: clamp(15px, 4.2vw, 17px); letter-spacing: .05em;
  text-decoration: none; box-shadow: var(--pop);
}
#deathShare.primary-share:active { transform: translateY(2px); }

/* THE PLATE TIER — the full .play-btn metrics, shaped exactly like the ghost
   block above so no fourth control family enters the house: same paper, same
   double ink rule, same 5px ink drop, same discrete press. Because both the
   plate and .play-btn are width:100%, swapping the label for an
   acknowledgement can never reflow the card. */
#deathShare.share-plate {
  position: relative; display: block; width: 100%; margin: 10px auto 0;
  min-height: 58px; padding: 12px 22px;
  font-size: clamp(19px, 5.4vw, 21px); letter-spacing: .05em; line-height: 1;
  color: var(--chip-text); -webkit-text-stroke: 0;
  background-image: var(--paper-grain), var(--btn);
  border: 4px solid var(--white); border-radius: 16px;
  text-decoration: none; box-shadow: var(--pop);
}
#deathShare.share-plate:active { transform: translateY(2px); }
/* .primary-share (1,1,0) outranks .rule-link[disabled] (0,2,0) on colour, so a
   spent ghost-plate stayed full ink with its shadow and looked alive. Match the
   disabled treatment on BOTH share families. */
#deathShare.primary-share[disabled], #deathShare.rule-link[disabled] {
  color: var(--muted); box-shadow: none; opacity: .75;
}
#deathShare.share-plate[disabled] {
  background-color: var(--chip); background-image: var(--paper-grain);
  color: var(--muted); box-shadow: none;
}
/* the acknowledgement re-fires the house stamp, on twos like every card motion */
#deathShare.acked, #winShare.acked, #endShare.acked { animation: pop-steps .34s steps(2); }
#deathShare.acked2, #winShare.acked2, #endShare.acked2 { animation: pop-steps2 .34s steps(2); }

/* ── TOP PILOTS leaderboard — ledger rows, paper chips ── */
.lb-title { margin-bottom: 4px; }
/* the ledger rides the card's content column: at min(320px, 84vw) it was wider
   than the paper it printed on, so auto margins collapsed and every row leaned
   left and overran the plate's own margin. Its HEIGHT is measured in whole rows
   (pitch = row + gap) — a free vh cap sliced the last row in half behind the
   sticky YOU chip. 9 pitches, less the trailing gap, = 8 clean rows + yours. */
.lb-list { display: flex; flex-direction: column; gap: 5px; overflow-y: auto;
  --lb-pitch: 37px;
  max-height: min(46vh, calc(9 * var(--lb-pitch) - 5px));
  width: 100%; max-width: 320px; margin: 12px auto 4px; }
.lb-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; min-height: 32px;
  font-variant-numeric: lining-nums tabular-nums; font-feature-settings: 'lnum' 1, 'tnum' 1;
  border-radius: 8px;
  background-color: var(--well);
  border: 2px solid var(--rule-faint); font-family: var(--chat);
  font-size: 13px; color: var(--white); }
.lb-row .lb-rank { width: 30px; border-right: 1.5px solid var(--rule-faint); padding-right: 6px; margin-right: 2px;
  color: var(--accent-on-paper); font-weight: 800; flex: 0 0 auto; text-align: left; }
.lb-row .lb-name { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-row .lb-score { font-weight: 800; }
.lb-you { margin: 10px 0 2px; }
.lb-you input { width: min(240px, 100%); min-height: 46px; padding: 11px 12px; border-radius: 10px; text-align: center;
  border: 2.5px solid var(--ink-faint); background: var(--well); color: var(--white);
  font-family: var(--disp); font-size: 15px; letter-spacing: .08em; outline: none; }
.lb-you input::placeholder { color: var(--muted); opacity: .6; }
.lb-you input:focus { border-color: var(--white); }   /* the lit frame — game 2's focus grammar */
#lbPanel .play-btn { margin-top: 12px; }
/* the CTA used to wrap to two lines and get clipped by Safari's toolbar */
#lbConnect { font-size: clamp(15px, 4.4vw, 20px); letter-spacing: .04em; white-space: nowrap; padding-left: 18px; padding-right: 18px; }
/* your row is what the eye hunts for: invert it, pin it in view, and give the
   inverted chip its own paper pinstripe */
/* the elision row: skipped ranks, printed as a run of leaders */
.lb-gap { justify-content: center; min-height: 20px; padding: 0 10px; opacity: .5;
  letter-spacing: .3em; pointer-events: none; border: 0; }
.lb-gap .lb-name { text-align: center; }
.lb-row.you {
  background: var(--chip-you, var(--gold)); color: var(--chip-you-text, var(--ink));
  border-color: var(--white); position: sticky; bottom: 0;
  box-shadow: inset 0 0 0 2px var(--chip-you), inset 0 0 0 3.5px var(--chip-you-text), 0 2px 0 var(--shadow-drop);
}
.lb-row.you .lb-rank { border-right-color: var(--chip-you-text); color: var(--chip-you-text); }
.challenge-line {
  font-family: var(--disp); font-size: 17px; letter-spacing: .06em; margin-top: 8px;
  color: var(--accent-on-paper); -webkit-text-stroke: var(--stroke-card) var(--ink); paint-order: stroke fill;
}
.death-cause { font-size: .34em; letter-spacing: .1em; -webkit-text-stroke: 0; color: var(--muted); vertical-align: middle; }
.revive-used { font-family: var(--chat); font-size: 12px; letter-spacing: .08em;
  color: var(--muted); margin: 6px 0 0; }
/* the legal truth informs, it never competes: 600-weight ink at full size read as
   loud as the plate above it */
.trust { max-width: 32ch; margin: 10px auto 0; font-size: 11.5px; font-weight: 400; line-height: 1.45; opacity: .78; }
/* …YOUR row is an inverted ink chip, so its cells must flip back or they
   render black-on-black (equal specificity — this rule has to come after) */
[data-theme="vintage"] .lb-row.you .lb-rank,
[data-theme="vintage"] .lb-row.you .lb-name,
[data-theme="vintage"] .lb-row.you .lb-score { color: var(--chip-you-text); }

/* fine print — the period-honest home for technical truth (tx states, burn terms) */
.fineprint { display: inline-block; margin-top: 8px; font-size: 11.5px; line-height: 1.45;
  letter-spacing: .02em; opacity: .78; text-wrap: balance; }

/* long period labels must not shatter layouts */
.boss-name { white-space: nowrap; font-size: clamp(13px, 4.6vw, 20px); }

/* ── S2: the film's native grammar ─────────────────────────────────────── */
/* THE END — white-on-black studio ident, the only frame with no paper */
#endCard { background: var(--screen); }
.end-inner { text-align: center; padding: 0 22px; max-width: 100vw; }
/* 20vw ran THE END off both edges of the frame — a title card that doesn't fit
   the screen is not a title card. 15vw is the widest that stays inside the margins. */
.end-the { font-family: var(--disp); font-size: clamp(44px, 15vw, 120px); line-height: 1; color: var(--white); letter-spacing: .04em; }
.end-ident { font-family: var(--credit); font-size: 14px; letter-spacing: .3em; text-indent: .3em; color: var(--gold); margin-top: 20px; text-transform: uppercase; }
.end-tap { font-family: var(--credit); font-size: 12px; color: var(--gold-dk); margin-top: 38px; animation: endblink 1.6s steps(1) infinite; }
/* WIRE THE NEWS — the ident is the only card with no paper under it, so the
   quiet link prints in gold. It sits AFTER tap-to-continue and stops its own
   click, so tapping anywhere else on the card still deals the win card. */
.end-share { margin-top: 6px; color: var(--gold); -webkit-text-stroke: 0; }
@keyframes endblink { 0%,100% { opacity: .85; } 50% { opacity: .25; } }

/* Episode intertitle: a paper plate with a double ink rule, lantern-slide in */
.title-card {
  position: absolute; left: 50%; top: 26%; transform: translateX(-50%);
  z-index: 6; pointer-events: none; text-align: center;
  min-width: min(320px, 78vw); padding: 20px 30px 22px;
  background: var(--card, #f2eee4);
  border: var(--card-border, 3px solid var(--ink));
  border-radius: var(--card-radius, 10px);
  box-shadow: inset 0 0 0 8px var(--card), inset 0 0 0 10px var(--rule-faint), var(--float);
  animation: slidein .28s steps(2) both;
}
.title-card.out { animation: slideout .24s steps(2) both; }
@keyframes slidein { from { opacity: 0; transform: translate(-50%, 5px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes slideout { from { opacity: 1; } to { opacity: 0; } }
.tc-eyebrow { font-family: var(--body, serif); font-size: 12.5px; letter-spacing: .3em; color: var(--muted); text-transform: uppercase; }
.tc-name { font-family: var(--disp); font-size: clamp(30px, 9vw, 46px); color: var(--white); margin: 4px 0 2px; }
.tc-sub { font-family: var(--body, serif); font-style: italic; font-size: 14px; color: var(--muted); }
.tc-boss { display: none; }
.tc-type { display: contents; }   /* the plain card keeps its exact old layout */

/* THE IRIS TITLE CARD (founder pick) — the splice's black leader now carries
   a real Mickey-short opening: a drawn iris circle with a double ink ring,
   the chain's keeper peeking through as a silhouette, the episode type over
   it. Irises in on threes like a hand-cranked print. */
.title-card.leader {
  top: 50%; transform: translate(-50%, -50%);
  width: min(78vw, 330px); height: min(78vw, 330px);
  min-width: 0; padding: 0; border-radius: 50%;
  display: grid; place-items: center; overflow: hidden;
  border: 3px solid var(--white);
  box-shadow: inset 0 0 0 5px var(--card), inset 0 0 0 7px var(--rule-faint),
              0 0 0 8px rgba(242, 238, 228, 0.12);
  animation: irisIn .4s steps(3) both;
}
.title-card.leader.out { animation: irisOut .24s steps(2) both; }
@keyframes irisIn { from { opacity: 0; transform: translate(-50%, -50%) scale(.18); }
                    to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
@keyframes irisOut { from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
                     to { opacity: 0; transform: translate(-50%, -50%) scale(.18); } }
.title-card.leader .tc-boss {
  display: block; position: absolute; left: 50%; bottom: -34%;
  width: 60%; transform: translateX(-50%);
  filter: brightness(0); opacity: .45;   /* the keeper RISES into the iris — a shadow peeking, never eating the type */
}
.title-card.leader .tc-type { display: block; position: relative; padding: 0 26px; }
.title-card.leader .tc-eyebrow::before { content: "— "; letter-spacing: 0; }
.title-card.leader .tc-eyebrow::after { content: " —"; letter-spacing: 0; }
.title-card.leader .tc-name { font-size: clamp(34px, 11vw, 54px); }
.title-card.leader .tc-sub { max-width: 230px; margin: 0 auto; }

/* Academy film leader: countdown circle w/ clock wipe, crosshair, flutter */
.leader {
  position: relative; width: 140px; height: 140px; margin: 24px auto 8px;
  border-radius: 50%; border: 3px solid var(--rule-faint);
  box-shadow: 0 0 0 6px var(--card), 0 0 0 7.5px var(--rule-faint);
  overflow: hidden;
}
.leader-sweep {
  position: absolute; inset: 0; border-radius: 50%;
}
.leader-cross { position: absolute; inset: 0; }
.leader-cross::before, .leader-cross::after {
  content: ""; position: absolute; background: var(--rule-half);
}
.leader-cross::before { left: 50%; top: 0; bottom: 0; width: 1.5px; }
.leader-cross::after { top: 50%; left: 0; right: 0; height: 1.5px; }
.leader-num {
  position: absolute; inset: 0; display: grid; place-items: center;
  font-family: var(--body); font-weight: 700;
  font-size: 60px; color: var(--white);
}
#loadScreen .overlay-card { animation: flutter .34s steps(1) infinite; }
/* ── THE LOADING SCREEN (round 30) ─────────────────────────────────────────
   OPAQUE, and that is the point. The old loader sat on a 22%-transparent
   scrim, so the moment the art settled the real stage and the real cat came
   up THROUGH it while it was still waiting on fontReady — which is exactly
   the "game 1 UI plus lobby character plus spinner" the founder was looking
   at. Solid film black shows one thing at a time.

   No grid, no place-items: the canvas IS the screen and sizes itself to the
   box in loader.js. #loadNote keeps its place under the number so the 12s
   dead-boot watchdog still has somewhere to speak. */
/* z-30, not the overlay's 20: game 1's top-bar controls and the safe-area
   fades sit at 21+ and were painting over the loading screen. Nothing else is
   ever on screen while this is up. */
#loadScreen {
  /* OPAQUE FROM THE FIRST PAINT, not just once loader.js has parsed — the
     fallback card underneath is what a player sees for the first second and
     it was showing game 1's HUD through the scrim. */
  background: var(--screen);
  z-index: 30;
}
#loadScreen.load-screen {
  display: block; padding: 0;
  background: var(--screen);
  animation: none;
}
#loadScreen.load-screen #loadCanvas {
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  display: block;
}
/* THE TOWER BORROWS THE SCREEN. #game2root is z-60 and opaque, and it must
   stay VISIBLE while the tower loads — the renderer sizes itself from that
   element's box and a hidden one measures zero — so the loading screen goes
   OVER it for that phase instead of a second veil underneath. Removed again
   at onReady, which is the tower's own first painted frame. */
#loadScreen.over-game { z-index: 70; }
/* nothing of the lobby is on screen before the curtain — see index.html's
   <body class="booting"> for why this cannot be solved with a z-index. */
body.booting #lobbyStrip { display: none !important; }
/* the note is positioned the same whether or not loader.js has mounted — it is
   the one thing that outlives the screen's own contents */
#loadScreen #loadNote {
  position: absolute; left: 0; right: 0; bottom: 8%;
  margin: 0; text-align: center; color: var(--muted);
  font-family: var(--chat); font-size: 12px; letter-spacing: .06em;
}
@keyframes flutter { 0%,100% { opacity: 1; } 55% { opacity: .93; } }

/* the HUD steps aside while the iris owns the frame */
.hud.dimmed { opacity: .12; transition: opacity .25s ease; }

/* ── S5: period print & the keep-the-mistakes motion pass ─────────────────
   Machine-perfect easing is a 2020s tell. UI motion steps like a hand-cranked
   print: cards deal in on twos, pops land in two frames, the score clunks over
   like an odometer wheel. */
.overlay:not(.hidden) .overlay-card { animation: cardIn .22s steps(2) both; }
/* the alternating pair: the death card re-triggers its deal-in at the iris
   hand-off with a class toggle — no reflow, no forced layout */
.overlay:not(.hidden) .overlay-card.deal2 { animation-name: cardIn2; }
/* THE POP STAYS (founder, 3 Sept, after the real fix landed). It was taken away
   on a wrong reading — cardIn's filled transform was blamed for the flat invite
   modal, when that card simply had no filter on it at all (see the party face
   in coop.js/tvglass.js). Panels pop in as they always did, and they bend. If a
   panel is ever seen flat again, `body.tv-on .overlay:not(.hidden)
   .overlay-card { animation: none }` is the one line that tests this door. */
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes cardIn2 { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
#distance.roll { animation: odoroll .28s steps(2); }
#distance.roll2 { animation: odoroll2 .28s steps(2); }
@keyframes odoroll { 0% { transform: skewX(-6deg) translateY(-3px); } 50% { transform: skewX(-6deg) translateY(2px); } 100% { transform: skewX(-6deg); } }
@keyframes odoroll2 { 0% { transform: skewX(-6deg) translateY(-3px); } 50% { transform: skewX(-6deg) translateY(2px); } 100% { transform: skewX(-6deg); } }
[data-theme="vintage"] #distance.roll { animation: odoroll-flat .28s steps(2); }
[data-theme="vintage"] #distance.roll2 { animation: odoroll-flat2 .28s steps(2); }
@keyframes odoroll-flat { 0% { transform: translateY(-3px); } 50% { transform: translateY(2px); } 100% { transform: none; } }
@keyframes odoroll-flat2 { 0% { transform: translateY(-3px); } 50% { transform: translateY(2px); } 100% { transform: none; } }
@keyframes pop-steps { from { transform: scale(.6); } to { transform: none; } }
/* the alternating twin: an acknowledgement label swap re-fires the stamp with a
   class toggle — no reflow, no void offsetWidth */
@keyframes pop-steps2 { from { transform: scale(.6); } to { transform: none; } }
@keyframes pop-steps-center { from { transform: translateX(-50%) scale(.6); } to { transform: translateX(-50%); } }
[data-theme="vintage"] .banner { animation-timing-function: steps(3); }
/* the logo bob breathes on sixes — a hand-held rostrum camera, not a tween */
[data-theme="vintage"] .overlay-card h1 { animation-timing-function: steps(6); }

/* ── S5 coherence fixes (blind panel round 2) ─────────────────────────────
   The keeper's name lives ONCE, in the top chip — the mid-screen DOM ghost
   read as a z-order bug to every judge who saw it. */
[data-theme="vintage"] .boss-name { display: none; }
/* Meters are printed gauges, not app pills: ink frame, paper gap, ink fill. */
/* IN-RUN INSTRUMENTS ARE CERTIFIED (round 21): the gauges keep their printed
   paper-and-ink look via the stage tokens (--gold / --stage-rule), NOT the kit
   card tokens — the flight's look is the client-approved one and stays. */
[data-theme="vintage"] .boss-bar, [data-theme="vintage"] .boost-meter {
  border-radius: 3px; border: 3px solid var(--ink); background: var(--gold, #efece3);
  padding: 2px; height: 18px; box-sizing: border-box; box-shadow: 0 3px 0 var(--stage-rule);
}
[data-theme="vintage"] .boost-meter { height: 16px; }
[data-theme="vintage"] .boss-fill, [data-theme="vintage"] .boost-fill {
  background: var(--ink); box-shadow: none; border-radius: 1px; transition: width .15s steps(3);
}
/* Graduation ticks so an empty gauge still reads as a printed instrument. */
[data-theme="vintage"] .boss-bar, [data-theme="vintage"] .boost-meter {
  background-image: repeating-linear-gradient(90deg, transparent 0 9px, var(--stage-rule) 9px 10px);
}
/* REDLINE (M2): the STEAM track's last 15% is cross-hatched — a printed
   danger zone — and past it the whole instrument rattles on twos. */
[data-theme="vintage"] .boost-meter {
  background-image: repeating-linear-gradient(90deg, transparent 0 9px, var(--stage-rule) 9px 10px),
    repeating-linear-gradient(45deg, transparent 0 3px, var(--stage-rule) 3px 5px);
  background-size: 100% 100%, 15% 100%;
  background-position: 0 0, 100% 0;
  background-repeat: repeat, no-repeat;
}
.boost-meter.redline { animation: needlerattle .18s steps(2) infinite; }
@keyframes needlerattle { 0% { transform: translateX(-1px); } 100% { transform: translateX(1px); } }
/* The 6px stroke ate the slab face's letter gap — CRASHED read double-struck. */
[data-theme="vintage"] .overlay-card h1 { letter-spacing: .1em; -webkit-text-stroke-width: 5px; }

/* ── M3: THE VERDICT + THE ALBUM ──────────────────────────────────────────
   The verdict is a letterpress plate stamped over the keeper's fall — paper
   plate, double ink rule (the title-card material), steps() motion, no glow
   (§0.8). It waits half a beat so the KO card lands first. */
.verdict {
  position: absolute; left: 50%; top: 46%; z-index: 5; pointer-events: none;
  transform: translateX(-50%) rotate(-3deg);
  font-family: var(--disp); font-size: clamp(17px, 5vw, 30px); letter-spacing: .12em;
  color: var(--white); text-transform: uppercase; text-align: center; white-space: nowrap;
  padding: 10px 20px 12px; max-width: 94vw;
  background: var(--card);
  border: 3px solid var(--white);
  border-radius: 12px;
  box-shadow: inset 0 0 0 2.5px var(--card), inset 0 0 0 4px var(--rule-faint), var(--pop);
  opacity: 0;
}
.verdict.play { animation: verdictstamp .3s steps(2) both; animation-delay: .45s; }
.verdict.play2 { animation: verdictstamp2 .3s steps(2) both; animation-delay: .45s; }
@keyframes verdictstamp2 { from { opacity: 0; transform: scale(1.6); } to { opacity: 1; transform: scale(1); } }
@keyframes verdictstamp {
  from { opacity: 0; transform: translateX(-50%) rotate(-3deg) scale(1.5); }
  to   { opacity: 1; transform: translateX(-50%) rotate(-3deg) scale(1); }
}

/* The album line — six printer's marks (● ◐ ✕ ○), small type under the
   subtitle / stats. Sorts from the case, not emoji. */
.album-line {
  font-family: var(--chat); font-size: 12px; letter-spacing: .28em; text-indent: .28em;
  color: var(--muted); margin-top: 6px; text-transform: uppercase;
}
[data-theme="vintage"] .album-line { color: var(--white); opacity: .72; }

/* ── M4: TODAY'S ROUTE — the daily appointment ────────────────────────────
   The schedule card is set type only: the postmark is a rotated double ring
   with the route number inside (a rubber stamp, not an image). The board's
   TODAY · ALL-TIME line is one typeset rule — the live word underlined like
   a typeset link (§0.8), never a modern pill tab. */
.lb-tabs { display: flex; align-items: center; justify-content: center; gap: 2px; margin: 2px 0 0; }
.lb-tab {
  background: none; border: none; cursor: pointer; pointer-events: auto;
  min-height: 44px; display: inline-flex; align-items: center; padding: 6px 10px;
  font-family: var(--disp); font-size: 14px; letter-spacing: .1em; color: var(--accent-on-paper);
  -webkit-text-stroke: var(--stroke-card) var(--ink); paint-order: stroke fill; opacity: .62;
}
.lb-tab.on { opacity: 1; text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 2px; }
.lb-tab:active { transform: translateY(1px); }
.lb-tab-sep { font-family: var(--disp); font-size: 14px; color: var(--muted); }
.lb-subhead { margin: 2px 0 0; letter-spacing: .18em; text-transform: uppercase; }

.daily-post {
  width: 148px; height: 148px; margin: 16px auto 2px; border-radius: 50%;
  border: 2.5px solid var(--white);
  box-shadow: inset 0 0 0 5px var(--card), inset 0 0 0 6.5px var(--rule-faint);
  display: grid; place-content: center; gap: 2px; transform: rotate(-7deg);
}
.daily-no {
  font-family: var(--disp); font-size: 34px; letter-spacing: .04em;
  color: var(--accent-on-paper); -webkit-text-stroke: var(--stroke-card) var(--ink); paint-order: stroke fill;
}
.daily-hint { margin: 14px 0 16px; }
#dailyStatus { color: var(--accent-on-paper); }   /* a status is not a challenge banner */
#dailyPanel .play-btn { margin-top: 10px; }
#dailyPodium { max-height: 24vh; margin-top: 4px; }

/* ── transition grammar: every user-dismissed overlay exits (dismiss() in
   game.js); being covered by a stacked panel is NOT a dismissal ── */
.overlay.closing { animation: fadeout .18s ease both; pointer-events: none; }
/* descendants re-assert pointer-events:auto (overlay hit-testing law), which
   would keep an invisible fading panel tappable — a dismissing overlay is inert */
.overlay.closing * { pointer-events: none !important; }
.overlay.closing .overlay-card { animation: cardOut .18s steps(2) both; }
@keyframes fadeout { to { opacity: 0 } }
@keyframes cardOut { to { opacity: 0; transform: translateY(4px) } }
/* no infinite animation may run on occluded elements (S6): show()/hide()
   keep body.overlay-up current; everything else infinite lives inside
   overlays and stops via display:none */
body.overlay-up .ring-icon::after, body.overlay-up .chev { animation-play-state: paused; }

/* ── H2b THE HOME STAGE — DOM props over the canvas scene ─────────────────
   The scene (backdrop, boards, spotlight, cat, curtains) is canvas-drawn and
   film-graded; #startScreen carries only set dressing. The container is
   pointer-transparent so tap-anywhere reaches the stage; every prop that
   acts re-asserts pointer-events. House dim lives in canvas layer 4 — the
   overlay wash and the blur are gone from this screen on every theme. */
#startScreen { display: block; background: none; animation: none; pointer-events: none; }
html:not([data-theme="vintage"]) #startScreen { backdrop-filter: none; }
#startScreen .marquee, #startScreen .cat-bubble, #startScreen .start-hint { pointer-events: none; }
#startScreen .challenge-line { display: none; }   /* text carrier for the bubble ladder */

/* the hanging marquee plate — title-card material, bobbing on sixes */
/* ---- THE CURTAIN CALL: the drawn logo, and the paper apron ------------------
   The painting occupies a panel whose exact top/bottom the canvas publishes as
   --stage-top / --stage-bot (one source of truth: if the pan-and-scan maths
   changes, the paper follows it without a second set of numbers). Below the
   panel is the apron: real paper, where every control lives. */
:root { --stage-top: 0%; --stage-bot: 100%; --cat-top: 42%; }

/* THE WORD SITS ON THE STRIP'S CENTRE LINE, IN CSS (founder, 31 Aug — "it
   still jumps"). It used to be anchored at 1.2% of the panel and then NUDGED
   into place by strip.js measuring the logo and writing a transform. That
   measurement ran at DOMContentLoaded, when the logo's box is still 0 high, so
   the first answer was wrong by ~50px and a 700ms poll corrected it up to five
   seconds later: the jump, exactly.

   Nothing needs measuring. The strip's buttons are `top: var(--g2strip-top)`
   with a 40px box, so their centre line is --g2strip-top + 20px — and
   --g2strip-top is itself pure CSS off --ui-top, so it is right on the FIRST
   paint, Telegram's tall inset included. The marquee's vertical padding is
   symmetric, so its box centre IS the logo's centre: put the box centre on
   that line and the word is centred with the mute and wallet icons from the
   frame it first appears in. No JS, nothing to correct, nothing to jump. */
.marquee {
  position: absolute; left: 0; right: 0; margin: 0 auto;
  top: calc(var(--g2strip-top) + 20px);
  transform: translateY(-50%);
  width: fit-content; max-width: 68vw;
  padding: 10px 18px;                     /* air on all four sides, per the note */
  text-align: center; pointer-events: none; z-index: 3;
}
.cc-logo {
  display: block; width: calc(var(--logo-k, 1) * min(62vw, 360px)); height: auto;
  /* the drawn line is already inked; it only needs to sit ON the world, so a
     single hard letterpress drop, no glow, no stroke, no skew */
  filter: drop-shadow(0 3px 0 var(--shadow));
}
.album-line, .challenge-line { position: relative; z-index: 3; }

@media (min-aspect-ratio: 95/100) {
  .cc-logo { width: calc(var(--logo-k, 1) * min(30vw, 340px)); }
}
/* THE PILOT'S STRIP is GONE (R21 dead-CSS sweep) — no markup consumer;
   game.js only null-queries it. The strip.js kit row carries identity now. */

/* the two side buttons hang under the strip, right-aligned to the SAME inset as
 ROUTE in the deck below — one number, so the two edges can never drift */
/* THE SIDE COLUMN IS GONE. Two chips hanging top-right and two more at the
   bottom read as two unrelated menus; all four now sit in one row on the deck.
   Anything still selecting .side-col is selecting nothing. */


.cat-bubble {
  /* Hangs off his CROWN, so it can never cover him. It needs no floor against the
     pilot strip: a max() would only shove it DOWN onto his head, and the two
     constraints genuinely have no solution when the crown rides above the strip.
     Room is guaranteed upstream instead — catH is capped so the crown always
     clears the strip plus this bubble (see CAT_HEAD_ROOM in game.js). */
  /* THE BUBBLE BELONGS TO THE PILOT, NOT THE WINDOW. This was left:50% — the
     centre of the VIEWPORT — while the cat stands wherever the painting's lit
     pool is, and now slides right when a friend arrives. Its tail pointed at
     empty boards about 68px from his mouth. --cat-cx has carried his real
     horizontal mark all along and was read by nothing. */
  /* --bob is THE SLOT'S LIVE BREATH (game.js publishes --cat-bob/--chair-bob
     per frame; the two rules below bind each family to its own slot). The
     balloon stack stretches with the body instead of hanging still over it.
     SPEECH RIDES ABOVE THE NAME (founder, 30 Aug) — --tag-h is the plate it
     stands on. */
  /* WIDTH, EXPLICITLY. An absolutely-positioned box with width:auto shrink-to-
     fits into whatever space is left of its `left` — so once the pilot moved to
     75% the bubble had 125px to live in and folded into a narrow column. It is
     sized by its own content now, capped so that a box centred on him still
     clears the frame. */
  /* THE KIT BUBBLE (round 21, founder brief): a TRUE 30s comic balloon — a
     roomy hand-drawn oval, flat cream fill, bold ink line, NOTHING printed
     inside it (no grain, no gradients — a letterer's balloon is blank paper).
     The wobbly elliptical radii are the hand; each balloon family wears a
     slightly different wobble so a cascade reads as a lettered page, not a
     cloned sticker. Speech is set in the chat voice (G2Body — the bouncy
     hand-caps), untracked, as lettering is. */
  position: relative;                           /* THE SEAT carries left/top/transform (the seats round) */
  width: max-content; max-width: min(58vw, 250px); padding: 10px 17px 12px;   /* 58vw (founder, 3 Sept: never three lines): at 375px the old 46vw = 172px wrapped five of the second world's lines to three */
  font-family: var(--chat); font-size: 14px; letter-spacing: 0; line-height: 1.35;
  text-align: center; text-wrap: balance;  /* two even lines, never a one-word second line */
  color: var(--k-bubble-text);   /* lines arrive typeset — "No." keeps its period case */
  background-color: var(--k-bubble-fill);
  border: var(--k-bubble-border) solid var(--k-bubble-ink);
  border-radius: var(--k-bubble-radius);   /* the lumpy oval */
  box-shadow: var(--k-bubble-shadow);   /* the cel shadow — flat, on the stage's own ink */
  animation: bubpop .22s steps(2) both;
}
.cat-bubble.bub2 { animation-name: bubpop2; }   /* alternating names restart the pop — no reflow */
.cat-bubble::after {   /* the tail: a rotated square pointing at the cat's mouth */
  content: ""; position: absolute; left: 50%; bottom: -8px;
  /* IT PAINTS UNDER THE TYPE. The tail is an opaque rotated square whose UPPER
     half sits inside the bubble, and as ::after it painted after the text — so
     that half did not "overlap" the letters, it ERASED them, in the bubble's own
     background colour. The founder photographed it eating an "a" and a "wh".

     z-index:-1 is the whole fix and it needs no new shape. Inside a stacking
     context the paint order is background, then negative-z children, then inline
     content — so the tail lands above the bubble's own background and below its
     text. The buried half is invisible (same colour as what it covers), the half
     hanging below still draws, and the letters are untouched. A triangle would
     also have worked, but it would have cost the rounded nib tip. */
  z-index: -1;
  width: 15px; height: 15px;
  /* TALLER, NOT WIDER. scaleY is applied AFTER the rotation (transforms read
     right to left), so it stretches the finished diamond in screen space
     rather than skewing the square before it turns. The diamond's shoulders
     sit exactly on the bubble's bottom edge and scaling about the centre
     leaves points at that height untouched — so the join keeps its width and
     only the point travels further down. */
  /* THE 30s SPIKE: the sides of the V curve inward (big radii on the two
     shoulder corners), the point keeps a tiny nib, and the whole tail leans
     a few degrees the way a letterer flicks it toward the mouth. */
  transform: translateX(-50%) scaleY(var(--tail-h, 1.9)) rotate(var(--k-bubble-tail));
  background-color: var(--k-bubble-fill);
  border-right: var(--k-bubble-border) solid var(--k-bubble-ink); border-bottom: var(--k-bubble-border) solid var(--k-bubble-ink);
  border-radius: 0 62% 4px 62%;
}
/* scale-only pop: visibility must never ride an animation's end state */
/* THE BOB RIDES THE POP TOO — a keyframe that dropped it would snap the
   balloon off the breath for the pop's 0.22s and back on after it. */
@keyframes bubpop  { from { transform: scale(.6); } to { transform: none; } }   /* the seat holds the place now (the seats round): the pop is a bare scale */
@keyframes bubpop2 { from { transform: scale(.6); } to { transform: none; } }
body.tv-on .cat-bubble { animation: none; }   /* a bent box must not animate a transform (WebKit paints it twice) */

/* corner-column letterpress chips — paper chip material, fixed column spots */
/* the two corner columns: absolutely placed, but their chips are IN FLOW so a
   two-line chip pushes instead of overprinting; pointer-transparent between
   chips so tap-anywhere still reaches the stage */
/* ---- THE CONTROL DECK ------------------------------------------------------
   Founder's layout, mapped off the reference lobby: PLAY dead centre at the
   bottom, SOUND to its left, ROUTE to its right, and MISSIONS + TOP PILOTS
   stacked in a column that hangs directly above ROUTE. Buttons are PROPS: the
   illustrated object is the button, and a small caps label only names it, so the
   type can stay quiet while the object does the talking. */
.deck {
  position: absolute; left: 0; right: 0; bottom: calc(2.6% + var(--ui-bottom));
  /* CENTRED, NOT BOTTOM-ALIGNED. flex-end lined the plaque's bottom up with the
     bottom of the props' LABELS, so PLAY's optical centre floated above the two
     icons either side of it. Centring makes the three read as one row. */
  /* TWO ROWS: the ways to fly, then the chips. Was a single horizontal
     space-between of [MUSIC][play][ROUTE], which is why the other two chips had
     to live somewhere else entirely. */
  display: flex; flex-direction: column; align-items: stretch;
  gap: 10px; box-sizing: border-box;
  /* each side takes ITS OWN inset — one shared value applied to both would
     shift the deck sideways on a notched phone held in landscape */
  padding-left: calc(var(--lobby-pad) + env(safe-area-inset-left, 0px));
  padding-right: calc(var(--lobby-pad) + env(safe-area-inset-right, 0px));
  /* ABOVE THE CHAIR. The invite silhouette is a tall hit area anchored to the
     stage floor, and that floor line sits BEHIND the deck — so with the chair
     at z-index 6 and the deck at 3, the chair's lower third covered CO-OP PLAY
     and swallowed every tap on it. The controls always win: a decorative hit
     area must never be able to eat a button. */
  pointer-events: none; z-index: 9;
}

/* THE PROP CHIPS are GONE (R21 dead-CSS sweep) — no markup consumer;
   game.js only null-queries .prop-label/.prop-art. */

/* PLAY: the one loud thing on the stage. Its own material, twice the presence. */
/* ══ THE GAME CARDS (round 18) — the picker on the stage ═══════════════
   The lobby's own material: paper chip, ink frame with the hand-cut wobble,
   letterpress inner rule, hard shadow. Selected lifts and takes the check;
   locked wears the HOST PICKS band; unavailable dims with its reason. */
/* ══ THE SHEET (round 18) — Fortnite grammar for the deck ══════════════
   Plaques above, cards below, the cards PEEK past the fold; a swipe rides
   the whole sheet over the dimming lobby. The transform is the only thing
   that moves. */
.play-sheet {
  position: relative; display: flex; flex-direction: column; align-items: center;
  gap: 0; pointer-events: auto; touch-action: pan-x;
  margin-bottom: calc(-1 * var(--sheet-peek, 0px));
  transform: translateY(var(--sheet-y, 0px));
  transition: transform .28s cubic-bezier(.22,.9,.3,1);
  will-change: transform; z-index: 3;
}
.play-sheet.dragging { transition: none; }
/* THE GRIP — the drawer's tell, in the house hand: a letterpress pill, ink
   frame, the same wobble the plaques wear. Flat with them (founder, 30 Aug). */
.sheet-grip {
  width: 58px; height: 9px; margin: 0 0 10px;
  background-color: var(--k-grip-fill); background-image: var(--paper-grain);   /* its own token since 3 Sept: the first world's value is plaque-fill's, verbatim */
  border: var(--k-grip-border) solid var(--k-grip-ink);
  border-radius: var(--k-grip-radius);
}
.sheet-dim {
  position: absolute; inset: 0; z-index: 2; pointer-events: none;
  background: #000; opacity: var(--sheet-dim, 0);
  transition: opacity .28s cubic-bezier(.22,.9,.3,1);
}
.sheet-dim.dragging { transition: none; }

/* ══ THE GAME CARDS — Fortnite-minimal, the founder's metric spec ═══════
   Card width == plaque width; card gap == the plaque row's own 10px; the
   section gap matches it. Type = the house display face (the plaques'
   var(--disp)) — 'Luckiest Guy' was never actually loaded and Telegram's
   webview exposed the fallback. The cover is an <img> with a root-absolute
   src, one URL from / and /tg/ alike. The live count is the Fortnite pill
   on the cover: [two figures] N active. */
/* THE CARDS MATCH THE PLAQUE ROW (founder, round 19 remake): the plaques
   above span the sheet's full width (the pair plaque flexes), so the cards
   flex identically — two equal columns, the same 10px gap, edge to edge
   with the row above. No clamp of their own: the ROW is the measure. */
/* TWO PER ROW, ALWAYS (founder, round 33). A third card must not squeeze the
   first two thinner — the plaque row above is the measure and the cards match
   it, so a card is half the row minus half the gap whether there are two of
   them or five, and the extra ones wrap onto a second row at the same size.
   With exactly two cards this is pixel-identical to the flex:1 1 0 it
   replaces. The collapsed drawer still peeks a slice of the FIRST row only,
   because the peek is measured from a .gc-cover and not from this block. */
.game-cards {
  /* LEFT, NOT CENTRED (founder, round 34). Two cards fill the row exactly
     (2 x calc(50% - 5px) + a 10px gap = 100%), so this changed nothing while
     there were two — but a third card wraps alone onto the second row, and
     centred it floated in the middle of the deck instead of sitting under the
     first card where the eye already is. */
  display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-start; align-items: flex-start;
  width: 100%;
  margin: 10px 0 0; pointer-events: auto;
}
.game-card {
  display: flex; flex-direction: column; align-items: flex-start; gap: 0;   /* the title lives INSIDE the cover now (founder, 3 Sept): a card is its cover, so rows sit on the same 10px gap as columns — a matrix */
  /* 0 0, NOT 0 1: with shrink allowed, three cards at a 50% basis simply
     compressed to 33% each and stayed on one row rather than wrapping. The
     basis has to be a floor, not a wish. 5px = half the 10px gap. */
  flex: 0 0 calc(50% - 5px); min-width: 0;
  padding: 0; background: none; border: 0; cursor: pointer; text-align: left;
  -webkit-tap-highlight-color: transparent;
}
.gc-cover {
  position: relative; display: block; width: 100%; aspect-ratio: 16 / 9;
  background: var(--k-plate-fill); overflow: hidden;
  /* 4px IN BOTH STATES (founder, 2026-08-31: "cards in selected state have
     checkmark and players tag in different position then in non selected
     state. Make position same (padding) - as in selected state").
     Selection only recolours it now. The reason it HAS to be the width and
     not padding is recorded below: .gc-check, .gc-live, .gc-badge and .gc-img
     are absolutely positioned, so they resolve against the PADDING box --
     border box MINUS the border -- and padding sits inside that box and moves
     nothing. While selection thickened the stroke 2.5 -> 4, every one of them
     stepped 1.5px inward on both axes as the card was picked, and .gc-badge
     moved further still because its offsets are percentages OF that shrinking
     box. One constant width and they never move again. */
  border: var(--k-card-border) solid var(--ink);
  border-radius: var(--k-card-radius);
  /* FLAT, like the plaques above it (founder, 30 Aug). The press is still felt
     — the card travels 2px on :active — it just no longer casts. */
  transition: transform .14s ease, filter .14s ease, opacity .14s ease;
}
.gc-img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block; pointer-events: none;
}
.game-card:not(.locked):not(.unavail) .gc-cover:active { transform: translateY(2px); }
/* THE SELECTION RING SURVIVES THE SWEEP, and is not a shadow: these are three
   zero-offset zero-blur SPREAD rings — an inked border drawn with box-shadow
   because a real border would move the card's box. Only the fourth entry was a
   drop (0 4px 0 6px) and it is gone with the rest.

   AND IT TURNS INWARD (founder, 31 Aug). Spread outward, the rings hung 9px
   off every side of the selected card — over its neighbour and past the row's
   own edge. Drawn INSET they stay inside the card's box, and the padding hands
   those same 9px back to the content so the art and the check keep their
   distance from what is now the inner edge. One padding does all of it:
   absolutely positioned children resolve against the PADDING box, so .gc-img,
   .gc-check, .gc-live and .gc-badge all move in together without a rule each.
   Inset rings paint first-on-top, so the read from the edge inward is the same
   dark / cream / ink it always was. */
/* IT MUST BE THE BORDER, NOT PADDING (founder, 31 Aug — he saw the stroke as
   simply GONE). Inset box-shadows paint on the background, UNDER content, and
   .gc-img is an absolutely positioned child with inset:0 — which resolves
   against the PADDING box, and a padding box INCLUDES its padding. So the art
   covered the whole ring and padding shifted nothing: the rings were being
   drawn and then painted over, every frame.

   A border is the one box that actually moves them. The containing block for
   an absolutely positioned child is the border box MINUS the border, so
   thickening the border on selection pulls the art, the check, the LIVE chip
   and the badge inward together — the gaps they keep from the card's edge are
   preserved, with no second rule per element and no magic number repeated.
   The card's own outer silhouette and size never change. */
/* SELECTION IS A CHANGE OF INK, NOT A CHANGE OF SHAPE (founder, 31 Aug).
   Every card already carries a stroke — .gc-cover's own 2.5px border — so
   selection recolours THAT and nothing else: same width, same box, the
   artwork and the check and the LIVE chip exactly where they sit on an
   unselected card. Nothing moves and nothing grows.

   It is BOLDER when selected: dark ink becomes cream (founder, 31 Aug), so the
   pick carries across a small phone. It used to thicken as well, 2.5px to 4px,
   and that was the one visible consequence — the art, the check and the tag
   all stepped 1.5px inward as a card was picked, because children resolve
   against the border box MINUS the border. The founder saw it. The width is
   now 4px in BOTH states and only the colour turns, so the pick still carries
   and nothing inside the card moves at all.

   The road not taken, so it is not retried: rings SPREAD outside grew the card
   9px into its neighbour, over the card beside it. Whatever the edge does now,
   it does INSIDE its own box. */
.game-card.selected .gc-cover { border-color: var(--k-card-selected); }   /* a kit token since 3 Sept: the first world's value is --chip, verbatim */
.game-card.locked .gc-cover, .game-card.unavail .gc-cover {
  filter: grayscale(1) brightness(.72); opacity: .8;
}
/* A PREVIEW CARD (a ghost with a world behind it) shows its cover in COLOUR
   (founder, 3 Sept): the strike and the dimmed title still say not yet. */
.game-card.unavail.preview .gc-cover { filter: none; opacity: 1; }
/* THE TITLE IS INSIDE THE CARD (founder, 3 Sept, global): bottom-left of the
   cover, where the live pill used to sit — the pill moved to the top-left
   corner to free the space. Same 6px inset as the pill and the check. */
.gc-cover::after {
  /* THE TITLE'S GROUND (founder, 3 Sept): a black gradient up from the card's
     bottom, so the title reads on a light cover too. Painted after the art,
     under the title (which gets a z-index of its own below). */
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 58%;
  background: linear-gradient(to top, rgba(0,0,0,.82), rgba(0,0,0,.45) 45%, rgba(0,0,0,0));
  pointer-events: none;
}
.gc-title {
  position: absolute; left: 6px; right: 6px; bottom: 6px; z-index: 1;
  /* SMALLER THAN IT WAS UNDER THE CARD: inside a 16:9 cover half the row wide,
     the 4vw title clipped at the border (THE TOWER touched it, CHAINCROSSING
     was cut in half). 3.1vw on a 393px phone is 12px: thirteen letters of the
     second world's black face fit with room, and the Mouse's narrower face
     with more. It may wrap; the cover is tall enough for two lines. */
  font-family: var(--disp); font-size: clamp(11px, 3.1vw, 14px);
  letter-spacing: .06em; color: var(--chip);
  padding: 0; line-height: 1.05; overflow-wrap: normal;
}
.game-card.locked .gc-title, .game-card.unavail .gc-title { opacity: .6; }
.gc-live {
  position: absolute; left: 6px; top: 6px;   /* top-left since 3 Sept: the title took the bottom */
  display: flex; align-items: center; gap: 5px; padding: 4px 9px 4px 7px;
  background: var(--k-live-fill); border-radius: 999px;
  font-family: var(--disp); font-size: 10px;   /* kit small-label: G2Card, wide-tracked */
  letter-spacing: .09em; color: var(--white);
}
.gc-live svg { display: block; }
.gc-check {
  position: absolute; top: 6px; right: 6px; width: 24px; height: 24px;
  border-radius: 50%; background: var(--chip); border: 2.5px solid var(--ink);
  display: none; align-items: center; justify-content: center;
  font-family: var(--disp); font-size: 14px; color: var(--chip-text);
}
.game-card.selected .gc-check { display: flex; }
.gc-badge {
  position: absolute; left: -4%; right: -4%; top: 34%; padding: 5px 0; text-align: center;
  background: var(--k-badge-fill); color: var(--chip);
  transform: rotate(var(--k-badge-tilt));
  font-family: var(--disp); font-size: clamp(11px, 3vw, 13px); letter-spacing: .12em;
  border-top: var(--k-badge-rule); border-bottom: var(--k-badge-rule);
}

.play-plaque {
  position: relative; display: flex; align-items: center; justify-content: center;
  width: clamp(128px, 40vw, 210px); min-height: 62px; padding: 10px 16px;
  cursor: pointer; pointer-events: auto; transition: none;
  /* THE KIT: BTN_BASE form in the stage's ink — 4px frame, r16, FLAT.
     On the light stage the frame is drawn in dark ink (game 2's own two-ground
     grammar: cream frames on film, ink frames on paper).

     NO SHADOW (founder, 30 Aug): "remove all shadows from cards and buttons in
     the lobby. We want consistency." Worth recording WHY it looked
     inconsistent, because the two builds used the same ledge: game 2's buttons
     carry 0 3px 0 rgba(0,0,0,.45) on a near-black card, where it is invisible;
     the lobby's plaques carried that PLUS a 4px-spread second drop on a bright
     stage, where it reads as a raised slab. Same value, opposite ground. The
     token itself is untouched — the in-run cards that share --pop still have
     theirs — this is a lobby rule, not a kit change. */
  background-color: var(--k-plaque-fill); background-image: var(--paper-grain);
  border: var(--k-btn-border) solid var(--k-plaque-frame);
  border-radius: var(--k-btn-radius);
  box-shadow: none;
}
.play-word {
  font-family: var(--disp); font-size: clamp(22px, 7vw, 34px);
  letter-spacing: .05em; color: var(--k-btn-ink); line-height: 1;
}
.play-plaque:active { transform: translateY(2px); }

/* ── THE TWO PLAQUES ───────────────────────────────────────────────────────
   With #coopBtn hidden (display:none) the stack has exactly one child and the
   deck lays out precisely as it did before co-op existed. That is the whole
   point of the wrapper: the review build must not move by a pixel. */
/* THE TWO WAYS TO FLY, side by side. CO-OP is the larger and comes first:
   it is the game we want played, so it is the button that looks like it. The
   old stack had them vertical with co-op as the SMALL one, which said the
   opposite. */
.play-stack {
  display: flex; flex-direction: row; align-items: stretch; justify-content: center;
  gap: 10px;
  pointer-events: none;                    /* the plaques re-assert it themselves */
}
/* A PAIR OF PLAQUES CANNOT BOTH SHOUT. The co-op plaque is the same material
   and the same shape at a quieter weight, so PLAY stays the loudest thing on
   the apron and the second option reads as an option rather than a rival.
   Both words are longer than "PLAY", so the display face steps down — set here
   rather than left to fitLabel, because a size chosen by a measuring loop
   varies with the word and these two must look like a matched pair. */
/* SAME SIZE, DIFFERENT VOICE. Hierarchy by size made the row read as one slab
   with a big half and a small half. Both plaques are now identical in box and
   in type; CO-OP carries the weight instead — printed solid, the way a poster
   sets the headline act against the supporting one. */
.play-stack .play-plaque.pair { flex: 1 1 0; min-height: 54px; padding: 9px 12px; }
/* On the solo apron CO-OP sits on the LEFT, so it wears the second voice: solid
   ink. See THE TWO MATERIALS below — the moment it moves right, it takes the
   paper and #startBtn takes the ink. */
.play-stack .play-plaque.pair {
  background-color: var(--k-pair-fill); background-image: none;   /* a kit token since 3 Sept: the first world's value is --ink, verbatim; the second world's is the logo's blue */
}
.play-stack .play-plaque.pair .play-word { color: var(--chip); }
/* THE MEMBER'S PAIR IS THE ORDINARY PAIR (founder, 31 Aug). LEAVE PARTY was
   briefly given game 2's NOT NOW outline so that sitting in the accent slot
   would not make the way out read as the offer. On the apron it read as a
   third material rather than a quieter one, so the row goes back to the two it
   has: ink on the left, paper on the right, the same in every state. No rule
   needed — that IS the default, and .outline is retired rather than kept
   unused. */
/* THE CEILING IS SET BY THE PLAQUE, NOT BY THE VIEWPORT.

   The plaque is clamp(128px, 40vw, 210px), so past a ~525px viewport it STOPS
   growing while a vw-based type size keeps going — the two clamps top out at
   different widths and the label overflows at exactly the sizes nobody tests on.
   21px is the largest size at which the longest word here ("CO-OP PLAY", with
   its .1em tracking) fits the widest plaque, so the pair never depends on
   fitLabel rescuing it. fitLabel still runs, as the safety net it is meant to
   be rather than the mechanism the layout leans on. */
/* ONE TYPE SIZE FOR BOTH — they are the same offer, differently pressed. */
/* Sized so the LONGEST word in the pair — CO-OP PLAY — fits its half of the
   row without fitLabel having to shrink it, which is what was ellipsising it
   to "CO-OP PL...". Both plaques are the same box, so the smaller word simply
   sits in more air. */
.play-stack .play-plaque .play-word { font-size: var(--k-stack-word); letter-spacing: var(--k-stack-track); }   /* kit tokens since 3 Sept: the first world's values verbatim; the second world's wide black face needs a size of its own or PLAY TOGETHER ellipsises */
/* SECOND CLASS, and only when there is something to be second to. With co-op
   hidden (the review build) the single plaque keeps its full size and reads
   PLAY, exactly as it always has. */
.play-stack.paired #startBtn { flex: 1 1 0; min-height: 54px; padding: 9px 12px; }
/* ── THE TWO MATERIALS ─────────────────────────────────────────────────────
   Founder's rule, arrived at over three passes and worth stating once, plainly:

       THE RIGHT-HAND PLAQUE IS THE ACCENT AND IS PRINTED ON PAPER.
       THE LEFT-HAND PLAQUE IS THE SECOND OPTION AND IS PRINTED IN SOLID INK.

   Both keep the same box and the same type — hierarchy by voice, never by size.

   The wrong turns, so nobody takes them again. Printing the ACTION in solid ink
   made it recede: on a dark stage solid ink sinks into the floor while paper
   with a hard shadow leaps off it, so the way out was the loudest thing on
   screen. And giving the second option a flat ghost fill read as DISABLED —
   a button that looks broken is worse than one that looks loud. Solid ink is
   the right second voice: full presence, obviously pressable, plainly not the
   one you came for.

   Which ELEMENT is on the right changes with the state, so the materials are
   keyed to the side and not to the button. Default — the solo apron — is
   CO-OP left, SOLO right. .joined flips the row for waiting and joined, where
   WAITING and I'M READY take the accent slot, and the materials flip with it. */
.play-stack.joined .play-plaque.pair {                  /* co-op moved right: it takes the paper */
  background-color: var(--chip); background-image: var(--paper-grain);
}
.play-stack.joined .play-plaque.pair .play-word { color: var(--chip-text); }
.play-stack.joined #startBtn {                          /* ...and the way out takes the ink */
  background-color: var(--ink); background-image: none;
}
.play-stack.joined #startBtn .play-word { color: var(--chip); }
.play-stack .play-plaque { min-width: 0; width: auto; }
/* Not edge to edge: they filled 98% of the row with a 10px gap, which is why
   they read as one bar rather than two choices. */
/* WIDTH, NOT AUTO MARGINS. `margin: 0 auto` on a stretched flex item stops it
   stretching and makes it shrink-to-fit — so the row never took its 95%, the
   plaques sized to their own text, and CO-OP PLAY had 142px of room for a word
   that needs 143. (That word measures 124px of glyphs plus 19px of
   letter-spacing; measuring the glyphs alone is how it looked like it fitted.) */
.play-stack { width: 95%; align-self: center; }

/* THE CHIP ROW is GONE (R21 dead-CSS sweep) — no markup consumer. */

/* ── THE INVITE CARD ───────────────────────────────────────────────────────
   The link is the point of the card, so it is set as a readable object rather
   than a caption: a full-width slip in the mono face, broken where it must be
   so a long room link never pushes the card wider than the phone. */
.invite-link {
  width: 100%; box-sizing: border-box; margin: 2px 0 10px;
  padding: 9px 11px; background: var(--chip); background-image: var(--paper-grain);
  border: 2px solid var(--ink); border-radius: 9px 7px 8px 7px / 7px 9px 7px 8px;
  /* The house has --disp and --body and no mono token; inventing one for a
     single slip would put a face in the design system that nothing else uses.
     The room-code alphabet already excludes i/l/o/0/1, so the ambiguity a mono
     face would be solving does not exist here. */
  font-family: var(--chat); font-weight: 600;
  font-size: 12.5px; line-height: 1.4; letter-spacing: .01em;
  color: var(--ink); overflow-wrap: anywhere; text-align: center;
}
.invite-state { margin: 9px 0 4px; opacity: .8; min-height: 1.1em; }
.invite-state.joined { opacity: 1; font-weight: 700; }

/* THE BUBBLE must never cover him: it is pinned ABOVE his head, which the canvas
   reports as --cat-top (feet minus height), so it tracks any scale change. */
.home-status {
  position: absolute; left: 0; right: 0; top: 4.5%; margin: 0;
  text-align: center; pointer-events: none; z-index: 3;
  font-family: var(--chat); font-size: 12px; letter-spacing: .06em;
  color: var(--white); text-shadow: 0 1px 0 rgba(16,14,12,.9);
}
.start-hint { display: none; }

/* the HUD steps offstage while the home scene / reel owns the frame; its
   infinite spin pauses there (S6 — nothing animates while occluded) */
.hud.offstage { opacity: 0; }
.hud.offstage .ring-icon::after { animation-play-state: paused; }

/* THE STAT ROW is GONE (R21 dead-CSS sweep) — no markup consumer. */
/* the drawn back arrow — one prop, every panel, top-left of the card. The arrow
   owns a BAND of its own: at 6px of air a long title (YOU CROSSED THE BRIDGE)
   printed straight through the arrowhead. 22px clears the art on every card. */
/* THE ARROW GETS A WHOLE ROW, and the number is arithmetic rather than taste.
   The arrow is absolutely positioned at top:6px and is 44px tall, so it ends
   50px down the card's padding box. 22px only pushed the title to 44px — six
   pixels INSIDE the arrowhead, which is exactly what the founder photographed
   twice. 40px puts the title at 62px: clear of the arrow with a real gap, and
   the same rule still serves every panel that already used it. */
.overlay-card > .back-arrow + h2, .card-skin > .back-arrow + h2 { margin-top: 40px; }   /* the skin (round 3) holds the arrow now */
.back-arrow {
  position: absolute; left: 8px; top: 6px; z-index: 5;
  width: 44px; height: 44px; padding: 0; margin: 0;      /* the touch floor holds (was 40) */
  /* THE KIT: game 2's own hand-inked back control (g2ui/back.png — the same
     asset Game2UI.modal mounts), sized to its 30px optical weight in a 44px
     touch box. Its press is game 2's press. */
  background: center/68% no-repeat url('g2ui/back.png');
  border: 0; cursor: pointer; pointer-events: auto; transition: none; opacity: .9;
}
.back-arrow:active { transform: translateY(1px) scale(.94); }
.overlay-card { position: relative; }        /* so the arrow can anchor to it */
.album-key { color: var(--muted); letter-spacing: .04em; margin-top: 2px; }


/* ── THE TAKEOFF (curtain call) ─────────────────────────────────────────────
   While the sequence plays the frame belongs to the stage. Everything the
   lobby put on top steps off — chips, plaque, bubble, pilot strip, hint —
   and ONLY the STONIC marquee stays, hanging over the scene like a theatre
   sign. One class, so every prop leaves and returns together. */
body.reel-up #startScreen > *:not(.marquee) { display: none !important; }
body.reel-up #catBubble,
body.reel-up .pilot-strip,
body.reel-up .deck { display: none !important; }


/* ── THE PILOT'S FACE (Mini App) ────────────────────────────────────────────
   A round letterpress disc carrying the pilot's initial, struck in our own ink
   so a leaderboard of strangers still looks like one printed page. When a
   Telegram photo exists it is laid OVER this as a background — so an expired
   CDN url degrades to the letter rather than to a broken image, which is why
   the letter is always painted underneath rather than instead. */
.lb-face {
  flex: 0 0 auto; width: 26px; height: 26px; margin-right: 9px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--disp); font-size: 13px; line-height: 1; color: var(--ink);
  background-color: var(--chip); background-size: cover; background-position: center;
  border: 2px solid var(--ink); border-radius: 50%;
  box-shadow: 0 2px 0 var(--shadow-drop);
  overflow: hidden; user-select: none;
}
.lb-row.you .lb-face { box-shadow: 0 2px 0 var(--ink); }

/* ═══════════════════════════════════════════════════════════════════════════
   THE SECOND CHAIR (co-op builds only)

   The chair is drawn on the canvas; this button is its hit target, positioned
   from the --chair-* vars drawHomeScene publishes. Doing it this way rather
   than hit-testing the canvas means focus, hit slop, keyboard access and
   VoiceOver all come for free, and it matches the deck's own "the prop is the
   button" language.

   It stays .hidden on the review build — the markup ships, the chair does not.
   ═══════════════════════════════════════════════════════════════════════════ */
/* ── THE LAST MINUTE ───────────────────────────────────────────────────────
   Three transparent buttons over the drawn props, positioned from the render.
   Same family as the empty chair's hit area, so they inherit the overlay
   hit-testing law: the screen above them is pointer-events:none, and every
   interactive descendant must re-assert auto or it renders perfectly and does
   nothing.

   Each rule names its own tokens rather than routing through a generic
   --cx/--top/--w/--h. Three more lines, and worth it twice over: an undeclared
   generic name is invisible to the token checker, and a reader can see which
   box a rule is about without following an indirection.

   The fallbacks are not decoration. The render publishes these, so before the
   first frame they resolve to NOTHING and a button would sit at the origin with
   no size at all. */
/* THE AFTERMATH LETS THE WRECK SHOW.

   The founder's second plate is the whole reason LOSE IT lands somewhere, and
   the card was covering all but its corners. Narrowed and lifted HERE ONLY —
   scoped to #deathScreen rather than to .overlay-card, because that class is
   the house material every card in the game is printed on and a change to it
   would quietly re-set the home screen, the win card, the furnace and the
   leaderboard along with this one.

   The plate is behind glass, so the card also stops being opaque about it: a
   touch of the drawing reads through the margins, which is what makes the two
   look like one screen rather than a dialog over a picture. */
#deathScreen .overlay-card { max-width: min(21rem, 86vw); margin-top: max(3vh, 12px); }
@media (min-height: 720px) { #deathScreen .overlay-card { margin-top: 8vh; } }

/* THE LAST MINUTE'S CARD. House material throughout — it is the same plate,
   the same rules and the same link family every other card in the game uses,
   because a player who has learned to read one of our cards has learned to
   read this one. */
.cp-body { font-family: var(--chat); font-size: 15px; line-height: 1.45; margin: 6px 0 14px; color: var(--white); }
.cp-steps { font-family: var(--chat); font-size: 14px; line-height: 1.6; text-align: left;
            margin: 0 0 14px; padding-left: 20px; color: var(--white); }
.cp-steps li { margin: 2px 0; }
/* The link is shown, not hidden behind a verb: a player who cannot make the
   share sheet work can still read six characters out to a friend. */
/* THE ROOM CODE HAS TO BE LEGIBLE — it is the fallback for a player whose
   share sheet fails, and it inherited the card's colour rather than declaring
   one, which on the paper card rendered near-white on near-white. */
/* The crash card's link field is the lobby's field — see the note in
   index.html. overflow-wrap:anywhere rather than word-break:break-all, so a URL
   wraps at a sensible point instead of mid-token. */
.cp-link { width: 100%; box-sizing: border-box; margin: 2px 0 10px;
           padding: 9px 11px; background: var(--chip); background-image: var(--paper-grain);
           border: 2px solid var(--chip); border-radius: 10px;
           font-family: var(--chat); font-weight: 600;
           font-size: 12.5px; line-height: 1.4; letter-spacing: .01em;
           color: var(--chip-text); overflow-wrap: anywhere; text-align: center; }
#cpLinkText { color: var(--chip-text); font-weight: 600; }
/* THE FLARE, and never digits — a countdown in numerals beside a paid button
   is pressure-selling, and this game does not do that. */
.cp-flare { height: 4px; background: var(--rule-faint); margin: 0 0 14px; overflow: hidden; }
.cp-flare i { display: block; height: 100%; width: 100%; background: var(--white);
              transform-origin: left center; animation: cpburn 90s linear forwards; }
@keyframes cpburn { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.crash-hit {
  position: absolute;
  transform: translateX(-50%);
  background: none; border: 0; padding: 0; margin: 0;
  cursor: pointer; z-index: 12;
  pointer-events: none;                 /* armed by body.crash-armed, below */
  min-width: 48px; min-height: 48px;    /* never smaller than a thumb */
}
#crashCall {
  left: var(--crash-call-cx, 50%); top: var(--crash-call-top, 30%);
  width: var(--crash-call-w, 60%); height: var(--crash-call-h, 22%);
}
#crashBurn {
  left: var(--crash-burn-cx, 50%); top: var(--crash-burn-top, 74%);
  width: var(--crash-burn-w, 60%); height: var(--crash-burn-h, 18%);
}
#crashLose {
  left: var(--crash-lose-cx, 50%); top: var(--crash-lose-top, 60%);
  width: var(--crash-lose-w, 44%); height: var(--crash-lose-h, 8%);
}
body.crash-armed .crash-hit { pointer-events: auto; }
/* The HUD and every other overlay step aside; the cockpit owns the frame the
   same way the takeoff reel does. */
body.crashing #hud, body.crashing #bossUI, body.crashing #titleCard, body.crashing #verdict, body.crashing .deck,
body.crashing #chairBtn, body.crashing #chairNote,
body.crashing .chev, body.crashing #tut, body.crashing #banner,
body.crashing .cat-bubble, body.crashing .peer-bubble { display: none !important; }

.chair-hit {
  position: absolute; inset: 0;                /* fills its SEAT; the seat wears the box, the origin and the rig's transform (the seats round) */
  /* WIDTH IS A WIDTH. This was calc(var(--chair-h) * 0.62) — a HEIGHT
     percentage resolved against the container's WIDTH, which on a 390px phone
     turned a 208px-tall chair into a 60px-wide button. game.js publishes the
     real box now, in the axis it belongs to. */
  /* game.js overwrites this transform every frame the chair breathes
     (chairRig) so the + rides the drawing instead of sitting still on it. The
     origin is the FEET — the same anchor the canvas draws from — because any
     other origin walks the disc sideways as the figure widens. Both are
     restated here so the button is still placed correctly before the first
     frame lands, and on the review build where game.js never calls it. */
  background: none; border: 0; padding: 0; margin: 0;
  cursor: pointer;
  /* THE OVERLAY HIT-TESTING LAW (see .overlay.closing above). #startScreen is
     pointer-events:none so canvas taps reach the game; every interactive
     descendant must RE-ASSERT auto. Without this the chair rendered perfectly,
     sat at the right coordinates, reported itself visible — and swallowed every
     tap, because the computed value was inherited `none`. It looked finished
     and did nothing. */
  pointer-events: auto;
  -webkit-tap-highlight-color: transparent;
}
.chair-hit:disabled { cursor: default; }
/* The + is the invite gesture. It rides the chair's top-left like a tack on the
   playbill, and only appears when tapping would actually do something. */
.chair-plus {
  /* ON THE PART YOU CAN SEE. The silhouette is full size and the star stands in
     front of most of it, so a + centred on the chair box lands behind him. This
     sits on the shoulder that is actually in clear air. */
  position: absolute; left: 26%; top: 30%;
  transform: translate(-50%, -50%);
  display: grid; place-items: center;
  /* A REAL INVITATION, not a footnote. 34px on a chair a fifth of the screen
     tall read as a speck; this is the one thing on the lobby that asks the
     player to bring somebody. */
  width: clamp(56px, 15vmin, 92px); height: clamp(56px, 15vmin, 92px);
  /* WHITE DISC, INKED CROSS, FLAT. It wore the plaques' hard drop and goes
     flat with them (founder, 30 Aug) — the invite badge is a button and the
     sweep is the whole point. */
  background: var(--k-plus-fill);   /* the WHITE disc — stage furniture stays paper; a kit token since 3 Sept, the first world's value is --chip */
  border: 3px solid var(--k-plus-frame);
  border-radius: 50%;
  transition: transform .12s ease-out;
}
.chair-plus svg { width: 62%; height: 62%; display: block; overflow: visible; }
.chair-plus svg path {
  fill: none; stroke: var(--k-plus-ink, #1a1a18);   /* the + itself: the first world's ink; the second world's accent */
  stroke-width: 13; stroke-linecap: round; vector-effect: non-scaling-stroke;
}
/* The press is discrete — the disc travels down onto the boards. */
.chair-hit:active .chair-plus {
  transform: translate(-50%, -50%) translateY(2px);
}
.chair-hit.busy .chair-plus { display: none; }

/* The room code / status line, set under the chair on the apron. */
.chair-note {
  /* IT BELONGS TO THE PILOT, AND IT SITS ON TOP OF WHAT HE IS ALREADY SAYING.

     It used to hang off the empty CHAIR at a fixed height — so it did not move
     when the cat breathed, and it landed across the bubbles instead of above
     them. It rides the star's own crown now, offset by the measured height of
     the bubble AND the tag beneath it, which is why those two publish theirs. */
  /* OVER THE SEAT, NOT UNDER IT. This hung below the chair's feet — and the
     feet stand on the stage floor, which is behind the deck, so the line ran
     under the buttons and clipped off the left edge of the screen. Above the
     head it is always in clear air, and it reads as a label on the empty chair
     rather than a caption floating in the furniture. */
  /* clears the NAME, then the bubble, at the house gap — unchanged */
  position: relative;                           /* THE SEAT carries left/top/transform (the seats round) */
  margin: 0; z-index: 2;                       /* under the tag: the comic stack */
  max-width: 46vw; overflow: hidden; text-overflow: ellipsis;
  text-align: center; white-space: nowrap;
  letter-spacing: .06em; text-transform: uppercase;
  font-weight: 800;
  text-shadow: 0 1px 0 var(--chip);
}
/* A PLATE WHEN IT IS NEWS, not just a caption.

   "That chair is taken" is the ONLY thing a player who followed their friend's
   link is ever told, and as a 12.5px line of ink on a wooden floor it is easy
   to walk straight past — leaving them to conclude the link, or the game, is
   broken. The house rule for exactly this is plate-over-stroke: give the words
   their own ground rather than fighting the art behind them. Same type, same
   place, same voice; it just stops competing with the stage. */
.chair-note.news {
  /* IT MAY WRAP. The base caption is nowrap + ellipsis, which is right for a
     one-line label on a stage and wrong for a sentence — "that link has gone
     cold" arrived as "THAT LINK HAS GONE CO...". A bubble is allowed two
     lines; a label is not. */
  white-space: normal; overflow: visible; text-overflow: clip;
  max-width: min(58vw, 264px); line-height: 1.25;
  padding: 8px 14px 9px;
  background: var(--chip);
  border: 3px solid var(--ink);
  border-radius: 52% 48% 46% 54% / 62% 70% 58% 64%;   /* the third wobble of the balloon family */
  box-shadow: 0 3px 0 var(--stage-rule);
  color: var(--chip-text); text-shadow: none;
}

/* ═══ LABELS MUST NOT BREAK THE BOX ═══════════════════════════════════════
   fitLabel() shrinks type to fit at runtime, but CSS has to hold the line for
   the frame before it runs and for any label written by a path that forgets to
   call it. Ten labels were measured overflowing on a 390px phone — including
   every co-op word on the plaque, which is sized for the four characters of
   "PLAY". Clipping is the honest failure here: a button that silently grows
   shoves the whole card sideways. */
.play-btn, .play-plaque, .play-word {
  min-width: 0;                       /* a flex/grid child defaults to min-content */
  overflow: hidden;
  text-overflow: ellipsis;
}
.play-word { display: block; white-space: nowrap; }

/* ── WAITING · · · ────────────────────────────────────────────────────────
   Three dots that arrive one at a time and clear together, the printed
   shorthand for a held beat. Real elements on staggered keyframes rather than
   animated `content`, which is not reliable across engines. */
.wait-dots { display: inline-flex; gap: .28em; margin-left: .4em; align-items: center; }
.wait-dots i {
  width: .26em; height: .26em; border-radius: 50%;
  /* INK, NOT PAPER. These were cream because the plaque under them was solid
     ink; now that the accent IS the paper plaque, cream dots on cream paper are
     three invisible dots and the word reads "WAITING" with a shrug after it. */
  background: var(--ink, #1a1a18); opacity: 1;
  /* VISIBLE AT REST, blinking off — not invisible at rest, blinking on. The
     dots ARE the "still waiting" affordance, and an animation that fails to
     start (a paused tab, a reduced-motion setting, a repaint that lands between
     the unhide and the first keyframe) left the plaque reading WAITING with a
     shrug after it. At worst now it reads three steady dots. */
  animation: waitdot 1.5s steps(1, end) infinite;
}
.wait-dots i:nth-child(2) { animation-delay: .375s; }
.wait-dots i:nth-child(3) { animation-delay: .75s; }
@keyframes waitdot { 0%, 74% { opacity: 1; } 75%, 100% { opacity: 0; } }
/* The plaque is the same box in every state; only the word and the weight move.
   COUNTDOWN is the one moment the choice is over, so the row becomes one word. */
.play-stack.counting #startBtn { display: none; }
.play-stack.counting .play-plaque.pair { flex: 1 1 100%; }

/* STAND BY is a state, not an offer: the plaque keeps its box but stops
   advertising itself, so the eye goes to the way out beside it. */
.play-plaque.holding { opacity: .72; }
.play-plaque.holding:active { transform: none; }
/* A STATE, NOT A CONTROL. WAITING occupies the loud slot because it is the
   thing you came to do, but there is nothing behind it while the room is open —
   so it does not take the tap, does not show a pointer, and does not depress.
   The way back into the invite card is the silhouette beside it. */
.play-plaque.inert { pointer-events: none; cursor: default; }
/* SOON (founder, 3 Sept): the solo plaque of a world that cannot be played yet —
   dimmed like .holding, untappable like .inert, one word */
.play-plaque.soon { opacity: .55; pointer-events: none; cursor: default; }
.play-plaque.soon:active { transform: none; }

/* THE FRIEND'S BUBBLE — same material, hung off the second chair's mark rather
   than the star's. Left of centre, so its tail points down at the pilot it
   belongs to. */
/* .peer-bubble's place: on its seat (the seats round) */

/* BOTH READY: the lobby steps aside exactly as the takeoff does, and the canvas
   says STARTING on its own. Same selector family as .reel-up so the two
   mechanisms stay independent but behave identically. */
body.counting #startScreen > *:not(.marquee) { display: none !important; }
body.counting #catBubble,
body.counting #peerBubble,
body.counting .pilot-strip,
body.counting .deck { display: none !important; }

/* MORE AIR BETWEEN THE ROWS. The plaques and the chips were 10px apart and read
   as one block of furniture. */
.deck { gap: 22px; }
/* AND THE ACCENT SITS ON THE RIGHT ONCE A FRIEND IS ABOARD: I'M READY is the
   thing to tap, LEAVE is the thing beside it. Reversing the row moves the
   accented plaque without moving the element that carries its behaviour. */
/* ALWAYS REVERSED, not just once a friend is aboard. The accent belongs on the
   right in every state — CO-OP PLAY, WAITING and I'M READY are the same slot
   doing the same job — and the second option belongs on the left. Reversing the
   ROW moves the accent without moving the element that carries the behaviour,
   which is what lets the exit interceptor keep keying on #startBtn by identity.
   (.joined is left in place: paint() still sets it, nothing reads it now, and
   removing it is churn in a third file for no behaviour.) */
.play-stack.joined { flex-direction: row-reverse; }

/* ── THE IDENTITY BUBBLE ──────────────────────────────────────────────────
   Smaller than the one below it, and stacked on the same anchor, so the two
   read as a comic pair: who is speaking, then what they are saying. It rides
   above the main bubble by that bubble's own measured height, which the render
   publishes as --bub-h — so it sits correctly whether the bubble below is one
   line, two, or hidden entirely (0px, and the tag simply takes its place).

   It is the ONLY wallet control in the lobby now that the strip is gone:
   unsigned it asks, signed it names you and offers to let go. */
/* ── THE BUBBLE STACK ─────────────────────────────────────────────────────
   Three layers can hang over a pilot: the main bubble at the bottom, the
   identity tag above it, and a news note above that. Each rides the SAME
   anchor — the character's own crown — and each is offset by the measured
   height of everything beneath it, so the whole column follows the cat as he
   breathes instead of hanging in the air.

   --bub-lap is the one number that sets how close they sit. Negative is a gap;
   positive would tuck them into one another. */
/* THEY TOUCH BY 4px, AND THE TAIL IS WHAT MAKES THAT SAFE.

   A gap was the wrong instinct. Each bubble's tail hangs ~8px below its own
   border box, so with a GAP the tail flies through open air and lands on the
   type below — which is what the founder photographed at 3px. Widening the gap
   to 12px only bought air; it also broke the comic, because two signs with
   daylight between them read as two signs.

   Overlapping fixes both at once. Every layer is painted UNDER the one beneath
   it (note 2, tag 3, bubble 4) and every bubble is opaque, so a tail that
   crosses into the box below is HIDDEN BY it. The pointer cannot reach the
   text, because the text's own background is in front of it.

   THE NUMBER, WALKED IN: 4px overlap crowded, 2px overlap was close, and the
   founder asked for four more pixels of air on top of that — so the stack now
   opens to a 2px GAP. Positive laps overlap, negative ones separate; this is
   the only line that decides it for all three layers. */
:root {
  --bub-lap: -2px;
  /* runtime-published anchors, declared with idle defaults so the token
     checker (and a first paint before JS publishes) always resolves */
  --bub-h: 0px;
  --tag-h: 0px;
  --peer-bub-h: 0px;
  --peer-tag-h: 0px;
  --name-h: 0px;
  --peer-name-h: 0px;
  --name-gap: 14px;   /* the stack clears the NAME by more than a bubble gap */
  --tail-h: 1.9;   /* R21: the 30s spike tapers longer than the old set-square diamond */
  /* THE SLOT'S BREATH, in px — game.js publishes these per frame; 0 is the
     honest idle, so a first paint (or a stopped rig) hangs nothing crooked. */
  --cat-bob: 0px;
  --chair-bob: 0px;
  --bob: 0px;
}

.cat-name {
  /* GAME 2'S NAME: type over the head — cream ink, a hard shadow, no vessel */
  position: relative;
  pointer-events: none; white-space: nowrap;   /* place, transform, z: on the seat (the seats round) */
  font-family: var(--disp); font-size: 13px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--white);
  /* DOWN AND RIGHT (founder, 31 Aug): a cream name on a bright curtain
     loses its vertical strokes to a purely vertical drop. The canvas
     twin in game.js drawNamePlate carries the same pair. */
  text-shadow: 2px 2px 3px rgba(0,0,0,.85), 1px 1px 0 rgba(0,0,0,.55);
}
/* .peer-name's place: on its seat (the seats round) */
.cat-name, .peer-name { --bob: var(--cat-bob, 0px); }
.peer-name { --bob: var(--chair-bob, 0px); }
body.crashing .cat-name, body.counting .cat-name { display: none !important; }
.cat-tag {
  /* A COMIC STACK, NOT TWO SEPARATE SIGNS. The tag sits slightly INTO the
     bubble below it and passes BEHIND it, so the pair reads as one object
     with depth rather than as two labels that happen to be near each other. */
  /* THE NAME SITS ON THE HEAD (founder, 30 Aug): the name plate is the
     LOWEST layer of the stack, exactly as game 2 hangs a name over a
     climber; speech stacks above it. */
  position: relative;                           /* THE SEAT carries left/top/transform (the seats round) */
  max-width: min(46vw, 210px);
  padding: 4px 10px 5px; box-sizing: border-box;
  /* WHITE, not the paper cream the big bubble uses. Two bubbles in the same
     material read as one torn sheet; a lighter, cleaner card reads as a second
     voice — which is what it is. */
  background-color: var(--k-tag-fill); color: var(--k-bubble-text);
  border: var(--k-tag-border) solid var(--k-tag-ink);
  border-radius: var(--k-tag-radius);   /* the second wobble of the balloon family */
  font-family: var(--chat); font-size: 12.5px; letter-spacing: 0;
  line-height: 1.15; text-align: center; white-space: nowrap;
  /* NO overflow:hidden HERE. It clipped the tail clean off — the pseudo-element
     hangs 8px below the box, so hiding overflow deletes the one thing that
     makes this a speech bubble rather than a chip. The label is short by
     construction (a name, or the wallet's own EQab…wxyz), so there is nothing
     to clip anyway. */
  cursor: pointer; pointer-events: auto;
}
.cat-tag::after {          /* the tail, pointing down at the bubble it belongs to */
  content: ""; position: absolute; left: 50%; bottom: -7px;
  z-index: -1;
  width: 13px; height: 13px;
  transform: translateX(-50%) scaleY(var(--tail-h, 1.9)) rotate(48deg);
  background-color: var(--k-tag-fill);
  border-right: var(--k-tag-border) solid var(--k-tag-ink); border-bottom: var(--k-tag-border) solid var(--k-tag-ink);
  /* A NIB, NOT A SET SQUARE. A bare rotated square gives a needle point that
     reads as a UI caret; the cartoonists rounded the tip because a pen does.
     The curved shoulders (big radii on the two side corners) are the 30s flick. */
  border-radius: 0 58% 4px 58%;
}
/* UNSIGNED IS THE LOUD ONE. It is the only thing on the stage asking for
   anything, so it earns the accent; signed, it goes quiet and becomes a label
   that happens to be tappable. */
/* Weight, not colour. The pink shouted — on a black-and-white stage the only
   colour in the frame reads as an advertisement, and this is the cat's own
   voice. Bold on white is enough to make it the loud one. */
.cat-tag.offered { font-weight: 700; letter-spacing: .1em; }
/* A FRIEND'S TAG IS INFORMATION, NOT A CONTROL — nobody may disconnect anybody
   else's wallet, so it must not look pressable. */
/* ── THE SLOT BINDING (round 21) ──────────────────────────────────────────
   Every balloon belongs to ONE slot and breathes with it. The cat family
   rides YOUR slot, the peer family rides the chair's; each binds the same
   local name, so one transform grammar serves both and nothing can inherit
   the wrong body's breath. */
.cat-bubble, .cat-tag, .chair-note { --bob: var(--cat-bob, 0px); }
.peer-bubble, .peer-tag           { --bob: var(--chair-bob, 0px); }
.peer-tag { cursor: default; pointer-events: none; }   /* place and transform: on the seat (the seats round) */
body.crashing .cat-tag, body.counting .cat-tag { display: none !important; }


/* ── THE CHAIR'S NEWS, AS A BUBBLE ────────────────────────────────────────
   "your friend left" was a bare paragraph in the middle of a painted stage,
   in a game where everything anybody says is in a bubble. It speaks now, in
   the same material as the cat's, and say() clears it after seven seconds. */
.chair-note.news {
  background-color: var(--chip); color: var(--chip-text);
  border: 3px solid var(--ink);
  border-radius: 52% 48% 46% 54% / 62% 70% 58% 64%;   /* matches its own first block — one wobble per voice */
  padding: 8px 14px 9px; max-width: min(52vw, 240px);
  font-family: var(--chat); font-size: 12.5px; letter-spacing: 0;
  line-height: 1.2; text-align: center;
  /* NO `position` HERE. This block is a second .chair-note.news rule appended
     after the first, and declaring position:relative overrode the base
     absolute — so the note fell out of the bubble stack and back into normal
     flow, landing beside the cat instead of above him. A later duplicate that
     re-declares layout is how a rule wins an argument it was never having. */
}
.chair-note.news::after {
  content: ""; position: absolute; left: 50%; bottom: -7px;
  z-index: -1;                                    /* under the type — see .cat-bubble::after */
  width: 13px; height: 13px;
  transform: translateX(-50%) scaleY(var(--tail-h, 1.9)) rotate(53deg);
  background-color: var(--k-bubble-fill);
  border-right: var(--k-bubble-border) solid var(--k-bubble-ink); border-bottom: var(--k-bubble-border) solid var(--k-bubble-ink);
  border-radius: 0 58% 4px 58%;                    /* the 30s flick, see .cat-tag::after */
}

/* ── THE PAIR'S AGREEMENT ON AN END CARD ───────────────────────────────────
   Founder, after a co-op win and a co-op loss where every button looked dead:
   the button carries YOUR switch, this cap carries your friend's. It is a
   printed line, not a widget — the house voice for "here is the situation". */
.coop-cap {
  margin: 10px auto 0; max-width: 88%;
  font-family: var(--chat); font-size: 13px; line-height: 1.45;
  letter-spacing: .14em; text-transform: uppercase; text-align: center;
  /* INK ON PAPER, NOT GOLD ON CREAM. This was var(--gold) — #e7e1d2 — printed
     on the card's cream stock at .92 opacity, so the one line telling a pilot
     what their FRIEND has done was very nearly invisible: photographed on
     device it read as a grey smudge under the button. The founder asked that
     the modal "clearly tells both players about the state"; it cannot do that
     in a colour chosen for a dark surface. Secondary weight now comes from
     size and opacity. (R21: the card IS dark now — the line prints in cream.) */
  color: var(--white); opacity: .74;
}
.coop-cap::before, .coop-cap::after { content: "\00a0\2014\00a0"; opacity: .5; }

/* ── CAMPAIGN LOBBY (founder, 2026-08-17): one plaque, full width — PLAY
   spans exactly the chip-row of four props below it. Scoped to .campaign so
   the two-plaque co-op layout returns untouched after the window. */
/* :not(.paired) IS LOAD-BEARING (round 21). The campaign class is
   unconditional since round 20, so this rule — written for the ONE-plaque
   campaign apron — was also claiming the row whenever the pair was up:
   width:100% on both plaques let the first take 352px of a 394px row and
   squeezed the second to 32px, which clipped its word to nothing. The
   founder saw a lobby full of blank buttons. A paired row is governed by
   the pair rules above; this one speaks only when a plaque is alone. */
.campaign .play-stack { width: 100%; }
.campaign .play-stack:not(.paired) .play-plaque { flex: 1 1 auto; width: 100%; max-width: none; }

/* CAMPAIGN END CARDS (founder): with the COINS cell hidden the stats grid
   still reserved its third column, so the two live cells sat left-of-centre
   and the numbers hugged the divider. Two real columns, and air on both
   sides of the rule. Win card shares .death-stats, so both screens agree. */
.campaign .death-stats { grid-template-columns: repeat(2, 1fr); }
.campaign .death-stats div { padding: 0 16px; }

/* ── THE OTHER MACHINE (game 2) ─────────────────────────────────────────────
   An opaque sheet over the held game-1 stage. Blockout era: tokens only, the
   same fallback shape the co-op styles use. The canvas is absolute-filled by
   game2-app.js; the veil is the loader's only chrome. */
#game2root { position: absolute; inset: 0; z-index: 60; background: #000; touch-action: none; overflow: hidden; }
#game2root canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.g2-veil { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 14px; color: var(--white); text-transform: uppercase;
  letter-spacing: .12em; text-align: center; }
.g2-veil .tiny { opacity: .7; }

/* ══ THE CAST ROW (round 19 remake) ══════════════════════════════════════
   The figures are CANVAS again — drawCastRow in game.js, the pair's own
   primitives at N scale. The only DOM here is #lcDrag: a transparent drag
   strip over the figure band that feeds StonicCarousel.scroll(). It sits at
   z1 — under the tag (3), the bubbles (4), the + (6) and the deck (9) — and
   only accepts the pointer while the row genuinely overflows, so it can
   never eat a tap that belongs to a control (the round-19 dead-buttons
   lesson: an overlay with pointer-events is guilty until proven idle). */
#lcDrag {
  position: absolute; left: 0; right: 0;
  top: calc(var(--lc-floor, 74%) - var(--lc-cath, 34%));
  height: var(--lc-cath, 34%);
  z-index: 1;
  pointer-events: none;                    /* scroll() turns it on only when needed */
  touch-action: pan-y;
}
body.crashing #lcDrag, body.counting #lcDrag { display: none !important; }
/* a full party has no shadow slot for the + to sit on */
.chair-hit.rowless { display: none !important; }

/* THE LOBBY'S INVITE IS GAME 2'S MODAL (round 21). The panel is only the
   mount: the component brings its own scrim, card and frame, so this host
   must not paint a second one behind it. */
#invitePanel.g2host { background: none; display: block; }
