/* THE VOID: under zoom, a viewport that does not divide evenly by the
   scale leaves a sliver of raw page at the stage edge -- visible as a thin
   strip on some machines. html wears the PAGE background (not a deeper
   black, which just traded a bright seam for a dark one -- see the band
   under the profile editor), so a seam cannot be picked out at all. */
html { background: #111; }
/* A SHORT screen (the profile editor, whose content simply ends) leaves the
   body box ending with it, so the window below is painted by html -- which
   is why html must wear the page colour rather than a deeper one. Tried
   body{min-height:100vh} as well; vh does not mean the window inside a
   zoomed document, so it bought nothing but a scrollbar risk. */

@font-face {
  font-family: 'SD CyberPunk City';
  src: url('../fonts/sdcyberpunkcitydemo.otf') format('opentype');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

/* Second equippable name font -- see FONTS in server/profile.js and
   FONT_STACKS client-side. Was sitting in public/fonts/ unused. */
@font-face {
  font-family: 'Dissonant Volt Shard';
  src: url('../fonts/dissonant-volt-shard-regular.ttf') format('truetype');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

/* Batch of equippable name fonts, license-checked before adding (bundled
   readme/license text read directly, or the embedded name-table metadata
   when no readme shipped with the download) -- see FONTS in
   server/profile.js and FONT_STACKS/FONT_LABELS client-side for the ids
   these map to. Three fonts from the same source batch were LEFT OUT on
   purpose (Lake Street, Antisocial, Whoa!) because their own bundled
   license text explicitly restricts embedding/commercial use regardless
   of how the download site had them tagged. */
@font-face { font-family: 'Angel'; src: url('../fonts/angel-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Bone'; src: url('../fonts/bone-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Dirty Brush'; src: url('../fonts/dirty-brush-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Don Graffiti'; src: url('../fonts/don-graffiti-regular.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'DragonForce'; src: url('../fonts/dragonforce-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Du Bellay'; src: url('../fonts/du-bellay-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Flamer'; src: url('../fonts/flamer-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'HellYeah'; src: url('../fonts/hellyeah-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Help Me'; src: url('../fonts/help-me-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Melting Town'; src: url('../fonts/melting-town-regular.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'Minercraftory'; src: url('../fonts/minercraftory-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Newspaper Cutout'; src: url('../fonts/newspaper-cutout-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Penguin'; src: url('../fonts/penguin-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Plante'; src: url('../fonts/plante-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Poppin'; src: url('../fonts/poppin-regular.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'Punks'; src: url('../fonts/punks-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Spider Font'; src: url('../fonts/spider-font-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Starbim'; src: url('../fonts/starbim-regular.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'Yaki Goma'; src: url('../fonts/yaki-goma-regular.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: 'Yozakura'; src: url('../fonts/yozakura-regular.otf') format('opentype'); font-display: swap; }

/* ---------- CRT / VHS screen overlay -- never blocks clicks
   (pointer-events:none inherits into every child layer and
   pseudo-element). Split across two fixed layers so HUD elements (the
   profile badge, z 600) can sit above the DARKENING effects but below the
   glass TEXTURE:
   #crt-overlay (z 500) -- soften, rollbar, glare, flicker, bezel,
     vignette (::after)
   #crt-texture (z 700) -- scanlines (::before), phosphor stripes, grain ---------- */
/* NOTE: no will-change here, deliberately. One was added while hunting the
   stale-band bug, on the theory that promoting the glass would stop it
   being re-rastered under the page. It did not fix the bands (the tear
   slices did -- see .cg-slice), and pinning a viewport-sized texture for
   nothing is the exact mistake the logo stack already paid for once. A
   hint that buys no measured frame is a memory cost with a story attached. */
#crt-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  pointer-events: none;
}
#crt-texture {
  position: fixed;
  inset: 0;
  z-index: 700;
  pointer-events: none;
}
#crt-overlay > div,
#crt-texture > div {
  position: absolute;
  inset: 0;
}
/* Analog softness: a real tube is slightly soft, hot and oversaturated, so
   blur/boost the LIVE content behind the overlay. Oversized past the
   viewport because backdrop-filter smears content at its own edges (reads
   as warping at the screen border) -- push those edges offscreen. Must
   also never carry an opacity animation: modulating opacity modulates the
   blur strength, which makes the whole picture visibly pulse. */
#crt-overlay .crt-soften {
  inset: -24px;
  backdrop-filter: blur(0.5px) saturate(1.25) contrast(1.06) brightness(1.04);
  -webkit-backdrop-filter: blur(0.5px) saturate(1.25) contrast(1.06) brightness(1.04);
}
/* Scanlines + aperture grille, combined on one layer -- the exact
   technique (and values, translated from its SCSS vars to literal rgba)
   from the reference this was modeled on: a tight 2px-period horizontal
   scan band stacked with a 3px-period vertical R/G/B subpixel fringe. */
#crt-texture::before {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
    linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
  background-size: 100% 2px, 3px 100%;
}
/* Aperture grille: vertical R/G/B phosphor columns, faint enough to read as
   texture up close without visibly tinting the picture at a distance. */
/* ---------- screen-change glitch burst -- one-shot ~0.36s chaos played by
   JS (showScreen adds .glitching, removes it after). Every layer here is
   tinted flashes over the picture; NOTHING in this subtree reads what is
   behind it, and that is load-bearing rather than incidental -- see the
   note on .cg-slice, which used to and was the stale-band bug.
   z 800: above the picture, the badge and the glass. ---------- */
#crt-glitch {
  position: fixed;
  inset: 0;
  z-index: 800;
  pointer-events: none;
  display: none;
}
#crt-glitch.glitching { display: block; }
#crt-glitch > div { position: absolute; opacity: 0; }
/* Horizontal tear bands, flicking on/off at different offsets.

   THE TEAR IS A BLEND, NOT A BACKDROP RESAMPLE, and that is the fix for
   the stale-band bug -- proved by elimination on the reporter's Mac
   (hiding this ONE layer stopped it; hiding the glass and the texture
   did not). These used to carry
   `backdrop-filter: invert(.85) hue-rotate(160deg) saturate(2.5)`:
   three FULL-WIDTH bands, each opening its own backdrop render surface
   and re-reading, inverting, hue-rotating and saturating everything
   behind it, EVERY FRAME, while translating sideways -- fired on every
   screen change and again on the ambient timer. Priced per pixel, so a
   near-fullscreen window at DPR 2 is where it stops finishing in time,
   and a slice that misses its frame doesn't mangle the picture, it
   presents the raw stale backdrop: a full-width band of the previous
   frame with a hard seam. THE GLITCH EFFECT FAILING WAS THE GLITCH BUG,
   which is why it read as a broken renderer rather than an effect, and
   why paint flashing never marked it -- backdrop-filter is composited,
   not painted, so there is no paint rectangle to see.
   THE SLICE READS NOTHING BEHIND IT NOW, and that is the whole rule:
   `mix-blend-mode: difference` was tried here first, on the theory that a
   compositor blend is cheaper than a filter pipeline. It is -- and it did
   NOT fix the bands, because a blend READS THE BACKDROP exactly like
   backdrop-filter does. Cheaper read, same dependency, same failure at
   size. So the tear is a flat translucent band: the compositor draws it
   over whatever is already there and never asks what that was.
   WHAT THIS COSTS, stated plainly: the tear no longer inverts the picture
   beneath it, it lights it. Over this app's near-black ground the two look
   close at 0.12s, which is the entire duration; the loss is real but it is
   small, and a signature effect that intermittently freezes a third of the
   screen is not worth defending.
   THE RULE FOR ANYTHING NEW: a full-bleed element that MOVES must not read
   its backdrop -- not via backdrop-filter, not via mix-blend-mode. Cost is
   the read, priced per pixel, and it is paid on every frame of the motion. */
.cg-slice {
  left: 0;
  right: 0;
  background: rgba(170, 250, 255, 0.44);
}
/* Positions, heights and jitter offsets are CSS vars re-rolled by JS every
   burst, so no two transitions ever look identical. */
.cg-s1 { top: var(--t1-top, 16%); height: var(--t1-h, 7%); }
.cg-s2 { top: var(--t2-top, 46%); height: var(--t2-h, 3.5%); }
.cg-s3 { top: var(--t3-top, 71%); height: var(--t3-h, 9%); }
/* The whole burst lives INSIDE the pre-swap window (swap at 110ms): every
   animation completes in 0.12s and JS hard-kills the overlay the instant
   the next screen mounts. */
/* Duration is a var, not hardcoded: screen-transition bursts stay at the
   original 0.12s (tuned to finish inside the swap window and mask it --
   changing that would let the actual DOM swap show through as a visible
   cut), while the ambient loop (main.js) stretches this several times
   longer so a burst that's meant to be NOTICED as its own event actually
   reads as one instead of blinking past in a tenth of a second. Since
   every sub-keyframe below is percentage-timed, stretching the duration
   stretches the whole burst proportionally -- no separate keyframe set
   needed for the two cases. */
.glitching .cg-s1 { animation: cg-tear-1 var(--glitch-dur, 0.12s) step-end 1; }
.glitching .cg-s2 { animation: cg-tear-2 var(--glitch-dur, 0.12s) step-end 1; }
.glitching .cg-s3 { animation: cg-tear-3 var(--glitch-dur, 0.12s) step-end 1; }
/* Chaos ramps IN over the first ~40% (old screen tearing apart), peaks at
   the swap point, then decays over the tail (new screen resolving). */
@keyframes cg-tear-1 {
  0%   { opacity: 1; transform: translateX(var(--t1-xa, -14px)); }
  15%  { opacity: 0; }
  30%  { opacity: 1; transform: translateX(var(--t1-xb, 26px)); }
  52%  { opacity: 1; transform: translateX(var(--t1-xa, -30px)); }
  68%  { opacity: 0; }
  82%  { opacity: 1; transform: translateX(var(--t1-xb, 10px)); }
  90%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes cg-tear-2 {
  0%   { opacity: 0; }
  18%  { opacity: 1; transform: translateX(var(--t2-xa, 34px)); }
  36%  { opacity: 1; transform: translateX(var(--t2-xb, -22px)); }
  55%  { opacity: 0; }
  70%  { opacity: 1; transform: translateX(var(--t2-xa, 16px)); }
  80%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes cg-tear-3 {
  0%   { opacity: 1; transform: translateX(var(--t3-xb, 10px)); }
  14%  { opacity: 0; }
  34%  { opacity: 1; transform: translateX(var(--t3-xa, -26px)); }
  58%  { opacity: 1; transform: translateX(var(--t3-xb, 30px)); }
  74%  { opacity: 0; }
  100% { opacity: 0; }
}
/* Static burst -- peaks mid-burst, right when JS swaps the screens, so the
   actual cut is buried under the noise. */
.cg-noise {
  inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)'/%3E%3C/svg%3E");
}
.glitching .cg-noise { animation: cg-noise-burst var(--glitch-dur, 0.12s) step-end 1; }
@keyframes cg-noise-burst {
  0%   { opacity: 0.12; }
  20%  { opacity: 0.22; }
  36%  { opacity: 0.34; }
  50%  { opacity: 0.3; }
  62%  { opacity: 0.16; }
  80%  { opacity: 0.08; }
  100% { opacity: 0; }
}
/* One fast vertical sync roll. */
.cg-roll {
  left: 0;
  right: 0;
  top: 0;
  height: 110px;
  background: linear-gradient(
    to bottom,
    transparent,
    rgba(255, 255, 255, 0.1) 45%,
    rgba(255, 255, 255, 0.16) 50%,
    rgba(255, 255, 255, 0.1) 55%,
    transparent
  );
}
.glitching .cg-roll { animation: cg-roll-sweep var(--glitch-dur, 0.12s) linear 1; }
@keyframes cg-roll-sweep {
  0%   { opacity: 1; transform: translateY(-110px); }
  100% { opacity: 1; transform: translateY(calc(100 * var(--dvh, 1vh))); }
}

/* Folded into #crt-texture::before now (the RGB fringe lives there
   alongside the scanline band, matching the reference's single-layer
   technique) -- this div stays in the DOM harmlessly, just inert. */
#crt-texture .crt-phosphor { display: none; }
/* Static noise removed on purpose -- it was reading as fuzz, not texture.
   Everything else (scanlines, phosphor fringe, vignette, bezel, flicker,
   roll bar) stays exactly where it was, still global via #crt-overlay /
   #crt-texture on every screen in the app. */
#crt-texture .crt-grain { display: none; }
/* Retired: the rolling VHS bar is out, replaced by the constant ambient
   glitch loop instead (see playScreenGlitch's setInterval-driven loop in
   main.js) -- the same tear/noise/roll burst that used to only fire on
   screen changes now fires on every screen, always, on a randomized
   cadence. That's "the moving thing" now, not this bar. */
#crt-overlay .crt-rollbar { display: none; }
/* Ambient room light reflecting off the curved glass, top of the tube. */
#crt-overlay .crt-glare {
  background: radial-gradient(ellipse 130% 55% at 50% -12%, rgba(255, 255, 255, 0.06), transparent 58%);
}
/* Brightness flicker: a black veil blipping in briefly. Lives on its own
   layer (instead of animating the overlay's opacity) so the glare and the
   backdrop blur stay rock steady between blips. */
#crt-overlay .crt-flicker {
  background: #000;
  opacity: 0;
  animation: crt-flicker 7s infinite;
}
/* True rounded tube corners: paint everything outside the rounded rect
   opaque black (the shadow only shows in the corner notches). */
#crt-overlay .crt-bezel {
  border-radius: 26px;
  box-shadow:
    0 0 0 60px #000,
    inset 0 0 1px rgba(255, 255, 255, 0.07);
}
/* Vignette + curved-tube darkening toward the corners, plus an RGB fringe
   right at the edge to hint at chromatic aberration in the glass.

   THE EDGE DARKENING IS GRADIENTS, NOT A BLURRED SHADOW. This used to
   carry `inset 0 0 70px 12px rgba(0,0,0,.7)` -- a 70px gaussian evaluated
   over EVERY pixel of the viewport, on a layer sitting above the whole
   app, re-rastered with any full-screen repaint beneath it. Blur cost
   scales with radius (Glow Bloom's switch is built on the same fact) and
   this was the largest radius in the file over the largest box in the
   file, so four edge ramps buy the same darkening for a gradient fill.
   Corners land slightly deeper than the shadow put them, which is what a
   tube does anyway and what the radial under them was already for. The
   8px RGB fringes stay: small radius, and they ARE the chromatic
   aberration.
   HONEST PROVENANCE, because the comment here used to claim otherwise:
   this was changed while hunting the stale-band bug and it was NOT the
   cause -- the bands survived it, and the culprit turned out to be the
   tear slices' backdrop-filter (see .cg-slice). It stays because it is a
   real cost saving at no visual cost, not because it fixed anything.
   Keep the rule blur-free on the same reasoning. */
#crt-overlay::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 26px;
  background:
    linear-gradient(to bottom, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.28) 22px, rgba(0, 0, 0, 0) 52px),
    linear-gradient(to top,    rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.28) 22px, rgba(0, 0, 0, 0) 52px),
    linear-gradient(to right,  rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.28) 22px, rgba(0, 0, 0, 0) 52px),
    linear-gradient(to left,   rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.28) 22px, rgba(0, 0, 0, 0) 52px),
    radial-gradient(ellipse at center, transparent 52%, rgba(0, 0, 0, 0.55) 100%);
  box-shadow:
    inset 4px 0 8px rgba(255, 0, 60, 0.14),
    inset -4px 0 8px rgba(0, 180, 255, 0.14);
}
@keyframes crt-flicker {
  0%, 100% { opacity: 0; }
  91% { opacity: 0; }
  92% { opacity: 0.06; }
  93% { opacity: 0; }
  96% { opacity: 0.03; }
  97% { opacity: 0; }
}
@keyframes crt-grain {
  0%   { transform: translate(0, 0); }
  12%  { transform: translate(-28px, 20px); }
  25%  { transform: translate(34px, -14px); }
  37%  { transform: translate(-20px, -34px); }
  50%  { transform: translate(14px, 28px); }
  62%  { transform: translate(-34px, 8px); }
  75%  { transform: translate(28px, -22px); }
  87%  { transform: translate(-8px, 34px); }
  100% { transform: translate(0, 0); }
}
@keyframes crt-roll {
  0%   { transform: translateY(-110%); }
  100% { transform: translateY(500%); }
}

/* ---------- "CRTV SCREEN SCAN" -- the CRT power-on flash ----------
   Saved for reuse, NOT auto-played on load -- fire it on demand with
   playCrtvScreenScan() (main.js).
   Two layers, not one: #crt-boot itself is a SOLID opaque backdrop that
   actually hides the real app underneath (a transform:scale on a single
   layer, tried first, only shrinks the painted RECTANGLE -- everything
   outside that thin strip stays fully visible, which is exactly the bug
   the first version had: the menu showing through around the line the
   whole time). The bright line lives on ::before instead, free to jerk
   and expand on its own while the backdrop stays opaque underneath it.
   Only once the line has bloomed does the backdrop itself fade, and the
   two fade out together -- so the real app is never visible until the
   actual "reveal" moment. Sits above every other overlay (z 900). */
#crt-boot {
  position: fixed;
  inset: 0;
  z-index: 900;
  pointer-events: none;
  background: #050805;
  opacity: 0;
}
#crt-boot::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 4px;
  transform: translateY(-50%);
  background: #eafff0;
  opacity: 0;
  box-shadow:
    0 0 40px 14px rgba(210, 255, 220, 0.85),
    0 0 140px 50px rgba(120, 255, 150, 0.5);
}
#crt-boot.crt-boot-play {
  animation: crt-boot-bg 1.9s cubic-bezier(0.2, 0.7, 0.3, 1) 1 both;
}
#crt-boot.crt-boot-play::before {
  animation: crt-boot-line 1.9s cubic-bezier(0.2, 0.7, 0.3, 1) 1 both;
}
/* The opaque backdrop: fully solid (hiding the app) through the entire
   flash/jerk/bloom sequence, only starting to lift in the last third. */
@keyframes crt-boot-bg {
  0%   { opacity: 1; }
  58%  { opacity: 1; }
  78%  { opacity: 0.6; }
  100% { opacity: 0; }
}
/* The line: pops in thin and bright, jerks through a couple of sync-roll
   jumps (the classic tube power-on stutter), then blooms to fill the
   whole screen with brightness before fading with the backdrop. */
@keyframes crt-boot-line {
  0% {
    top: 50%;
    height: 4px;
    opacity: 1;
    filter: brightness(3);
  }
  6%   { top: 66%; }
  6.3% { top: 34%; opacity: 1; }
  9%   { top: 50%; height: 4px; }
  24% {
    top: 50%;
    height: calc(100 * var(--dvh, 1vh));
    opacity: 1;
    filter: brightness(1.7);
    box-shadow: none;
  }
  60% {
    height: calc(100 * var(--dvh, 1vh));
    opacity: 0.85;
    filter: brightness(1.05);
  }
  100% {
    height: calc(100 * var(--dvh, 1vh));
    opacity: 0;
  }
}

/* ---------- "CRTV PANEL GLITCH" -- the whole-picture CRT glitch ----------
   Both constant motions from the reference pen, together, applied to the
   ENTIRE picture (every screen + all HUD chrome), not any one element:
     - crtv-jerkup: the pen's exact 100ms-infinite 1px vertical tremble
       (its `.menu { animation: jerkup 100ms infinite }`) -- this is the
       always-on "the tube is alive" shudder you see the moment you look.
     - crtv-panel-glitch: the pen's exact @keyframes glitch -- the hard
       80deg/-50deg skew warp + opacity dip firing once per 5s cycle.
   Two transform animations on one element would normally fight (last one
   wins wholesale) -- animation-composition lets them stack: the skew
   REPLACES the base transform when it fires, and the jerk ADDs its 1px on
   top of whatever the skew is doing. Pure CSS, no JS clock.
   The CRT glass layers (#crt-overlay/#crt-texture) are deliberately NOT
   animated: the PICTURE warps behind steady glass, which is how a real
   tube fails -- and it sidesteps every containing-block hazard those
   fixed full-bleed layers would otherwise have.
   (position:fixed children of animated elements -- the right-click menu
   inside the lobby screen, mode-select's fixed chrome -- reparent their
   containing block to the animated ancestor; those ancestors are all
   full-bleed viewport-sized when it matters, so coordinates stay sane,
   and during a warp they skew WITH the picture, which is the point.) */
/* #login-gate is deliberately absent: onboarding is flat and quiet by
   design -- no FILTER tremble/skew over it (see the ONBOARDING block). */
.screen,
#top-bar,
#voice-bar,
#home-profile-badge,
#profile-view,
#clan-view,
#crt-texture {
  animation:
    crtv-panel-glitch 30s step-end 2s infinite,
    crtv-jerkup 2.4s infinite;
  animation-composition: replace, add;
}
/* #crt-texture is the one glass layer that DOES move: the scanline/
   phosphor texture spans the full viewport on every screen, so its
   tremble+warp is what makes even sparse screens read as "the ENTIRE
   display is glitching" -- a filter over the whole site, not per-panel
   motion. (It's fixed inset:0 with no fixed descendants and costs
   nothing to move -- plain gradients, no backdrop-filter. The heavier
   glass -- soften/bezel/vignette on #crt-overlay -- stays steady: it
   HAS a backdrop-filter child, which would resample every frame of a
   10Hz forever-animation.) Slight oversize so its edges never peek in
   while the 1px tremble shifts it. */
#crt-texture { inset: -12px; }
/* Per-screen FILTER opt-outs (ID selectors outweigh the .screen rule;
   the :has() variants re-declare the scanline texture while that screen
   is up, because the texture spans the viewport -- if it kept an effect
   the screen dropped, you'd still see that effect ON the screen).
   - Mode-select + store: NO FILTER motion at all -- static glass only.
     (The store's own hover/holo effects are untouched; this only strips
     the FILTER's whole-screen movement.)
   - Server browser: keeps the shake tick, drops the skew warp. */
#screen-mode-select,
#screen-store,
body:has(#screen-mode-select.active) #crt-texture,
body:has(#screen-store.active) #crt-texture {
  animation: none;
}
#screen-lobbylist,
body:has(#screen-lobbylist.active) #crt-texture {
  animation: crtv-jerkup 2.4s infinite;
  animation-composition: add;
}
/* Game-flow screens: NO FILTER motion at all -- static glass only.
   Standing rule from the user: no glitches whatsoever during actual
   gameplay (beat vote through results), in every game, ever. */
#screen-wordsubmit, #screen-wordvote,
#screen-crate, #screen-production, #screen-listen, #screen-results,
body:has(#screen-wordsubmit.active) #crt-texture,
body:has(#screen-wordvote.active) #crt-texture,
body:has(#screen-crate.active) #crt-texture,
body:has(#screen-production.active) #crt-texture,
body:has(#screen-listen.active) #crt-texture,
body:has(#screen-results.active) #crt-texture {
  animation: none;
}

/* The FILTER's shake, demoted to TEXTURE (per tuning): the picture is
   perfectly still ~92% of every cycle, then a single sudden flick -- two
   rapid sub-half-pixel snaps and back, ~60ms each -- like the tube
   ticking, not a screen that's continuously shaking. Paired stops keep
   every jump a hard cut with zero glide. */
@keyframes crtv-jerkup {
  0%, 91%   { transform: translate(0, 0); }
  92%, 94%  { transform: translate(0.4px, -0.3px); }
  95%, 97%  { transform: translate(-0.3px, 0.3px); }
  98%, 100% { transform: translate(0, 0); }
}
/* The skew warp -- the FILTER's "push the screen" moment. Angles stay
   small (subtle-detail tuning); the grit comes from hard cuts, not size. */
@keyframes crtv-panel-glitch {
  /* ONE position per cycle (per tuning): snap sheared-right, hold ~120ms,
     snap straight back -- no left phase, no bounce. On the 30s cycle in
     the shorthands, 0.4% of the timeline = the 120ms hold; paired stops
     + steps() timing keep both jumps hard cuts. */
  0%, 40.3%, 40.9%, 100% { opacity: 1; transform: skew(0deg, 0deg); }
  40.4%, 40.8% { opacity: 0.97; transform: skew(1.2deg, 0deg); }
}

* { box-sizing: border-box; }
/* Reserve the scrollbar's gutter permanently. Page height here changes
   constantly -- a roster gains a row, a phase swaps the screen, the
   transition takes a screen out of flow entirely (position:fixed) -- and
   every crossing of the "does this need a scrollbar" line reflows the whole
   layout by the scrollbar's width. That's the scrollbar flickering in and
   out, and it drags the centered column sideways with it. Reserving the
   space means the threshold is never crossed. */
html {
  scrollbar-gutter: stable;
}
body {
  font-family: sans-serif;
  max-width: 700px;
  margin: 0 auto;
  padding: 56px 16px 40px;
  background: #111;
  color: #eee;
}
/* The 700px cap is right for the utility screens but strangles the main
   menu and the mode-select split on big monitors -- release it whenever
   one of those is up. Mode-select also drops the page padding entirely so
   its full-bleed split reaches every edge (and so the screen-enter
   animation's box lines up with the viewport -- no jump at the end). */
body:has(#screen-home.active),
body:has(#screen-mode-select.active),
body:has(#screen-profile.active),
body:has(#screen-store.active),
body:has(#screen-clans.active) { max-width: none; }
body:has(#screen-mode-select.active) { padding: 0; }
/* The home menu is a fixed, known-short list (logo + 5 items + socials) --
   it should never need to scroll. `safe center` on .screen.active is a
   real fallback for screens whose content genuinely varies (the shop, a
   long roster), but on home a stray scrollbar only ever means a few px of
   sub-pixel drift in the --dvh/zoom math, not real overflow. Clipping it
   here is cheaper than chasing that drift to zero. */
body:has(#screen-home.active) { overflow: hidden; }
/* Gangs is viewport-fitted too (the handset is capped at 58dvh), so any
   scrollbar here is noise. The one that actually shows up: the power-on
   dolly scales the whole handset past the viewport, and a transformed
   box still counts toward scrollable overflow -- both page scrollbars
   flash in for the ~1s of the zoom, then vanish at the fullscreen cut.
   Clip instead. */
body:has(#screen-clans.active) { overflow: hidden; }
/* Profile, gangs and store get their own generous column instead of the
   700px squeeze, and a tighter top so screens fit with no scrollbar. */
#screen-profile.active,
#screen-store.active,
#screen-clans.active { max-width: 1200px; margin: 0 auto; }
/* THE BLACK BAR. The editor is shorter than the window, and a block-level
   screen just stops where its content ends -- leaving a dead tail at the
   bottom of the page. That tail is not a different colour (html and body
   are both #111); it looks black because #crt-overlay::after is a radial
   vignette that pours rgba(0,0,0,0.55) into the edges of the viewport, and
   with nothing drawn down there the darkest ring is all you see. So: fill
   the window and centre the column, and the leftover space splits evenly
   top and bottom as margin instead of pooling into a bar.
   The 62px is body's own padding (22 top + 40 bottom) -- filling the full
   100*--dvh on top of that is exactly what puts a scrollbar on the page. */
#screen-profile.active {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: calc(100 * var(--dvh, 1vh) - 62px);
}
/* THE GREY BAR, Firefox edition (settings + gangs, reported by a first-time
   Firefox player). Same dead tail as the profile's black bar, worse
   consequence: in Firefox the fixed CRT layers (#crt-overlay/#crt-texture,
   inset:0) can end up sized against the ZOOMED ROOT'S CONTENT BOX instead
   of the viewport, so below a short screen there is no vignette, no
   scanlines, no glass -- just raw #111 with a razor edge where the content
   stops (the reported band starts at exactly body-content-height in both
   screenshots). Chrome merely leaves the tail looking flat; Firefox strips
   the room off it. Either way the cure is the same: the screen fills the
   window, so a "below the page" doesn't exist. var(--vph) (true layout
   viewport, see swApplyZoom) rather than 100*--dvh: the design grid is
   only as tall as the window at 16:9 exactly -- a taller window would keep
   a strip of band below the grid. Offsets are each screen's body padding
   (settings: 0; gangs: 22 top + 40 bottom). */
#screen-settings.active {
  min-height: var(--vph, 100vh);
}
#screen-clans.active {
  min-height: calc(var(--vph, 100vh) - 62px);
}
/* phone-full drops the body padding (see the phone-full body rule), so the
   full budget comes back to the screen. */
#screen-clans.phone-full.active {
  min-height: var(--vph, 100vh);
}
/* The shop fills the screen: wide as the viewport allows (capped), card
   heights viewport-relative with floors (never smaller than before) and
   ceilings (never so big the page scrolls on a normal display). */
/* The old store's screen rule lived here: a 1780px cap and a BACK button
   pinned to the bottom-left. Both are gone with the rebuild -- the shop sets
   its own centred cap further down this file, and its exit is the corner
   LEAVE plate. Left in place these were a duplicate #screen-store.active
   fighting the real one and a fixed rule for markup that no longer exists. */
#screen-store > .btn-row .jp-btn {
  font-size: 26px;
  padding: 12px 46px;
}
body:has(#screen-profile.active),
body:has(#screen-clans.active) { padding-top: 22px; }
body:has(#screen-store.active) { padding-top: 12px; }
h1, h2, h3 { color: #ffcc00; }
h2 { margin-bottom: 14px; }
.mode-desc { color: #aaa; max-width: 480px; }

/* Angular HUD profile plate (per sketch): tall section on the left holding
   the square icon + name/level text, diagonal step down after the text,
   then a thin tail running right that houses the XP bar (origin dot ->
   track -> end cap), back-slanted right edge. The outline is faked with
   two stacked clip-path layers: #hpb-plate paints the border color, its
   ::after paints the same shape inset 2px in the background color.
   Hue-rotates in sync with the logo and menu. */
#home-profile-badge {
  position: fixed;
  /* Shared with #voice-speakers, which stacks directly above this plate --
     one source of truth so moving or resizing the badge can't leave the
     speaker HUD overlapping it. */
  bottom: var(--hpb-bottom);
  left: 16px;
  /* Above the CRT overlay (z 500): the badge lives in the corner where the
     vignette is darkest, which was visibly dimming it vs the centered logo. */
  z-index: 600;
  /* --hpb-break is where the top edge's diagonal starts; JS pushes it
     right for long names so the plate morphs instead of truncating.
     Total width tracks it so the XP tail keeps its length. */
  width: calc(var(--hpb-break, 230px) + 230px);
  height: var(--hpb-height);
  /* Two stacked glows -- a tight hot core plus a wide bloom -- to match
     the logo's neon intensity. */
  filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.9)) drop-shadow(0 0 28px rgba(57, 255, 20, 0.6)) hue-rotate(var(--logo-hue));
  cursor: pointer;
  /* Same springy pop as the menu items. Only the transform transitions --
     filter must not, since hue-rotate changes every frame. */
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ---------- PROFILE PLATE MENU ----------
   Opens upward on hover or keyboard focus. The plate's own click still opens
   the profile, so this adds doors rather than replacing the one that was
   there.
   Anchored to the plate and sharing its edge, so the pointer never crosses a
   gap on the way up -- a menu that closes while you reach for it is worse
   than no menu at all. Same reason it has no entry delay on the way in but
   does keep a hold on the way out (the :hover on the badge covers both,
   since the menu is INSIDE it). */
#hpb-menu {
  position: absolute;
  left: 0;
  /* Touching the badge's top edge, NOT floating above it. Any gap here
     belongs to neither element, and crossing it drops :hover -- which is
     precisely what shut the menu the moment you reached for it. The visual
     separation comes from the margin below instead, which is inside the
     menu's own hit area. */
  bottom: 100%;
  /* Breathing room that is still part of the menu, so the pointer stays on
     a live target the whole way up. */
  padding-bottom: 12px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 232px;
  padding: 6px;
  background: linear-gradient(180deg, rgba(8, 20, 10, 0.97), rgba(4, 12, 6, 0.99));
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.55);
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
  /* Hidden without display:none so it can animate, and without pointer
     events so it can never swallow a click while invisible. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translateY(6px);
  transition: opacity 0.14s ease, transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), visibility 0.14s;
  /* Held open briefly on the way OUT, instant on the way in. Forgives a
     fast diagonal that clips a corner of the plate on its way to an item --
     without it, the menu punishes moving quickly, which is how everybody
     moves. */
  transition-delay: 0.22s;
}
/* An ::after bridge used to sit here to cover the gap. It could never have
   worked: this element carries a clip-path for its cut corners, and
   clip-path clips HIT TESTING as well as pixels -- so a bridge positioned
   at top:100%, outside the menu's own box, was clipped out of existence.
   The gap is closed properly above instead. */
/* CLICK opens it, not hover: the plate is a button now, and the class is
   the whole state -- set on click, cleared by any click outside or a menu
   pick. Hover-open kept punishing the corner of the screen the pointer
   naturally rests in; a menu that appears uninvited is furniture. The
   hover-exit grace delay above is harmless here and keeps the close soft. */
#home-profile-badge.hpb-open #hpb-menu {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: none;
  transition-delay: 0s;
}
.hpb-mi {
  display: block;
  width: 100%;
  text-align: left;
  padding: 9px 12px;
  border: 0;
  background: transparent;
  color: #9dffb0;
  font-family: 'Courier New', monospace;
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease;
}
.hpb-mi-t {
  display: block;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 3px;
  line-height: 1.1;
}
.hpb-mi-s {
  display: block;
  font-size: 10.5px;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: rgba(157, 255, 176, 0.45);
  margin-top: 2px;
}
.hpb-mi:hover,
.hpb-mi:focus-visible {
  background: rgba(57, 255, 20, 0.14);
  color: #d8ffe0;
  outline: none;
}
.hpb-mi:focus-visible { box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.7); }
/* Nothing behind it yet. Dimmed and labelled rather than hidden -- the xp is
   genuinely being recorded, so "soon" is true, and a dead click would be
   worse than saying so. */
.hpb-mi[data-soon] { opacity: 0.45; cursor: default; }
.hpb-mi[data-soon]:hover { background: transparent; color: #9dffb0; }
.hpb-mi[data-soon] .hpb-mi-t::after {
  content: 'SOON';
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 2px;
  margin-left: 8px;
  padding: 1px 5px;
  vertical-align: middle;
  border: 1px solid rgba(157, 255, 176, 0.4);
}
@media (prefers-reduced-motion: reduce) {
  /* Keep the exit delay -- it is a usability hold, not decoration. */
  #hpb-menu { transition: opacity 0.14s ease, visibility 0.14s; transform: none; }
}

/* THE reason the menu kept closing under the pointer.
   #voice-speakers is deliberately parked directly on top of this plate's
   corner (z-index 690, see the note on its `bottom`), and .vs-row sets
   pointer-events: auto so a row can be clicked to mute someone. The menu
   opens into exactly that space, and the badge is only z-index 600 -- so
   moving up onto the menu actually landed on the speaking HUD, which is
   outside the badge, which dropped :hover and shut the menu.
   The badge carries a `filter`, so it is a stacking context and the menu
   cannot climb out on its own -- the badge itself has to come up, and only
   while it is open so the HUD is unobstructed the rest of the time. */
#home-profile-badge:hover,
#home-profile-badge:focus-within {
  z-index: 700;
}

#home-profile-badge:hover {
  transform: translate(-3px, -3px) scale(1.03);
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(2px 2px 0 rgba(15, 88, 6, 0.7))
    drop-shadow(0 0 10px rgba(57, 255, 20, 1))
    drop-shadow(0 0 30px rgba(57, 255, 20, 0.7))
    hue-rotate(var(--logo-hue));
}
/* The inner pieces lift a little further than the plate itself, each with
   its own extrusion trail -- parallax depth, like the badge is a stack of
   physical layers. */
#home-profile-badge .player-icon,
#hpb-text,
#hpb-xp-bar {
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 0.15s,
    text-shadow 0.15s;
}
#home-profile-badge:hover .player-icon {
  transform: translate(-2px, -2px);
  box-shadow:
    2px 2px 0 rgba(24, 140, 10, 0.9),
    4px 4px 0 rgba(15, 88, 6, 0.6),
    0 0 14px rgba(57, 255, 20, 0.8),
    inset 0 0 10px rgba(57, 255, 20, 0.35);
}
#home-profile-badge:hover #hpb-text {
  transform: translate(-2px, -2px);
  text-shadow:
    1px 1px 0 rgba(24, 140, 10, 0.95),
    2px 2px 0 rgba(19, 112, 8, 0.85),
    3px 3px 0 rgba(15, 88, 6, 0.7),
    4px 4px 0 rgba(11, 64, 5, 0.55),
    0 0 6px rgba(57, 255, 20, 1),
    0 0 18px rgba(57, 255, 20, 0.8);
}
#home-profile-badge:hover #hpb-xp-bar {
  transform: translate(-2px, -2px);
  box-shadow:
    2px 2px 0 rgba(24, 140, 10, 0.7),
    0 0 8px rgba(57, 255, 20, 0.5);
}
#home-profile-badge.hidden { display: none; }
#hpb-plate {
  position: absolute;
  inset: 0;
  /* Lighter, white-hot line color -- thin strokes need a hotter core than
     the logo's chunky fills to read as equally bright. */
  background: #86ff66;
  clip-path: polygon(0 0, var(--hpb-break, 230px) 0, calc(var(--hpb-break, 230px) + 56px) 44px, 100% 44px, calc(100% - 14px) 100%, 0 100%);
}
#hpb-plate::after {
  content: '';
  position: absolute;
  inset: 3px;
  background: rgba(12, 28, 10, 0.9);
  box-shadow: inset 0 0 26px rgba(57, 255, 20, 0.3);
  clip-path: polygon(0 0, calc(var(--hpb-break, 230px) - 4px) 0, calc(var(--hpb-break, 230px) + 51px) 41px, 100% 41px, calc(100% - 13px) 100%, 0 100%);
}
#home-profile-badge .player-icon {
  position: absolute;
  left: 12px;
  top: 12px;
  width: 58px;
  height: 58px;
  font-size: 30px;
  border: 3px solid #86ff66;
  border-radius: 4px;
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.8), inset 0 0 10px rgba(57, 255, 20, 0.35);
}
#hpb-text {
  position: absolute;
  left: 84px;
  bottom: 28px;
  display: flex;
  align-items: baseline;
  gap: 14px;
  line-height: 1;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 1), 0 0 18px rgba(57, 255, 20, 0.8), 0 0 34px rgba(57, 255, 20, 0.45);
  white-space: nowrap;
}
#hpb-xp-bar {
  position: absolute;
  left: 96px;
  right: 34px;
  top: 60px;
  height: 4px;
  background: rgba(57, 255, 20, 0.4);
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.5);
}
/* Origin dot at the left end of the bar */
#hpb-xp-bar::before {
  content: '';
  position: absolute;
  left: -12px;
  top: 50%;
  transform: translateY(-50%);
  width: 7px;
  height: 7px;
  border: 2px solid #86ff66;
  border-radius: 50%;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
/* Vertical end cap at max-XP */
#hpb-xp-bar::after {
  content: '';
  position: absolute;
  right: -2px;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 13px;
  background: #86ff66;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
#hpb-xp-fill {
  height: 100%;
  width: 0%;
  background: #86ff66;
  box-shadow: 0 0 10px rgba(57, 255, 20, 1);
}

/* Chrome-less: no bar background or border line -- the status text and
   leave button float over whatever screen is up, and the bar itself never
   eats hovers/clicks (children re-enable their own). */
#top-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 0 16px;
  z-index: 60;
  pointer-events: none;
}
#top-bar > * { pointer-events: auto; }
#conn-status { color: #ff6666; font-size: 14px; }
/* ---------- IN-GAME MODAL (replaces native confirm/alert) ----------
   Sits above every screen and the FILTER glass -- a decision this final
   shouldn't be readable through scanlines. */
#sw-modal {
  position: fixed;
  inset: 0;
  z-index: 980;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(1, 5, 3, 0.72);
  backdrop-filter: blur(2px);
  animation: sw-modal-dim 0.18s ease-out;
}
#sw-modal.hidden { display: none; }
@keyframes sw-modal-dim { from { opacity: 0; } to { opacity: 1; } }
#sw-modal-card {
  /* Sized up: these carry real messages now (a couple of paragraphs, not a
     one-line yes/no), and at 560px/18px they read as fine print on a screen
     where everything else is huge. */
  width: min(760px, calc(92 * var(--dvw, 1vw)));
  padding: 34px 40px 30px;
  background: linear-gradient(180deg, rgba(10, 22, 16, 0.98), rgba(4, 12, 8, 0.98));
  border: 2px solid rgba(255, 96, 96, 0.55);
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
  box-shadow: 0 0 40px rgba(255, 50, 50, 0.2);
  animation: sw-modal-pop 0.2s cubic-bezier(0.34, 1.5, 0.64, 1);
}
@keyframes sw-modal-pop {
  from { transform: scale(0.9); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
#sw-modal-head {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 14px;
}
#sw-modal-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 38px;
  letter-spacing: 4px;
  color: #ff8a8a;
  text-shadow: 0 0 12px rgba(255, 80, 80, 0.6);
}
#sw-modal-kata {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 5px;
  color: rgba(255, 140, 140, 0.5);
  /* Japanese breaks between any two characters, so a long title pushing the
     tag rightwards was splitting 投票のお願い across two lines mid-word.
     It's a tag, not a paragraph -- it never wraps. */
  white-space: nowrap;
  flex: 0 0 auto;
}
#sw-modal-body {
  margin: 0 0 28px;
  font-family: 'Courier New', monospace;
  font-size: 22px;
  line-height: 1.55;
  letter-spacing: 0.5px;
  color: #cfe8d8;
  /* Bodies are set with textContent (never innerHTML -- some carry player
     names), so paragraph breaks have to survive as real newlines. Without
     this they collapse and a multi-paragraph message becomes one block. */
  white-space: pre-line;
}
/* swPrompt's textarea (report reasons). Body-matched type; the body's
   28px bottom margin moves here when it shows so spacing stays even. */
#sw-modal-input {
  display: block;
  width: 100%;
  margin: -14px 0 28px;
  padding: 12px 14px;
  font-family: 'Courier New', monospace;
  font-size: 20px;
  line-height: 1.4;
  color: #eaffe3;
  background: rgba(6, 16, 10, 0.85);
  border: 1px solid rgba(134, 255, 102, 0.35);
  resize: vertical;
  min-height: 84px;
}
#sw-modal-input:focus {
  outline: none;
  border-color: rgba(134, 255, 102, 0.75);
  box-shadow: 0 0 14px rgba(80, 255, 120, 0.18);
}
#sw-modal-input.hidden { display: none; }

#sw-modal-actions { display: flex; gap: 14px; }
.sw-modal-btn {
  flex: 1 1 0;
  padding: 18px 0 15px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 29px;
  letter-spacing: 3px;
  color: #b9dcb0;
  background: rgba(20, 44, 14, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.35);
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.16s ease-out, color 0.16s ease-out, filter 0.16s ease-out;
}
.sw-modal-btn:hover, .sw-modal-btn:focus-visible {
  transform: translate(-3px, -3px) scale(1.04);
  background: rgba(40, 88, 26, 0.85);
  color: #eaffe3;
}
.sw-modal-btn:active { transform: scale(0.97); }
.sw-modal-danger {
  color: #ffd9d9;
  background: rgba(72, 16, 16, 0.7);
  border-color: rgba(255, 96, 96, 0.5);
}
.sw-modal-danger:hover, .sw-modal-danger:focus-visible {
  background: rgba(120, 24, 24, 0.92);
  color: #fff;
  /* drop-shadow, not box-shadow: these buttons are clip-path parallelograms
     and a clip cuts the box-shadow away with everything else, so the old
     glow here never actually drew. */
  filter:
    drop-shadow(3px 3px 0 rgba(48, 10, 10, 0.9))
    drop-shadow(3px 3px 0 rgba(48, 10, 10, 0.6))
    drop-shadow(0 0 16px rgba(255, 60, 60, 0.5));
}
/* alert mode: one button, and it isn't a warning */
#sw-modal.sw-modal-alert #sw-modal-cancel { display: none; }
#sw-modal.sw-modal-alert #sw-modal-card { border-color: rgba(134, 255, 102, 0.5); box-shadow: 0 0 40px rgba(57, 255, 20, 0.18); }
#sw-modal.sw-modal-alert #sw-modal-title { color: #86ff66; text-shadow: 0 0 12px rgba(57, 255, 20, 0.6); }
#sw-modal.sw-modal-alert #sw-modal-kata { color: rgba(140, 255, 205, 0.5); }
#sw-modal.sw-modal-alert .sw-modal-danger {
  color: #b9dcb0;
  background: rgba(20, 44, 14, 0.5);
  border-color: rgba(134, 255, 102, 0.35);
}

/* Pinned top-left, above every piece of UI (transition panes 640-660,
   profile card 680) but deliberately BELOW the FILTER glass at 700 -- the
   scanlines wash over it like everything else, so it sits IN the screen
   instead of floating on top of it. The glass is click-through, so this
   is still the first thing the cursor hits. Muted on purpose: it's an
   escape hatch, not a call to action -- it only lights up on hover. */
/* The shared corner plate. LEAVE GAME wore this first; the mode-select
   BACK now uses the same shape, size, position and motion so "get me out
   of here" looks identical everywhere it appears. Colour is the only
   thing that varies -- see the -danger modifier below. */
.sw-corner-btn {
  /* Everything colour-related runs off these three, so the danger variant
     is a 3-line override and the texture, extrusion and glow all follow
     it automatically instead of being written out twice. */
  --cb-rgb: 134, 255, 102;
  --cb-deep: 10, 28, 8;
  --cb-ink: 168, 206, 158;

  position: fixed;
  top: 16px;
  left: 16px;
  /* Above #crt-texture (700, the scanline/grain darkening layer) AND
     #crt-glitch (800, the screen-transition burst) -- at the old 690 this
     sat UNDER both, so the scanlines' own darkening band (a repeating
     0/25% black gradient) was actually painting over the plate, reading
     as "the button is dim" rather than as a texture on top of it. Same
     reasoning #sw-modal already states for itself: an exit control this
     important shouldn't be readable through the FILTER, let alone dimmed
     by it. Still under real UI layers (modals, 900+) since those should
     cover it like everything else on screen. */
  z-index: 810;
  margin: 0;
  padding: 15px 34px 12px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, calc(2.1 * var(--dvw, 1vw)), 32px);
  letter-spacing: 4px;
  line-height: 1;
  color: rgba(var(--cb-ink), 0.85);
  /* Layered plate: hazard diagonals over CRT scanlines over the base
     gradient. It's an EXIT control, so the diagonals read as door
     markings -- and the scanlines tie it to the FILTER over everything. */
  background:
    repeating-linear-gradient(135deg,
      rgba(var(--cb-rgb), 0.07) 0 7px,
      transparent 7px 15px),
    repeating-linear-gradient(0deg,
      rgba(0, 0, 0, 0.32) 0 1px,
      transparent 1px 3px),
    linear-gradient(180deg, rgba(var(--cb-deep), 0.72), rgba(var(--cb-deep), 0.86));
  border: 1px solid rgba(var(--cb-rgb), 0.32);
  /* notched corners -- same angular plate language as the rest of the UI */
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  cursor: pointer;
  overflow: hidden;
  text-shadow: 0 0 8px rgba(var(--cb-rgb), 0.22);
  /* The extrusion is drawn with drop-shadow, NOT box-shadow: this plate
     has a clip-path, and clip-path clips an element's box-shadow along
     with everything else, so a stepped box-shadow stack would simply
     never appear. filter runs AFTER the clip, so it traces the notched
     silhouette -- and chained drop-shadows compound their offsets, which
     is what builds the stepped depth. */
  filter: none;
  /* SNAP THE GLOW, SPRING THE TRANSFORM -- the house rule, learned on
     .menu-item-title and restated here. filter and text-shadow are NOT
     transitioned: rest is `none` / one shadow and hover is an EIGHT-function
     drop-shadow chain, and interpolating between mismatched shadow lists is
     what killed the menu neon mid-tween (far worse in Gecko than Blink). The
     halo has to be there the instant the pointer lands, or it reads as not
     being there at all. */
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    color 0.18s ease-out,
    border-color 0.18s ease-out;
}
/* A light rakes across the plate on hover -- the clip-path contains it,
   so it wipes the notched shape rather than a rectangle. */
.sw-corner-btn::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg,
    transparent 38%,
    rgba(var(--cb-rgb), 0.28) 50%,
    transparent 62%);
  transform: translateX(-140%);
  transition: transform 0.55s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}
.sw-corner-btn:hover::after,
.sw-corner-btn:focus-visible::after { transform: translateX(140%); }

/* The house hover: springy pop toward the viewer, with the depth faked as
   a stack of hard shadows stepping down-right -- on the PLATE and on the
   lettering, so the whole thing lifts as one solid object. */
.sw-corner-btn:hover, .sw-corner-btn:focus-visible {
  /* Lifts further than the standard pop: this plate is big, and on a big
     element a 3px rise reads as a wobble rather than as height. */
  transform: translate(-6px, -6px) scale(1.09);
  color: rgb(var(--cb-rgb));
  border-color: rgba(var(--cb-rgb), 0.9);
  /* THE GLOW FIREFOX CAN ACTUALLY SEE.
     The outer drop-shadow ring below is the nice-to-have: this plate carries
     clip-path AND filter on the SAME element, and per spec filter is applied
     first and then CLIPPED -- Blink is lenient about it (the halo rendered in
     Chromium the whole time), Gecko is not, so on Firefox the ring was being
     sliced off at the notched edge and the owner correctly saw nothing. The
     documented fix is to split filter and clip onto different elements, but
     .sw-corner-btn is a bare <button> in 34 places with no wrapper to move
     the filter onto.
     So the glow that MUST land is INSET: it paints inside the padding box,
     which is inside the clip, so it survives the clip-path, it survives the
     viewport corner (the plate sits 8 visual px from two edges under the 0.5
     root zoom), and it survives the dark extrusion -- none of which the outer
     halo can do. The plate reads as lit from within rather than haloed, which
     is the only version of "lit" this geometry actually allows.
     The 1px inset ring is also the house border-on-a-clipped-plate pattern
     (see THE CLIP EATS THE BORDER), doubling as the hot edge. */
  background:
    repeating-linear-gradient(135deg,
      rgba(var(--cb-rgb), 0.20) 0 7px,
      transparent 7px 15px),
    repeating-linear-gradient(0deg,
      rgba(0, 0, 0, 0.32) 0 1px,
      transparent 1px 3px),
    linear-gradient(180deg, rgba(var(--cb-rgb), 0.26), rgba(var(--cb-rgb), 0.12));
  box-shadow:
    inset 0 0 0 1px rgba(var(--cb-rgb), 0.95),
    inset 0 0 10px rgba(var(--cb-rgb), 0.55),
    inset 0 0 26px rgba(var(--cb-rgb), 0.30);
  text-shadow:
    1px 1px 0 rgba(var(--cb-deep), 0.95),
    2px 2px 0 rgba(var(--cb-deep), 0.88),
    3px 3px 0 rgba(var(--cb-deep), 0.78),
    4px 4px 0 rgba(var(--cb-deep), 0.66),
    5px 5px 0 rgba(var(--cb-deep), 0.52),
    6px 6px 0 rgba(var(--cb-deep), 0.36),
    0 0 10px rgba(var(--cb-rgb), 0.8),
    0 0 28px rgba(var(--cb-rgb), 0.5);
  /* THREE chained steps at 3px, compounding to ~9px of wall down-right.
     It was SIX (18px) and that is what made the hover glow invisible: the
     halo is cast from the composite of everything before it and drawn
     BEHIND it, so an 18px wall of near-opaque --cb-deep swallowed the
     glow on the only side that had room. The plate sits 8 VISUAL px from
     the top and left edges under the 0.5 root zoom, so those two sides are
     clipped by the viewport -- down-right was the whole budget. Shortening
     the wall is what buys the ring its space; do not put the steps back
     without moving the glow somewhere it can breathe.

     THE LAST THREE are THE RING, borrowed from #social-toggle:hover. That
     button draws its halo with box-shadow; this plate CANNOT -- it has a
     clip-path, and a clip eats box-shadow whole (the same reason the
     extrusion is drop-shadow). filter runs AFTER the clip, so these trace
     the notched silhouette instead of a rectangle. THREE radii, not the
     circle's two: 6/1 + 22/0.85 + 50/0.45, alphas falling faster than the
     radii grow (the CHROMA bloom curve). The circle can do it in two because
     it blooms into open space; this one is in a corner. Reads --cb-rgb,
     so the danger variant glows red for free.

     The comment lives OUT here on purpose: this value contains var(), so
     the browser keeps it as an unresolved token stream and does NOT strip
     comments inside the declaration -- verified, they survive into
     .style.filter. Keep prose outside any value carrying a var(). */
  filter:
    drop-shadow(3px 3px 0 rgba(var(--cb-deep), 0.98))
    drop-shadow(3px 3px 0 rgba(var(--cb-deep), 0.9))
    drop-shadow(3px 3px 0 rgba(var(--cb-deep), 0.8))
    drop-shadow(0 0 6px rgba(var(--cb-rgb), 1))
    drop-shadow(0 0 22px rgba(var(--cb-rgb), 0.85))
    drop-shadow(0 0 50px rgba(var(--cb-rgb), 0.45));
}
/* slams flat against the extrusion on press */
.sw-corner-btn:active {
  transform: translate(0, 0) scale(0.97);
  transition-duration: 0.06s;
  filter: drop-shadow(0 0 14px rgba(var(--cb-rgb), 0.55));
}
.sw-corner-btn.hidden { display: none; }

/* ==========================================================================
   THE CORNER PLATE EMITS (Gecko-safe outer glow)

   The plate above carries clip-path AND filter on the SAME element, and per
   spec the filter is applied first and the RESULT is then clipped -- so the
   drop-shadow halo is sliced off at the notched edge. Blink paints it anyway;
   Gecko does not, which is why this looked fine in Chromium and dead in
   Firefox through three rounds of tuning.

   The house fix is already written down: paint+clip on a pseudo, filter on
   the parent (#hpb-plate, #jb-shell). This is that, applied to a component
   that is a bare <button> in 34 places and has no wrapper to move onto:

     .sw-corner-btn          -- NO clip, NO paint. Just text + the filter, so
                                its halo has nothing to be clipped by.
     .sw-corner-btn::before  -- the plate itself: paint, ring, notch.
     .sw-corner-btn::after   -- the light sweep, notched to match (it used to
                                rely on the parent's clip + overflow:hidden).

   TWO THINGS THAT WOULD SILENTLY BREAK THE LAYOUT IF MISSED. The border is
   kept at 1px TRANSPARENT rather than removed: dropping it resizes the plate
   by 2px (the file already warns about this). And ::before is inset -1px, not
   0, because an absolutely positioned child resolves inset against the
   PADDING box -- at 0 it would sit inside that transparent border and the
   plate would paint 2px small.

   The pseudos sit at z-index -1 so the label paints over them. That is safe
   because the button's own filter makes it a stacking context, so -1 cannot
   escape behind anything else on the screen.
   ========================================================================== */
.sw-corner-btn {
  background: none;
  clip-path: none;
  /* THE SWEEP MUST STAY CAGED. overflow:hidden clips DESCENDANTS; the
     filter is applied to the element as a group AFTERWARDS, so the halo
     still radiates freely -- this is not a choice between the two. Setting
     overflow:visible here (to "let the glow out", which it never needed)
     released ::after, whose hover state is translateX(140%), and it flew
     off the plate as a detached notched rectangle several hundred px to
     the right. */
  overflow: hidden;
  /* Border folded into padding so the box is IDENTICAL while ::before can
     fill it at inset 0. overflow:hidden clips at the PADDING box, so a
     ::before hanging 1px out to cover a border would just be trimmed and
     the plate would paint 2px small. 1px border + 15/34/12 padding == no
     border + 16/35/13 padding, exactly. */
  border: 0;
  padding: 16px 35px 13px;
}
.sw-corner-btn::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    repeating-linear-gradient(135deg,
      rgba(var(--cb-rgb), 0.07) 0 7px,
      transparent 7px 15px),
    repeating-linear-gradient(0deg,
      rgba(0, 0, 0, 0.32) 0 1px,
      transparent 1px 3px),
    linear-gradient(180deg, rgba(var(--cb-deep), 0.72), rgba(var(--cb-deep), 0.86));
  /* the ring, as an inset shadow -- THE CLIP EATS THE BORDER */
  box-shadow: inset 0 0 0 1px rgba(var(--cb-rgb), 0.32);
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  pointer-events: none;
  transition: background 0.18s ease-out;
}
/* the sweep carried the parent's clip until now */
.sw-corner-btn::after {
  z-index: -1;
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
}
/* Hover: the plate lights from within (::before) AND throws outward (the
   parent's filter, which is finally free to). Both, not one or the other --
   the inset half is what survives the viewport corner, the outer half is
   what makes it read as EMITTING rather than merely brighter. */
.sw-corner-btn:hover, .sw-corner-btn:focus-visible {
  background: none;
  box-shadow: none;
}
.sw-corner-btn:hover::before,
.sw-corner-btn:focus-visible::before {
  background:
    repeating-linear-gradient(135deg,
      rgba(var(--cb-rgb), 0.20) 0 7px,
      transparent 7px 15px),
    repeating-linear-gradient(0deg,
      rgba(0, 0, 0, 0.32) 0 1px,
      transparent 1px 3px),
    linear-gradient(180deg, rgba(var(--cb-rgb), 0.26), rgba(var(--cb-rgb), 0.12));
  box-shadow:
    inset 0 0 0 1px rgba(var(--cb-rgb), 0.95),
    inset 0 0 10px rgba(var(--cb-rgb), 0.55),
    inset 0 0 26px rgba(var(--cb-rgb), 0.30);
}
/* Per-screen copies: identical plate, but it belongs to the screen so it
   travels with the pane during the PAN DEPLOY. #screen-production sets
   `> * { position: relative }` on its children to stack them over the
   texture layer -- that would drop this out of fixed positioning, so the
   position is restated here at higher specificity. */
#screen-production.active > .screen-leave,
#screen-crate.active > .screen-leave,
#screen-wordsubmit.active > .screen-leave,
#screen-wordvote.active > .screen-leave,
#screen-listen.active > .screen-leave {
  position: fixed;
  top: 16px;
  left: 16px;
}

/* Danger variant: leaving a game in progress is not the same act as
   stepping back a menu, so only that one is red. */
.sw-corner-btn-danger {
  --cb-rgb: 255, 90, 90;
  --cb-deep: 42, 8, 8;
  --cb-ink: 214, 138, 138;
}

/* ---------- WHO'S TALKING ----------
   Bottom-left, transparent, one row per live speaker. No plate, no
   background: it overlays gameplay constantly, so it has to be readable
   without ever becoming furniture. Sits below the FILTER glass (700) so
   the scanlines wash over it like everything else, and is fully
   click-through -- it must never eat a click meant for the game.
   Anchored at the bottom, so each new speaker pushes the stack upward. */
#voice-speakers {
  position: fixed;
  left: 22px;
  /* Rests directly on top of the profile plate's corner, on EVERY screen
     -- not just the ones showing the badge. A HUD that shifts height
     depending on what else happens to be on screen reads as unstable, so
     the position is constant and derived from the badge's own numbers. */
  bottom: calc(var(--hpb-bottom) + var(--hpb-height) + 34px);
  z-index: 690;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* The container stays click-through so an empty HUD never eats clicks meant
     for the game behind it. Individual rows opt back in -- see .vs-row. */
  pointer-events: none;
}
.vs-row {
  display: flex;
  align-items: center;
  gap: 9px;
  /* Clickable: tapping a row mutes that person (your own row mutes you).
     Only the row itself, not the column of empty space around it. */
  pointer-events: auto;
  cursor: pointer;
  padding: 2px 6px 2px 2px;
  margin: -2px -6px -2px -2px; /* padding for the hit area, without moving it */
  transition: background 0.14s ease-out;
  animation: vs-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes vs-in {
  from { opacity: 0; transform: translateX(-12px); }
}
.vs-row.vs-leaving {
  opacity: 0;
  transform: translateX(-10px);
  transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
.vs-icon {
  display: flex;
  width: 19px;
  height: 19px;
  flex: none;
}
.vs-icon svg {
  width: 100%;
  height: 100%;
  fill: #86ff66;
  filter: drop-shadow(0 0 6px rgba(57, 255, 20, 0.85));
}
.vs-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 2px;
  color: #eafff0;
  /* a hard dark edge, not a blur: the HUD sits over waveforms, art and
     bright plates, and a glow alone disappears on light backgrounds */
  text-shadow:
    1px 1px 0 rgba(0, 0, 0, 0.85),
    -1px 1px 0 rgba(0, 0, 0, 0.85),
    0 0 12px rgba(57, 255, 20, 0.45);
}
/* someone you've locally silenced is still ANNOUNCED -- you should know
   they're talking -- but drawn as the mute it is */
.vs-row:hover { background: rgba(57, 255, 20, 0.1); }
/* A slash through the mic while muted, so the state reads at a glance rather
   than only as a colour change. */
.vs-muted .vs-icon { position: relative; }
.vs-muted .vs-icon::after {
  content: '';
  position: absolute;
  left: -2px;
  right: -2px;
  top: 50%;
  height: 2px;
  background: #ff6a6a;
  transform: rotate(-45deg);
  box-shadow: 0 0 6px rgba(255, 90, 90, 0.7);
}
/* Muted and NOT currently talking: dim, no glow. */
.vs-muted .vs-icon svg { fill: #7d8a78; filter: none; }
/* Muted but talking RIGHT NOW: the mic lights again, in amber rather than
   green, so it reads as "you're making noise and nobody can hear it". The red
   slash stays on top, so the muted state is never in doubt. */
.vs-muted.vs-live .vs-icon svg {
  fill: #ffce6b;
  filter: drop-shadow(0 0 7px rgba(255, 190, 80, 0.9));
}
.vs-muted.vs-live .vs-name { color: #ffd98c; }
.vs-muted .vs-name { color: #9aa89a; text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.85); }

/* ---------- voice chat HUD ----------
   RETIRED: the bar (grey strip + "You"/peer chips pinned under the top
   bar) was ugly and, worse, hung over the game screens. Voice itself is
   untouched -- the mesh still runs and mute/unmute lives in the
   right-click player menu, where it belongs. The nodes stay in the DOM
   because voice.js writes status text and peer chips into them; only the
   bar is taken off screen. Speaking rings on the lobby roster are the
   visible "who's talking" cue now. */
#voice-bar {
  position: fixed;
  display: none !important;
}
#voice-status { color: #999; font-size: 12px; }
#voice-peers { display: flex; gap: 6px; flex-wrap: wrap; }
.voice-peer {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px 2px 2px;
  border-radius: 12px;
  background: #1a1a1a;
  border: 1px solid #333;
  font-size: 11px;
  color: #bbb;
  cursor: context-menu;
}
/* You, always the first chip -- distinct border so it reads as "this one's
   you" at a glance among the others. Right-click it for MUTE SELF, same
   menu as everyone else. */
.voice-peer-self { border-color: #4caf50; }
.voice-peer-icon {
  position: relative;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #333;
  font-size: 10px;
  transition: box-shadow 0.15s, filter 0.15s;
}
/* Speaking ring -- same rule reused on the WAITING-room player list icons
   (.player-icon.speaking) so both surfaces agree visually. */
.voice-peer.speaking .voice-peer-icon,
.player-icon.speaking {
  box-shadow: 0 0 0 2px #4caf50, 0 0 9px 2px rgba(76, 255, 80, 0.65);
}
/* Muted: the avatar itself goes grayscale, with a red prohibition ring +
   diagonal slash floated exactly over it -- transparent center (no white
   disc), so the avatar and its color still read through the sign, same
   as a real "no" icon. Same treatment on both surfaces (lobby-home
   player-icon, floating-bar voice-peer-icon) via a shared class. */
.mp-icon-muted {
  filter: grayscale(1);
}
.mp-icon-muted::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='41' fill='none' stroke='%23e0142c' stroke-width='17'/%3E%3Cline x1='23' y1='23' x2='77' y2='77' stroke='%23e0142c' stroke-width='17' stroke-linecap='round'/%3E%3C/svg%3E");
  background-size: 100% 100%;
}
/* Muted badge: shown on a chip/row when that mic is off (self) or you've
   locally silenced them (others) -- small, unobtrusive, red reads as
   "no audio" without needing a tooltip. Secondary to the icon overlay
   above; matters most on rows with no configured avatar to overlay. */
.voice-mute-badge {
  font-size: 10px;
  color: #ff8a8a;
}
.voice-mute-badge.hidden { display: none; }

.screen { display: none; }
.screen.active { display: block; }
/* Screen transitions: the OUTGOING screen never moves -- it holds still
   under the glitch burst and simply turns off at the swap. Only the
   INCOMING screen animates: a quick snap open from a white-hot center
   line, expanding symmetrically from its own middle. */
.screen { transform-origin: 50% 50%; }
.screen-enter {
  animation: screen-enter-expand 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes screen-enter-expand {
  0%   { transform: scaleX(1.04) scaleY(0.012); filter: brightness(3) saturate(1.3); }
  100% { transform: scaleX(1) scaleY(1); filter: brightness(1); }
}

.menu-screen.active {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: calc(calc(100 * var(--dvh, 1vh)) - 96px);
  text-align: center;
  /* 3D stage for the floating logo. */
  perspective: 1100px;
}

/* ---------- animated logo ---------- */

:root {
  --logo-speed: 0.5;
  --logo-hue: 0deg;
  /* 0 when nothing is playing, rising toward 1 with the music. The logo's
     glow is written in terms of this, so with no music it simply sits at
     its designed value and nothing about the look changes. */
  --logo-pulse: 0;
  /* Bottom-left corner stack: the profile plate sits on the floor and the
     speaker HUD rests on top of it. Both read these, so the two can never
     drift into each other. */
  --hpb-bottom: 16px;
  --hpb-height: 84px;
  /* The house display font for a PLAYER'S NAME specifically -- #pv-name (the
     profile card) is the canonical user of this; anything else showing a
     player's name should read this variable rather than repeating the font
     stack, so a custom font (coming later) is a one-line swap here instead
     of a find-and-replace across every place a name is shown. */
  --player-name-font: 'SD CyberPunk City', 'Courier New', monospace;
}

#logo-wrap {
  position: relative;
  /* Scales with the monitor: ~62% of the viewport, capped for ultrawides. */
  width: min(calc(62 * var(--dvw, 1vw)), 1200px);
  margin: 0 auto;
  /* True 3D: depth copies sit at real negative Z behind the face, and the
     whole stack drifts on a slow wave so the depth parallaxes. NOTE: no
     filter here -- a filter would flatten preserve-3d, so the glow and hue
     cycle live on the individual layers instead. */
  transform-style: preserve-3d;
  /* The tilt itself is JS-driven: the face follows the mouse (lerped), and
     falls back to an ambient wave when the cursor goes idle. */
  /* Decorative object: can't be selected, dragged, or clicked. */
  user-select: none;
  -webkit-user-select: none;
  pointer-events: none;
}
#logo-wrap img {
  -webkit-user-drag: none;
  user-drag: none;
}
#logo-img {
  display: block;
  width: 100%;
  height: auto;
  position: relative;
  z-index: 1;
  transform: translateZ(0);
  /* Base glow is exactly what it always was at --logo-pulse: 0; the music
     only ever ADDS a second, wider bloom on top of it. No music, no
     change. Superseded by the tight-core/strong-bloom pair further down
     (search "THE EXTRUSION BECOMES TWO SHADOWS") -- left matching rather
     than deleted so this is never the stale copy if something ever reads
     top-to-bottom instead of by cascade. */
  filter:
    drop-shadow(0 0 calc(6px + 4px * var(--logo-pulse, 0)) rgba(57, 255, 20, calc(0.9 + 0.1 * var(--logo-pulse, 0))))
    drop-shadow(0 0 calc(30px + 30px * var(--logo-pulse, 0)) rgba(57, 255, 20, calc(0.7 + 0.25 * var(--logo-pulse, 0))))
    hue-rotate(var(--logo-hue));
  /* The MAIN logo itself glitches into slices at the layered moments --
     the holes reveal the dark 3D understack, like the sign physically
     shearing apart. Whole and legible the rest of the cycle. */
  animation: logo-glitch-paths calc(15s / var(--logo-speed, 0.5)) step-end infinite;
}
/* The extrusion: same PNG stacked at receding depths, darkening as it
   goes back -- the image's own alpha keeps every layer letter-shaped. */
.logo-depth {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.ld1 { transform: translateZ(-8px);  filter: brightness(0.3) saturate(0.85) hue-rotate(var(--logo-hue)); }
.ld2 { transform: translateZ(-16px); filter: brightness(0.25) saturate(0.85) hue-rotate(var(--logo-hue)); }
.ld3 { transform: translateZ(-24px); filter: brightness(0.2) saturate(0.85) hue-rotate(var(--logo-hue)); }
.ld4 { transform: translateZ(-32px); filter: brightness(0.15) saturate(0.85) hue-rotate(var(--logo-hue)); }
.ld5 { transform: translateZ(-40px); filter: brightness(0.11) saturate(0.85) hue-rotate(var(--logo-hue)); }
/* Each effect layer is the SAME logo image again, stacked exactly on top.
   <img> always respects PNG alpha natively (no masking-spec dependency at
   all), so brightening a duplicate and revealing only a thin clipped band
   of it guarantees the effect never appears outside the actual letter
   pixels -- clip-path is a plain rectangle, it doesn't need to know the
   letter shapes, the image's own transparency handles that part for free. */
.logo-fx-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;
  transform: translateZ(2px);
}

#logo-shimmer {
  filter: brightness(2.2) saturate(1.3) hue-rotate(var(--logo-hue));
  mix-blend-mode: screen;
  animation: logo-shimmer-sweep calc(3.4s / var(--logo-speed)) ease-in-out infinite;
}
@keyframes logo-shimmer-sweep {
  0%   { clip-path: inset(0% 0 96% 0); opacity: 0; }
  10%  { opacity: 0.85; }
  50%  { clip-path: inset(46% 0 46% 0); opacity: 0.85; }
  90%  { opacity: 0.85; }
  100% { clip-path: inset(96% 0 0% 0); opacity: 0; }
}

/* ---------- layered logo glitch ----------
   The classic layered glitch system (slice paths + opacity pops + stepped
   movement + tint/blur shifts) ported onto the logo image stack. Per
   ghost, PATHS and OPACITY share one clock so the slices read as pieces
   of the sign itself tearing off; the tint and the movement run on their
   own clocks, so the combinations never repeat. Ghost tints are derived
   from the LIVE logo hue (base +180 / +90), so the RGB-split stays
   complementary while the sign color cycles. Everything scales with
   --logo-speed (defaults tuned so 0.5 = the reference timing). */
.logo-glitch {
  mix-blend-mode: screen;
  opacity: 0;
}
#logo-glitch-1 {
  animation:
    logo-glitch-paths calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-op calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-tint calc(4s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-move calc(5s / var(--logo-speed, 0.5)) step-end infinite;
}
#logo-glitch-2 {
  animation:
    logo-glitch-paths calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-op calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-tint calc(3.5s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-move calc(4s / var(--logo-speed, 0.5)) step-end infinite;
}
/* third ghost rides the same system phase-shifted, so its pops land in
   the other layers' quiet moments */
#logo-glitch-3 {
  animation:
    logo-glitch-paths calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-op calc(15s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-tint calc(4.5s / var(--logo-speed, 0.5)) step-end infinite,
    logo-glitch-move calc(6s / var(--logo-speed, 0.5)) step-end infinite;
  /* small offset keeps it INSIDE the shared burst window, flickering
     against the other two -- the long quiet stays untouched */
  animation-delay: calc(0.4s / var(--logo-speed, 0.5));
}
.logo-glitch.paused-hidden { display: none; }
/* RARE by design: the ORIGINAL 5-second glitch performance plays exactly
   as authored (same internal rhythm -- percentages are the originals
   scaled by 5/30), once per 30s cycle, followed by ~25s of clean sign. */
@keyframes logo-glitch-paths {
  0% {
    clip-path: polygon(0% 43%, 83% 43%, 83% 22%, 23% 22%, 23% 24%, 91% 24%, 91% 26%, 18% 26%, 18% 83%, 29% 83%, 29% 17%, 41% 17%, 41% 39%, 18% 39%, 18% 82%, 54% 82%, 54% 88%, 19% 88%, 19% 4%, 39% 4%, 39% 14%, 76% 14%, 76% 52%, 23% 52%, 23% 35%, 19% 35%, 19% 8%, 36% 8%, 36% 31%, 73% 31%, 73% 16%, 1% 16%, 1% 56%, 50% 56%, 50% 8%);
  }
  0.833% {
    clip-path: polygon(0% 29%, 44% 29%, 44% 83%, 94% 83%, 94% 56%, 11% 56%, 11% 64%, 94% 64%, 94% 70%, 88% 70%, 88% 32%, 18% 32%, 18% 96%, 10% 96%, 10% 62%, 9% 62%, 9% 84%, 68% 84%, 68% 50%, 52% 50%, 52% 55%, 35% 55%, 35% 87%, 25% 87%, 25% 39%, 15% 39%, 15% 88%, 52% 88%);
  }
  5% {
    clip-path: polygon(0% 53%, 93% 53%, 93% 62%, 68% 62%, 68% 37%, 97% 37%, 97% 89%, 13% 89%, 13% 45%, 51% 45%, 51% 88%, 17% 88%, 17% 54%, 81% 54%, 81% 75%, 79% 75%, 79% 76%, 38% 76%, 38% 28%, 61% 28%, 61% 12%, 55% 12%, 55% 62%, 68% 62%, 68% 51%, 0% 51%, 0% 92%, 63% 92%, 63% 4%, 65% 4%);
  }
  7.5% {
    clip-path: polygon(0% 33%, 2% 33%, 2% 69%, 58% 69%, 58% 94%, 55% 94%, 55% 25%, 33% 25%, 33% 85%, 16% 85%, 16% 19%, 5% 19%, 5% 20%, 79% 20%, 79% 96%, 93% 96%, 93% 50%, 5% 50%, 5% 74%, 55% 74%, 55% 57%, 96% 57%, 96% 59%, 87% 59%, 87% 65%, 82% 65%, 82% 39%, 63% 39%, 63% 92%, 4% 92%, 4% 36%, 24% 36%, 24% 70%, 1% 70%, 1% 43%, 15% 43%, 15% 28%, 23% 28%, 23% 71%, 90% 71%, 90% 86%, 97% 86%, 97% 1%, 60% 1%, 60% 67%, 71% 67%, 71% 91%, 17% 91%, 17% 14%, 39% 14%, 39% 30%, 58% 30%, 58% 11%, 52% 11%, 52% 83%, 68% 83%);
  }
  12.667% {
    clip-path: polygon(0% 26%, 15% 26%, 15% 73%, 72% 73%, 72% 70%, 77% 70%, 77% 75%, 8% 75%, 8% 42%, 4% 42%, 4% 61%, 17% 61%, 17% 12%, 26% 12%, 26% 63%, 73% 63%, 73% 43%, 90% 43%, 90% 67%, 50% 67%, 50% 41%, 42% 41%, 42% 46%, 50% 46%, 50% 84%, 96% 84%, 96% 78%, 49% 78%, 49% 25%, 63% 25%, 63% 14%);
  }
  15% {
    clip-path: polygon(0% 41%, 13% 41%, 13% 6%, 87% 6%, 87% 93%, 10% 93%, 10% 13%, 89% 13%, 89% 6%, 3% 6%, 3% 8%, 16% 8%, 16% 79%, 0% 79%, 0% 99%, 92% 99%, 92% 90%, 5% 90%, 5% 60%, 0% 60%, 0% 48%, 89% 48%, 89% 13%, 80% 13%, 80% 43%, 95% 43%, 95% 19%, 80% 19%, 80% 85%, 38% 85%, 38% 62%);
  }
  0.167%, 1.167%, 5.5%, 7.833%, 13%, 15.5% {
    clip-path: none;
  }
}
@keyframes logo-glitch-op {
  0% { opacity: 0.1; }
  0.833% { opacity: 0.7; }
  5% { opacity: 0.4; }
  7.5% { opacity: 0.6; }
  12.667% { opacity: 0.4; }
  15% { opacity: 0.8; }
  0.167%, 1.167%, 5.5%, 7.833%, 13%, 15.5% { opacity: 0; }
}
@keyframes logo-glitch-move {
  0% { top: 0px; left: -20px; }
  15% { top: 10px; left: 10px; }
  60% { top: 5px; left: -10px; }
  75% { top: -5px; left: 20px; }
  100% { top: 10px; left: 5px; }
}
/* the image equivalent of the font layer: tint + blur + heat shifts */
@keyframes logo-glitch-tint {
  0% { filter: hue-rotate(calc(var(--logo-hue, 0deg) + 180deg)) brightness(2.2) blur(3px); }
  20% { filter: hue-rotate(var(--logo-hue, 0deg)) brightness(3) blur(0px); }
  50% { filter: hue-rotate(calc(var(--logo-hue, 0deg) + 90deg)) brightness(2.5) blur(2px); }
  60% { filter: hue-rotate(var(--logo-hue, 0deg)) brightness(3.2) blur(0px); }
  90% { filter: hue-rotate(calc(var(--logo-hue, 0deg) + 200deg)) brightness(2) blur(6px); }
}

#logo-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin: 14px 0 4px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  color: #6a9;
}
#logo-controls label { display: flex; align-items: center; gap: 6px; margin: 0; }
#logo-controls button {
  background: #111;
  color: #39ff14;
  border: 1px solid #2a5;
  font-family: inherit;
  font-size: 12px;
}
#logo-controls button:hover { border-color: #39ff14; }
.menu-list {
  display: flex;
  flex-direction: column;
  /* Items hug their own text -- hovering empty space beside a label must
     not highlight it. */
  align-items: center;
  gap: 14px;
  width: 100%;
  max-width: 560px;
  /* NO filter here, and that is load-bearing. The hue used to be applied
     once on this wrapper, which made the whole menu ONE composited surface
     -- and hovering any item whose pop GREW that surface's bounds (PLAY at
     the top, CREDITS at the bottom, SETTINGS the widest) forced the
     compositor to reallocate and redraw the entire rectangle, which
     flashed as a visible dark square around the menu. The hue now lives on
     each .menu-item instead (same variable, so the items still shift in
     lockstep); a hover can only ever resize the hovered word's own small
     surface, which is already visually in motion. */
}
/* ---------- challenges board (producer/artist menu, lower left) ----------
   Same chrome language as the profile badge: angular plate, neon frame,
   hue drift. The challenge set is picked deterministically from the UTC
   day, so it rotates at midnight and everyone sees the same board.
   In FLOW, not fixed: it's the last child of #mp-left, so it lands under
   the menu and rides that column's own layout instead of being pinned to
   viewport coordinates every screen size would fight. */
#mp-daily {
  position: relative;
  /* Fills the menu column: the board is a main feature of this screen,
     not a side widget, so it matches the menu's own width. */
  width: 100%;
  max-width: 820px;
  /* Sits on the same baseline as the lobby panel opposite it. #mp-left
     stretches to the row height (so it's exactly as tall as the panel),
     and auto margin drops the board to the bottom of that column -- the
     two bottom edges then line up on their own at any screen size,
     rather than being matched with a number that would drift. */
  margin-top: auto;
  padding: 15px 20px 12px;
  /* Same faked-border technique as #mp-lobby-panel across the screen: an
     accent gradient plate with the real background inset over it, so the
     board reads as the lobby panel's twin instead of a green box parked
     on a red screen. --mp-accent is set per mode, so this is red on
     PRODUCER and blue on ARTIST with no extra rules. */
  background: linear-gradient(160deg, var(--mp-accent-soft), rgba(134, 255, 102, 0.25));
  clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 20px, 100% 100%, 20px 100%, 0 calc(100% - 20px));
  /* No hue-rotate here (the old home placement had it): the point is to
     sit on the mode's exact accent, and drifting the hue would walk it
     off that colour a few seconds after arrival. */
  font-family: 'Courier New', monospace;
  /* The glitch pair is deliberately NOT re-joined here (the old fixed
     placement had to). The board now sits INSIDE #screen-mpmenu, which
     already carries the global CRTV animation, so it trembles with its
     parent -- listing it again would run the warp twice, out of phase. */
  animation: hd-enter 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
#mp-daily::before {
  content: '';
  position: absolute;
  inset: 2px;
  clip-path: polygon(0 0, calc(100% - 19px) 0, 100% 19px, 100% 100%, 19px 100%, 0 calc(100% - 19px));
  background: rgba(6, 12, 6, 0.93);
}
/* the inset plate above is absolutely positioned, so the real content has
   to be lifted out from under it */
#mp-daily > * { position: relative; }
#mp-daily.hidden { display: none; }
/* Short viewports (laptops at 720-800px tall) don't have the headroom for
   the mode header + menu + a full-size board, and the board is the last
   thing in the column -- so it, not the menu, is what would fall off the
   bottom. Compress the column's own gaps and the board's internal spacing
   rather than letting the menu screen scroll. */
/* 540, not 900: swApplyZoom keeps the LAYOUT viewport at a logical 1080
   tall on any screen, so these short-screen trims firing on a real 768px
   laptop was double-compensation -- the exact re-proportioning the zoom
   exists to kill. Below 540 real px the zoom hits its 0.5 floor and the
   layout genuinely shrinks again, which is when these earn their keep. */
@media (max-height: 540px) {
  /* On a short screen the header + menu + board genuinely exceed the
     viewport, so the gaps alone can't fix it -- the oversized display
     type has to come down as well. The board keeps the most of its size
     because it's the thing that was too small to begin with. */
  /* Scoped through #screen-mpmenu on purpose: these elements get their
     base sizes from rules FURTHER DOWN this file, and at equal
     specificity the later rule wins no matter that this one is in a
     media query. The extra id is what makes the override stick. */
  #screen-mpmenu #mp-wrap { padding-top: clamp(20px, calc(3 * var(--dvh, 1vh)), 44px); padding-bottom: calc(2 * var(--dvh, 1vh)); }
  #screen-mpmenu #mp-left { gap: calc(1.6 * var(--dvh, 1vh)); }
  /* Short screens: smaller still, but the nowrap in the base rule is what
     keeps the header a constant height across a gamemode switch. */
  #screen-mpmenu #mp-mode-name { font-size: clamp(32px, calc(3.4 * var(--dvw, 1vw)), 60px); }
  #screen-mpmenu #mp-mode-tagline { font-size: clamp(14px, calc(1.1 * var(--dvw, 1vw)), 18px); }
  #screen-mpmenu #mp-head-rule { margin-top: 9px; }
  #screen-mpmenu .mp-item { padding: 4px 0; }
  #screen-mpmenu .mp-item-label { font-size: clamp(24px, calc(2.1 * var(--dvw, 1vw)), 32px); }
  /* margin-top stays auto here too -- overriding it to 0 would break the
     bottom-edge alignment with the lobby panel on short screens. */
  #mp-daily { padding: 12px 16px 10px; }
  #mp-daily .hd-claim { min-width: 112px; padding: 8px 13px; }
  #mp-daily .hd-claim-label { font-size: 17px; }
  #mp-daily .hd-claim-sub { font-size: 10px; }
  #mp-daily .hd-reward { min-width: 64px; padding: 6px 8px; }
  #mp-daily .hd-reward-num { font-size: 19px; }
  #mp-daily #hd-list { gap: 7px; }
  #mp-daily .hd-row { padding: 9px 12px; gap: 14px; }
  #mp-daily .hd-name { font-size: 21px; }
  #mp-daily .hd-track { margin-top: 7px; }
  #mp-daily .hd-head { padding-bottom: 6px; margin-bottom: 2px; }
  #mp-daily .hd-title { font-size: 26px; }
  #mp-daily .hd-status-text { font-size: 13px; letter-spacing: 2px; }
  #mp-daily .hd-check { width: 19px; height: 19px; font-size: 12px; }
  #mp-daily .hd-foot { margin-top: 8px; padding-top: 6px; }
  #mp-daily .hd-reset { font-size: 13px; }
}
@keyframes hd-enter {
  from { opacity: 0; transform: translateY(-12px); }
}
.hd-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding-bottom: 8px;
  margin-bottom: 4px;
  border-bottom: 1px solid var(--mp-accent-dim, rgba(134, 255, 102, 0.3));
}
.hd-title {
  flex: 1;
  text-align: center;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 34px;
  letter-spacing: 2px;
  /* mirrors the mode name above it -- same accent, same glow recipe */
  color: var(--mp-accent, #86ff66);
  text-shadow: 0 0 10px var(--mp-accent-soft, rgba(57, 255, 20, 0.8)), 0 0 26px var(--mp-accent-dim, rgba(57, 255, 20, 0.4));
}
/* Cycle arrows: no box. A bare chevron with a hairline tail reads far
   sleeker than a bordered button, and it leans in the direction it takes
   you instead of popping in place. */
.hd-arrow {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 34px;
  padding: 0;
  background: none;
  border: none;
  font-family: 'Courier New', monospace;
  font-size: 30px;
  line-height: 1;
  color: var(--mp-accent, #86ff66);
  opacity: 0.62;
  cursor: pointer;
  transition: opacity 0.16s ease, transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), text-shadow 0.16s ease;
}
/* the hairline the chevron rides along -- grows out on hover */
.hd-arrow::after {
  content: '';
  position: absolute;
  bottom: 5px;
  left: 50%;
  width: 0;
  height: 1px;
  background: var(--mp-accent, #86ff66);
  box-shadow: 0 0 6px var(--mp-accent-soft, rgba(57, 255, 20, 0.8));
  transform: translateX(-50%);
  transition: width 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
.hd-arrow:hover {
  opacity: 1;
  text-shadow: 0 0 12px var(--mp-accent-soft, rgba(57, 255, 20, 1));
}
.hd-arrow:hover::after { width: 24px; }
/* leans the way it sends you */
#hd-prev:hover { transform: translateX(-4px); }
#hd-next:hover { transform: translateX(4px); }
.hd-arrow:active { transform: scale(0.88); transition-duration: 0.06s; }
/* board swap slides in from the arrow's direction -- never an instant swap */
@keyframes hd-slide-l {
  from { opacity: 0; transform: translateX(-14px); }
}
@keyframes hd-slide-r {
  from { opacity: 0; transform: translateX(14px); }
}
.hd-kata { font-size: 18px; color: #5a8f50; letter-spacing: 3px; }

/* Each challenge is its own CARD, not a line of text: the objective and a
   long progress track fill the plate, with the XP payout as a tile on the
   right. Two columns, so every bar ends on the same x no matter how long
   the objective name runs. */
#hd-list { display: flex; flex-direction: column; gap: 10px; }
.hd-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 18px;
  padding: 12px 14px;
  background:
    /* faint vertical ribbing -- the plate reads as a screen, not a box */
    repeating-linear-gradient(90deg, rgba(134, 255, 102, 0.05) 0 1px, transparent 1px 4px),
    var(--mp-accent-dim, rgba(134, 255, 102, 0.06));
  /* the accent stripe is what carries the mode colour down the list */
  border-left: 3px solid var(--mp-accent, rgba(134, 255, 102, 0.55));
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
}
.hd-body { min-width: 0; }
.hd-name {
  font-size: 26px;
  letter-spacing: 1.5px;
  color: #eefae9;
}
/* progress count sits INLINE with the bar (not stacked under it) so the
   bar gets the full remaining width, as long as the card allows */
.hd-track {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 9px;
}
.hd-bar {
  flex: 1;
  height: 9px;
  min-width: 0;
  background: rgba(255, 255, 255, 0.09);
  overflow: hidden;
}
.hd-fill {
  height: 100%;
  width: 0%;
  background: var(--mp-accent, #86ff66);
  box-shadow: 0 0 9px var(--mp-accent-soft, rgba(57, 255, 20, 0.8));
  /* progress never snaps -- it grows */
  transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.hd-progress {
  font-size: 17px;
  letter-spacing: 1px;
  color: #6dcf5a;
  white-space: nowrap;
}
/* ---- status seal ----
   A finished challenge gets a STAMPED chip rather than a line of text:
   cut-corner plate, its own bezel around the check, and a sheen that
   crosses it every few seconds so it looks lit rather than printed. */
.hd-status {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 5px 13px 5px 6px;
  overflow: hidden;
  background: linear-gradient(150deg, rgba(255, 215, 94, 0.22), rgba(255, 215, 94, 0.06));
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(255, 215, 94, 0.5);
  clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px);
}
.hd-status-text {
  font-size: 15px;
  letter-spacing: 3px;
  color: #ffe9a8;
}
/* the check sits in its own recessed square -- the detail that makes it
   read as hardware rather than a text glyph */
.hd-check {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  font-style: normal;
  font-size: 14px;
  line-height: 1;
  color: #1a1405;
  background: #ffd75e;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 0 12px rgba(255, 215, 94, 0.55);
  clip-path: polygon(5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%, 0 5px);
}
.hd-status::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 40%, rgba(255, 255, 255, 0.4) 50%, transparent 60%);
  transform: translateX(-160%);
  animation: hd-seal-sheen 3.4s ease-in-out infinite;
  pointer-events: none;
}
@keyframes hd-seal-sheen {
  0%, 70% { transform: translateX(-160%); }
  92%, 100% { transform: translateX(160%); }
}
/* claimed: the seal goes cold -- no sheen, no glow, just a spent stamp */
.hd-status-spent {
  background: rgba(0, 0, 0, 0.4);
  border-color: rgba(255, 215, 94, 0.2);
}
.hd-status-spent .hd-status-text { color: #7d6f45; }
.hd-status-spent .hd-check {
  color: #2a2413;
  background: #6e6134;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.hd-status-spent::after { animation: none; opacity: 0; }
/* reward tile: stands in for CoD's unlock art -- we have no per-challenge
   artwork, so the payout itself is the thing on display */
.hd-reward {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 74px;
  padding: 7px 9px;
  background: rgba(255, 215, 94, 0.08);
  border: 1px solid rgba(255, 215, 94, 0.35);
  clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
}
.hd-reward-num {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  line-height: 1;
  color: #ffd75e;
  text-shadow: 0 0 10px rgba(255, 215, 94, 0.5);
}
.hd-reward-unit {
  margin-top: 3px;
  font-size: 11px;
  letter-spacing: 2px;
  color: #b39433;
}
/* ---- COMPLETE ----
   A finished challenge goes GOLD rather than staying on the mode accent:
   the payout tile is already gold, so the whole row lighting up in the
   reward's colour is what reads as "earned" at a glance -- and it can't
   be confused with an in-progress bar, which is always the accent. */
.hd-row.hd-done {
  background:
    repeating-linear-gradient(90deg, rgba(255, 215, 94, 0.05) 0 1px, transparent 1px 4px),
    rgba(255, 215, 94, 0.09);
  border-left-color: #ffd75e;
}
.hd-row.hd-done .hd-fill {
  background: #ffd75e;
  box-shadow: 0 0 10px rgba(255, 215, 94, 0.75);
}
.hd-row.hd-done .hd-progress {
  color: #ffd75e;
  text-shadow: 0 0 8px rgba(255, 215, 94, 0.5);
}
/* the tile inverts once claimed -- solid gold plate, dark type */
.hd-row.hd-done .hd-reward {
  background: rgba(255, 215, 94, 0.9);
  border-color: #ffd75e;
  box-shadow: 0 0 14px rgba(255, 215, 94, 0.35);
}
.hd-row.hd-done .hd-reward-num { color: #1a1405; text-shadow: none; }
.hd-row.hd-done .hd-reward-unit { color: #4a3c0d; }

/* ---- CLAIM button ----
   Replaces the payout tile the moment a challenge completes. It is the
   loudest thing on the board on purpose: solid gold plate, breathing
   glow, and the signature extrusion pop on hover. */
.hd-claim {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-width: 132px;
  padding: 11px 16px;
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px #ffe38a;
  background: linear-gradient(150deg, #ffe07a, #f5b924);
  clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
  cursor: pointer;
  /* FLAT at rest -- the depth is a hover reward, not the default look.
     A permanent wall made the whole board read as embossed. */
  filter: none;
  /* Short and snappy on purpose -- this is a small button you tap, so the
     pop has to land almost immediately rather than easing in. */
  transition:
    transform 0.12s cubic-bezier(0.34, 1.56, 0.64, 1),
    filter 0.12s ease-out;
}
.hd-claim-label {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 3px;
  line-height: 1;
  /* A cool near-black, deliberately NOT in the gold family. The face used
     to be #1a1405 -- a dark brown, which is where the extrusion wall ends
     up too, so the front of each letter and the back of its wall were the
     same colour and the depth turned to mush. */
  color: #0d1408;
  /* Flat at rest. The extrusion belongs to :hover -- see below. */
  text-shadow: none;
  transition: text-shadow 0.12s ease-out;
}
.hd-claim-sub {
  font-size: 11px;
  letter-spacing: 2px;
  color: #5c4a10;
}
/* The LETTERS carry the extrusion, and only while hovered: eight hard
   steps down-right, each a shade darker, so the word stands up off the
   gold instead of being printed on it.
   The wall stays inside a MID-GOLD range and stops well short of black.
   It used to run down to #2f2404, which is the same value as the letter
   face -- so the far end of the wall and the front of the letter read as
   one shape. A wall must never reach the colour of the face it belongs
   to, or the depth collapses. */
.hd-claim:hover .hd-claim-label,
.hd-claim:focus-visible .hd-claim-label {
  text-shadow:
    1px 1px 0 #b0880f,
    2px 2px 0 #a37e0e,
    3px 3px 0 #96740d,
    4px 4px 0 #896a0c,
    5px 5px 0 #7c600b,
    6px 6px 0 #6f560a,
    7px 7px 0 #624c09,
    8px 8px 0 #554208;
}
/* pressed: the letters sit flat on the plate, same as the wall collapsing */
.hd-claim:active .hd-claim-label {
  text-shadow: 1px 1px 0 rgba(140, 108, 14, 0.7);
}
/* Hover raises it off the SAME wall it already sits on: the plate travels
   up-left while the stack grows to 8 steps, so the gap between face and
   wall widens -- that widening gap is the whole illusion. The wall stays
   in a mid-gold range and never darkens toward black, or it disappears
   into this near-black card. */
.hd-claim:hover, .hd-claim:focus-visible {
  transform: translate(-8px, -8px) scale(1.04);
  filter:
    brightness(1.07)
    drop-shadow(2px 2px 0 #f0b91e)
    drop-shadow(2px 2px 0 #e0aa1c)
    drop-shadow(2px 2px 0 #cf9a18)
    drop-shadow(2px 2px 0 #bd8a14)
    drop-shadow(2px 2px 0 #ab7b11)
    drop-shadow(2px 2px 0 #996c0e)
    drop-shadow(2px 2px 0 #875f0c)
    drop-shadow(2px 2px 0 #75520a)
    drop-shadow(0 0 18px rgba(255, 215, 94, 0.8));
}
/* Slams all the way down onto the board: the plate travels the full 8px
   back AND the wall collapses to nothing, so the key bottoms out. */
.hd-claim:active {
  transform: translate(0, 0) scale(0.95);
  transition-duration: 0.04s;
  filter: brightness(1.16) drop-shadow(0 0 10px rgba(255, 215, 94, 0.85));
}
.hd-claim:disabled { cursor: default; }
/* a light sweeps across the plate, like a shine passing over metal */
.hd-claim::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 38%, rgba(255, 255, 255, 0.75) 50%, transparent 62%);
  transform: translateX(-140%);
  animation: hd-claim-shine 2.6s ease-in-out infinite;
  pointer-events: none;
}
@keyframes hd-claim-shine {
  0%, 62% { transform: translateX(-140%); }
  86%, 100% { transform: translateX(140%); }
}

/* ---- spent / claimed ----
   Cools right down: the row stops competing for attention once there's
   nothing left to do with it. */
.hd-row.hd-claimed {
  background: rgba(255, 255, 255, 0.03);
  border-left-color: rgba(255, 215, 94, 0.35);
}
.hd-row.hd-claimed .hd-name { color: #8f9a8b; }
.hd-row.hd-claimed .hd-fill {
  background: rgba(255, 215, 94, 0.4);
  box-shadow: none;
}
.hd-row.hd-claimed .hd-progress { color: #8a7a45; text-shadow: none; }
/* Genuinely dark once claimed -- the payout is spent, so the tile reads
   as a burnt-out plate rather than a dimmer version of the live one.
   Selector is scoped through .hd-done on purpose: a claimed row is ALSO
   a done row, and `.hd-row.hd-done .hd-reward` (0,3,0) would otherwise
   outrank a bare `.hd-reward-spent` (0,1,0) and keep the tile gold. */
.hd-row.hd-done .hd-reward-spent {
  background: rgba(0, 0, 0, 0.55);
  border-color: rgba(255, 215, 94, 0.16);
  box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.6);
}
.hd-row.hd-done .hd-reward-spent .hd-reward-num { color: #6a5c33; text-shadow: none; }
.hd-row.hd-done .hd-reward-spent .hd-reward-unit { color: #4d4326; font-size: 10px; }

/* ---- claim burst ----
   Its own fixed layer, deliberately OUTSIDE the board: .hd-row and
   #mp-daily are clip-path plates, and clip-path clips descendants, so
   shards drawn inside a card would be sliced off at the card edge --
   which is precisely where they need to fly past. Sits under the CRT
   glass (700) so the sparks read as being on the screen, not above it. */
#hd-burst {
  position: fixed;
  inset: 0;
  z-index: 650;
  pointer-events: none;
  overflow: hidden;
}
/* Targeting reticle: four corner brackets, no closed outline. Square
   geometry and hard corners are what make it read as a HUD lock-on
   instead of a ripple -- same cut-corner language as every plate here. */
.hd-reticle { position: absolute; }
.hd-reticle i {
  position: absolute;
  width: 12px;
  height: 12px;
  /* hairline brackets with a soft halo -- heavy 2px borders and a hot
     glow made this shout over a quiet sound */
  border: 1px solid #ffe38a;
  box-shadow: 0 0 7px rgba(255, 215, 94, 0.5);
}
.hd-reticle i:nth-child(1) { top: -4px; left: -4px; border-right: 0; border-bottom: 0; }
.hd-reticle i:nth-child(2) { top: -4px; right: -4px; border-left: 0; border-bottom: 0; }
.hd-reticle i:nth-child(3) { bottom: -4px; right: -4px; border-left: 0; border-top: 0; }
.hd-reticle i:nth-child(4) { bottom: -4px; left: -4px; border-right: 0; border-top: 0; }
/* Data slice: a lance through the centre that tears open and snaps shut.
   Scaled rather than resized so it stays on the compositor. Kept just
   wider than the button -- it should underline the press, not cross the
   card. */
.hd-slice {
  position: absolute;
  width: 165px;
  height: 3px;
  background: linear-gradient(90deg, transparent, rgba(255, 246, 216, 0.85) 22%, #ffd75e 50%, rgba(255, 246, 216, 0.85) 78%, transparent);
  box-shadow: 0 0 10px rgba(255, 215, 94, 0.55);
}
.hd-shard {
  position: absolute;
  width: 5px;
  height: 5px;
  background: #ffe07a;
  box-shadow: 0 0 6px rgba(255, 215, 94, 0.6);
  /* angular chips, not dots -- same geometry language as the plates */
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.hd-xp-fly {
  position: absolute;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 2px;
  white-space: nowrap;
  /* Near-white core with a gold halo around it -- a flat gold fill reads
     as dim against the plate it lifts off. The brightness lives in the
     text colour, not in a bigger size, so it stays restrained. */
  color: #fffaea;
  text-shadow:
    0 0 6px #ffe07a,
    0 0 14px rgba(255, 215, 94, 0.9),
    0 0 30px rgba(255, 215, 94, 0.45);
}
/* footer strip: reset copy on the right, dev control (if any) on the left */
.hd-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 12px;
  padding-top: 8px;
  border-top: 1px solid var(--mp-accent-dim, rgba(134, 255, 102, 0.2));
}
.hd-reset {
  margin-left: auto;
  font-size: 16px;
  letter-spacing: 2px;
  text-align: right;
  color: #6da762;
}
/* DEV ONLY control -- deliberately plain and dim. It is not part of the
   game's visual language because it is not part of the game; keeping it
   ugly makes it obvious it should never ship. */
#hd-dev-reset {
  padding: 3px 9px;
  background: none;
  border: 1px dashed rgba(255, 255, 255, 0.28);
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 1px;
  color: #7a8a76;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#hd-dev-reset:hover { color: #cfe0ca; border-color: rgba(255, 255, 255, 0.6); }
#hd-dev-reset.hidden { display: none; }
/* DEV ONLY -- same reasoning as above: intentionally ugly so it can never
   be mistaken for shippable UI. Pinned top-right, above the CRT glass so
   it stays clickable on the full-bleed vote screens. */
#dev-skip-results {
  position: fixed;
  top: 14px;
  right: 14px;
  z-index: 900;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px dashed rgba(255, 255, 255, 0.32);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #7a8a76;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-skip-results:hover { color: #cfe0ca; border-color: rgba(255, 255, 255, 0.65); }
#dev-skip-results.hidden { display: none; }
/* DEV ONLY -- sits directly under END ROUND, same deliberately-ugly plate so
   it can never be mistaken for real UI. STRIP with the rest. */
#dev-force-tie {
  position: fixed;
  top: 46px;
  right: 14px;
  z-index: 900;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px dashed rgba(255, 193, 77, 0.4);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #9a8a66;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-force-tie:hover { color: #ffd899; border-color: rgba(255, 193, 77, 0.85); }
#dev-force-tie.hidden { display: none; }
/* DEV ONLY -- same deliberately-ugly treatment, inside the social drawer. */
#dev-party-bot {
  display: block;
  width: 100%;
  margin-top: 14px;
  padding: 9px 12px;
  background: none;
  border: 1px dashed rgba(255, 255, 255, 0.28);
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1px;
  color: #7a8a76;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-party-bot:hover { color: #cfe0ca; border-color: rgba(255, 255, 255, 0.6); }
#dev-party-bot.hidden { display: none; }
.menu-item {
  /* The button hugs the text exactly -- hover/click only on the word. */
  width: auto;
  padding: 6px 0;
  position: relative;
  background: none;
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* Arrow + title both key off this so they scale together. */
  font-size: clamp(30px, calc(2.5 * var(--dvw, 1vw)), 48px);
  /* The hue cycle, PER ITEM -- moved down from .menu-list (see the note
     there for why the shared surface had to go). Every item reads the same
     variable, so there's still no per-item drift. On the button rather
     than the title keeps the hue matrix OFF the drop-shadow chain: the
     title's blurred glow is cached once and this just colour-rotates the
     cached surface, same trade the logo makes ("THE HUE ROTATION MOVES
     OFF THE BLURS, ONTO THEIR PARENT"). */
  filter: hue-rotate(var(--logo-hue));
}
.menu-item-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: inherit;
  font-weight: normal;
  letter-spacing: 2px;
  /* Hollow, like the logo's linework, instead of a solid chunky fill --
     stroke only, no interior colour. The glow is drop-shadow, NOT
     text-shadow: text-shadow blurs the whole glyph SILHOUETTE (fill
     included, transparent or not), which painted the counters back in;
     drop-shadow works on the rendered pixels, so only the stroke blooms. */
  color: transparent;
  /* WHITE core, GREEN halo -- the exact material the logo is made of (white
     linework, drop-shadow(rgba(57,255,20)) bloom, hue cycle from the shared
     --logo-hue on .menu-list). Same neon recipe, smaller tube. */
  -webkit-text-stroke: 1.5px #fff;
  /* The LOGO's exact glow pair (see #logo-img in the hue-rotation section,
     at rest / pulse 0): a tight near-opaque core hugging the linework plus
     a wide soft bloom thrown into the air around it. Same values verbatim,
     so the words read as smaller tubes of the same sign. The wide 38px
     bloom spreads thin enough not to bring back the rectangular fog the
     old mid-size (14px) second shadow made. */
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
  /* Springy overshoot on the transform sells the "pop". The FILTER is NOT
     transitioned, deliberately (the last act of the black-band saga, video
     evidence in hand): rest and hover are different drop-shadow pairs, and
     tweening between them interpolates the bright core INTO the dark
     extrusion shadow while the bloom shrinks -- for ~100ms the word's neon
     bottoms out, and on the dark room that dip reads as a dark horizontal
     band flashing across the row (much deeper in Firefox's interpolation
     than Chrome's, which is why it looked Firefox-only). A neon sign
     doesn't crossfade anyway -- it SNAPS -- so the glow switches
     instantly and only the transform springs. */
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Hover: the title lifts toward you (up-left + a smidge of scale) while a
   stack of progressively darker steps trails down-right behind it -- a 3D
   extrusion, like the letters physically pushed out of the screen. */
.menu-item:hover .menu-item-title,
.menu-item:focus .menu-item-title {
  transform: translate(-3px, -3px) scale(1.07);
  /* Same story as the base state: drop-shadow, not text-shadow, so the
     extrusion steps trail the STROKE outline and the counters stay hollow
     instead of solid glyph copies showing through the transparent fill.
     Kept SHORT on purpose -- every extra drop-shadow re-blurs the whole
     stack before it, and past ~3 the glow mushrooms into a rectangular fog
     with a visible edge around the word. */
  /* NO wide bloom here, on purpose: stacked on the extrusion step it
     compounds into a visible rectangular fog around the word (the resting
     state gets away with the 38px bloom because it is only two shadows
     deep). The lift reads from the extrusion + a hotter core instead. */
  filter:
    drop-shadow(2px 2px 0 rgba(19, 112, 8, 0.9))
    drop-shadow(var(--sw-glow-core-hot));
}
/* Press: the word SQUISHES flat into the screen -- extrusion collapses,
   letters compress -- same slam language as every button in the app. */
.menu-item:active .menu-item-title {
  transform: translate(0, 0) scale(0.94);
  filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.8));
  transition-duration: 0.06s;
}
.menu-item-sub { display: none; }

/* ---------- mode select: SF6-style split screen ----------
   Producer owns the left in red, Artist the right in blue, divided by a
   neon chevron seam (kink at mid-height, per sketch). Hovering a side
   floods it with its color, pops the label with the signature extrusion,
   and dims the other side. */
/* The section fills the viewport so the snap-open animation expands from
   the true middle of the screen; split, title and back button all layer
   over it as fixed full-bleed chrome. */
#screen-mode-select.active {
  min-height: calc(100 * var(--dvh, 1vh));
}
/* Title sits on its own HUD plate (same language as the profile badge:
   hot neon outline, dark interior, angular banner shape) so it never
   blends into whichever color floods the panel behind it. */
#mode-title {
  position: fixed;
  top: 26px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 20;
  pointer-events: none;
  margin: 0;
  /* Slightly asymmetric on purpose: +4px left cancels the trailing
     letter-spacing gap, +1px top cancels the font's high ink bias --
     measured so the GLYPHS are dead center, not just the text box. */
  padding: 15px 64px 13px 68px;
  white-space: nowrap;
  text-align: center;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: clamp(28px, calc(3 * var(--dvw, 1vw)), 54px);
  letter-spacing: 4px;
  color: #39ff14;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.9), 0 0 22px rgba(57, 255, 20, 0.5);
  filter:
    drop-shadow(0 0 8px rgba(57, 255, 20, 0.7))
    drop-shadow(0 0 22px rgba(57, 255, 20, 0.4))
    hue-rotate(var(--logo-hue));
}
/* Banner plate: pointed caps left/right, border + dark fill via two
   stacked clip-path layers behind the text. */
#mode-title::before,
#mode-title::after {
  content: '';
  position: absolute;
  z-index: -1;
}
#mode-title::before {
  inset: 0;
  background: #86ff66;
  clip-path: polygon(36px 0, calc(100% - 36px) 0, 100% 50%, calc(100% - 36px) 100%, 36px 100%, 0 50%);
}
#mode-title::after {
  inset: 3px;
  background: rgba(8, 16, 8, 0.94);
  clip-path: polygon(34px 0, calc(100% - 34px) 0, 100% 50%, calc(100% - 34px) 100%, 34px 100%, 0 50%);
}
#mode-split {
  position: fixed;
  inset: 0;
  margin: 0;
}
.mode-panel {
  position: absolute;
  inset: 0;
  border: none;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  font-family: inherit;
  transition:
    background 0.15s,
    filter 0.15s,
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    clip-path 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* The whole panel is a giant pressable button: it lifts toward you with
   the signature spring, extrusion steps trail behind its clipped shape
   (drop-shadow follows the chevron edge), and it presses back down on
   click. */
.mode-panel:hover {
  transform: translate(-6px, -6px) scale(1.015);
}
.mode-panel:active {
  transform: translate(-1px, -1px) scale(0.998);
  transition-duration: 0.06s;
}
/* Chevron divide: top at 52%, kink left to 42% at mid, bottom at 55%.
   --seam-shift slides the whole boundary sideways -- the hovered side
   shoves the divide into the other side's territory (set on #mode-split
   below). Both panels and the seam read the same var so the three edges
   move as one. */
.mode-producer {
  clip-path: polygon(
    0 0,
    calc(52% + var(--seam-shift, 0%)) 0,
    calc(42% + var(--seam-shift, 0%)) 50%,
    calc(55% + var(--seam-shift, 0%)) 100%,
    0 100%);
  padding-right: 52%;
  background: rgba(255, 45, 45, 0.08);
}
.mode-artist {
  clip-path: polygon(
    calc(52% + var(--seam-shift, 0%)) 0,
    100% 0,
    100% 100%,
    calc(55% + var(--seam-shift, 0%)) 100%,
    calc(42% + var(--seam-shift, 0%)) 50%);
  padding-left: 52%;
  background: rgba(58, 108, 255, 0.08);
}
/* The shove itself. */
#mode-split:has(.mode-producer:hover) { --seam-shift: 3.5%; }
#mode-split:has(.mode-artist:hover) { --seam-shift: -3.5%; }
/* Neon seam tracing the chevron, hue-synced with the logo. */
.mode-seam {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: #39ff14;
  clip-path: polygon(
    calc(51.7% + var(--seam-shift, 0%)) 0,
    calc(52.3% + var(--seam-shift, 0%)) 0,
    calc(42.3% + var(--seam-shift, 0%)) 50%,
    calc(55.3% + var(--seam-shift, 0%)) 100%,
    calc(54.7% + var(--seam-shift, 0%)) 100%,
    calc(41.7% + var(--seam-shift, 0%)) 50%);
  filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.9)) hue-rotate(var(--logo-hue));
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    clip-path 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* The seam rides the lifted panel: it shares the exact same box as the
   panels, so the identical transform keeps it glued to the moving chevron
   edge -- no color ever peeks past it. The static side's bare edge hides
   in that side's dimmed darkness. */
#mode-split:has(.mode-panel:hover) .mode-seam {
  transform: translate(-6px, -6px) scale(1.015);
}
#mode-split:has(.mode-panel:active) .mode-seam {
  transform: translate(-1px, -1px) scale(0.998);
}
.mode-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(42px, calc(5.5 * var(--dvw, 1vw)), 100px);
  letter-spacing: 4px;
  transform: skew(-6deg);
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    color 0.15s, text-shadow 0.15s;
}
.mode-producer .mode-name { color: rgba(255, 90, 90, 0.85); text-shadow: 0 0 12px rgba(255, 45, 45, 0.5); }
.mode-artist .mode-name { color: rgba(110, 150, 255, 0.85); text-shadow: 0 0 12px rgba(58, 108, 255, 0.5); }
.mode-sub {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  color: #9aa;
  max-width: 320px;
  transition: color 0.15s;
}
/* Hover: SF6 color flood + signature extrusion pop on the label. */
.mode-producer:hover {
  background: linear-gradient(180deg, rgba(255, 45, 45, 0.75), rgba(120, 8, 8, 0.9));
  filter:
    drop-shadow(4px 4px 0 rgba(140, 12, 12, 0.9))
    drop-shadow(5px 5px 0 rgba(88, 6, 6, 0.8))
    drop-shadow(0 0 30px rgba(255, 45, 45, 0.55));
}
.mode-artist:hover {
  background: linear-gradient(180deg, rgba(58, 108, 255, 0.75), rgba(10, 24, 120, 0.9));
  filter:
    drop-shadow(4px 4px 0 rgba(22, 44, 140, 0.9))
    drop-shadow(5px 5px 0 rgba(12, 24, 88, 0.8))
    drop-shadow(0 0 30px rgba(58, 108, 255, 0.55));
}
.mode-panel:hover .mode-name {
  transform: skew(-6deg) translate(-3px, -3px) scale(1.07);
  color: #fff;
}
.mode-producer:hover .mode-name {
  text-shadow:
    1px 1px 0 rgba(150, 20, 20, 0.95),
    2px 2px 0 rgba(120, 14, 14, 0.85),
    3px 3px 0 rgba(95, 10, 10, 0.75),
    4px 4px 0 rgba(70, 6, 6, 0.6),
    5px 5px 0 rgba(45, 3, 3, 0.45),
    0 0 14px rgba(255, 70, 70, 1),
    0 0 34px rgba(255, 45, 45, 0.7);
}
.mode-artist:hover .mode-name {
  text-shadow:
    1px 1px 0 rgba(24, 50, 150, 0.95),
    2px 2px 0 rgba(18, 40, 120, 0.85),
    3px 3px 0 rgba(14, 30, 95, 0.75),
    4px 4px 0 rgba(9, 20, 70, 0.6),
    5px 5px 0 rgba(5, 12, 45, 0.45),
    0 0 14px rgba(100, 150, 255, 1),
    0 0 34px rgba(58, 108, 255, 0.7);
}
.mode-panel:hover .mode-sub { color: #fff; }
/* The side you're NOT hovering falls back into the dark. */
#mode-split:has(.mode-producer:hover) .mode-artist,
#mode-split:has(.mode-artist:hover) .mode-producer {
  filter: brightness(0.45) saturate(0.5);
}
#screen-mode-select .btn-row {
  position: fixed;
  bottom: 24px;
  left: 0;
  right: 0;
  z-index: 20;
  justify-content: center;
}

/* ---------- profile: Japanese-videogame cyberpunk HUD ----------
   Segmented neon frames with corner ticks, slanted katakana section tags,
   chamfered picker tiles, mono inputs on dark glass, signature extrusion
   on the action buttons. Chrome stays static green here (no hue cycle) so
   the icon COLOR choices read true. */
#profile-title,
#gang-title,
#store-title {
  display: flex;
  align-items: baseline;
  gap: 16px;
  margin: 0 0 10px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: 54px;
  letter-spacing: 3px;
  color: #39ff14;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.9), 0 0 22px rgba(57, 255, 20, 0.5);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#profile-title .jp-sub,
#gang-title .jp-sub,
#store-title .jp-sub {
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 3px;
  color: #4d8f44;
  text-shadow: none;
}
.jp-panel {
  position: relative;
  /* Adjacent block margins COLLAPSE to the larger one, not their sum -- so
     22px here was really only a 22px gap between panels, and the next
     panel's tag sits at top:-20px (it overlaps its own panel's top edge on
     purpose, same "breaks the corner" look the identity picture uses). 22
     minus that 20 left about 2px of real clearance above the tag pill,
     which is what read as everything touching. */
  margin: 44px 0;
  /* Top padding well clear of the tag: the tag sits at top:-20px and stands
     roughly 39px tall, so its bottom edge lands about 19px into the panel --
     26px of padding left only ~7px of real clearance before the first
     interactable (the icon row, the textarea, the name field), on every
     panel, which is what read as the header touching its own content. */
  padding: 52px 26px 20px;
  /* Dark grey with a fine texture instead of a flat tint -- two diagonal
     hatches at different angles and spacing so they don't moire into a
     single visible grid, over a neutral grey base rather than the green-
     black that used to tie every panel's fill to the border's own colour. */
  background:
    repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 5px),
    repeating-linear-gradient(-45deg, rgba(0, 0, 0, 0.35) 0 1px, transparent 1px 6px),
    #17181a;
}
/* The whole frame (segmented border + corner ticks) lives on its own
   layer so it can hue-drift via --ui-hue without recoloring the panel's
   contents -- icon and color pickers must always read true. */
.jp-panel::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Frame drawn edge-by-edge so each break is placed deliberately: top
     solid (the tag interrupts it anyway), ONE gap on each side edge at
     different heights, ONE on the bottom. Sparse and asymmetric, like the
     sketch. Plus the corner ticks. */
  background:
    linear-gradient(rgba(134, 255, 102, 0.85), rgba(134, 255, 102, 0.85)) top left / 100% 2px,
    linear-gradient(90deg, rgba(134, 255, 102, 0.85) 0 24%, transparent 24% 26.5%, rgba(134, 255, 102, 0.85) 26.5% 100%) bottom left / 100% 2px,
    linear-gradient(180deg, rgba(134, 255, 102, 0.85) 0 34%, transparent 34% 44%, rgba(134, 255, 102, 0.85) 44% 100%) top left / 2px 100%,
    linear-gradient(180deg, rgba(134, 255, 102, 0.85) 0 58%, transparent 58% 68%, rgba(134, 255, 102, 0.85) 68% 100%) top right / 2px 100%,
    linear-gradient(#86ff66, #86ff66) top left / 20px 4px,
    linear-gradient(#86ff66, #86ff66) top left / 4px 20px,
    linear-gradient(#86ff66, #86ff66) bottom right / 20px 4px,
    linear-gradient(#86ff66, #86ff66) bottom right / 4px 20px;
  background-repeat: no-repeat;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.jp-panel-tag {
  position: absolute;
  top: -20px;
  left: 20px;
  /* Text is already centered WITHIN the plate -- padding is symmetric
     (8px 30px) either side of the label. This is about the plate's own
     position on the panel, which stays left-pinned; centering the whole
     plate across the panel width (translateX(-50%), tried before) read as
     the tag floating loose in the middle rather than sitting on the
     panel's corner the way the rest of the frame language does. */
  padding: 8px 30px;
  background: #0a140a;
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px #86ff66;
  clip-path: polygon(11px 0, 100% 0, calc(100% - 11px) 100%, 0 100%);
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 29px;
  letter-spacing: 2px;
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.6);
  white-space: nowrap;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.jp-panel-tag-sub {
  margin-left: 10px;
  font-size: 14px;
  letter-spacing: 3px;
  opacity: 0.4;
}
/* The identity panel needs headroom: its preview box deliberately BREAKS
   OUT of the frame's top-left corner (per sketch) rather than sitting
   inside it. */
/* The picture overshoots ABOVE this panel's own top edge (see
   #profile-preview-box), so this margin is also the clearance between the
   title and the picture -- it was actually overlapping the title by ~6px
   before this. Room reserved here on purpose for a frame graphic later. */
#screen-profile .jp-panel:first-of-type { margin-top: 90px; }
#profile-id-row { display: block; }
/* Nudge the name up a touch: it sits in the panel's own content band
   (below the identity tag's clearance, above the panel's bottom padding)
   which is centered a few px lower than this. A visual-only transform
   (no layout impact) rather than growing the row's height -- a taller
   row just pushes the whole vertically-centered frame down by half as
   much, cancelling the centering out. */
#profile-id-fields { transform: translateY(-6px); }
/* The picture IS the button (see index.html) -- reset the <button> chrome
   the browser would otherwise add on top of these, then a few px bigger
   than before, both for its own presence and to leave room for a frame
   graphic around it later. */
#profile-preview-box {
  position: absolute;
  top: -74px;
  left: -60px;
  z-index: 2;
  width: 232px;
  height: 232px;
  margin: 0;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #060d05;
  border: none;
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
  cursor: pointer;
  font-family: inherit;
}
/* Border + glow live on a pseudo-element (not the box itself) so the
   hue-rotate filter that follows --ui-hue only recolors the frame --
   applying it to the whole box would also hue-shift the player's actual
   uploaded avatar image sitting inside it. */
#profile-preview-box::after {
  content: '';
  position: absolute;
  inset: 0;
  border: 2px solid #86ff66;
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.45), inset 0 0 10px rgba(57, 255, 20, 0.2);
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
  pointer-events: none;
  filter: hue-rotate(var(--ui-hue, 0deg));
  transition: box-shadow 0.16s ease-out, border-color 0.16s ease-out;
}
#profile-preview-box:hover::after,
#profile-preview-box:focus-visible::after {
  border-color: #b6ffa0;
  box-shadow: 0 0 20px rgba(57, 255, 20, 0.65), inset 0 0 14px rgba(57, 255, 20, 0.3);
}
/* The instruction that used to be a permanent label + hint paragraph +
   button underneath -- now it only shows up when you are actually looking
   to change the picture, sitting right on top of the thing it changes. */
#profile-preview-hint {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 8px;
  background: rgba(3, 8, 3, 0.82);
  color: #d8ffd0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  line-height: 1.35;
  letter-spacing: 2px;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.6);
  opacity: 0;
  transition: opacity 0.16s ease-out;
  pointer-events: none;
}
#profile-preview-box:hover #profile-preview-hint,
#profile-preview-box:focus-visible #profile-preview-hint {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  #profile-preview-box, #profile-preview-hint { transition: none; }
}
#profile-preview-box .player-icon {
  width: 150px;
  height: 150px;
  font-size: 82px;
}
#profile-avatar {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
#profile-avatar.hidden { display: none; }
/* Both offsets track the picture box's own geometry (top:-64/left:-60,
   232 wide -- its right edge sits 172px past the panel's left border) so
   growing the box again later just means moving these by the same delta,
   not re-deriving them. */
#profile-id-fields { margin-left: 202px; }
/* Slide the identity tag right so the escaped box doesn't cover it. */
#screen-profile .jp-panel:first-of-type .jp-panel-tag { left: 202px; }
.jp-label {
  display: block;
  margin: 15px 0 7px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 2px;
  color: #6dcf5a;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#profile-id-fields .jp-label:first-child { margin-top: 0; }
#screen-profile input[type="text"],
#screen-profile textarea,
#screen-clans input[type="text"],
#screen-clans textarea {
  width: 100%;
  padding: 14px 18px;
  background: rgba(4, 10, 4, 0.85);
  border: 1px solid rgba(134, 255, 102, 0.35);
  border-left: 4px solid rgba(134, 255, 102, 0.6);
  color: #d8ffd0;
  font-family: 'Courier New', monospace;
  font-size: 23px;
  outline: none;
  transition:
    border-color 0.15s,
    box-shadow 0.2s,
    transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
/* Hover: just a whisper of glow. */
#screen-profile input[type="text"]:hover:not(:focus),
#screen-profile textarea:hover:not(:focus),
#screen-clans input[type="text"]:hover:not(:focus),
#screen-clans textarea:hover:not(:focus) {
  border-color: rgba(134, 255, 102, 0.55);
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.18);
}
/* Focus: the field lifts gently (softer cousin of the button extrusion)
   and idles with a slow calm breathing glow while you type. */
#screen-profile input[type="text"]:focus,
#screen-profile textarea:focus,
#screen-clans input[type="text"]:focus,
#screen-clans textarea:focus {
  border-color: #86ff66;
  transform: translate(-2px, -2px);
  animation: jp-input-breathe 3.2s ease-in-out infinite;
}
@keyframes jp-input-breathe {
  0%, 100% {
    box-shadow:
      2px 2px 0 rgba(20, 110, 10, 0.55),
      4px 4px 0 rgba(10, 60, 5, 0.35),
      0 0 10px rgba(57, 255, 20, 0.25),
      inset 0 0 8px rgba(57, 255, 20, 0.1);
  }
  50% {
    box-shadow:
      2px 2px 0 rgba(20, 110, 10, 0.8),
      4px 4px 0 rgba(10, 60, 5, 0.55),
      0 0 22px rgba(57, 255, 20, 0.5),
      inset 0 0 14px rgba(57, 255, 20, 0.22);
  }
}
#screen-profile textarea { resize: vertical; height: 108px; }
#profile-emojis {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
#profile-emojis .pick-option {
  width: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 30px;
  background: rgba(4, 10, 4, 0.85);
  border: 1px solid rgba(134, 255, 102, 0.25);
  clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px);
  cursor: pointer;
  transition: border-color 0.12s, box-shadow 0.12s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#profile-emojis .pick-option:hover {
  transform: translate(-2px, -2px);
  border-color: rgba(134, 255, 102, 0.7);
}
#profile-emojis .pick-option.selected {
  border-color: #86ff66;
  background: rgba(18, 36, 15, 0.95);
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.55), inset 0 0 8px rgba(57, 255, 20, 0.25);
}
#profile-colors {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
#profile-colors .color-swatch {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.15);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.12s, border-color 0.12s;
}
#profile-colors .color-swatch:hover { transform: translate(-2px, -2px); }
#profile-colors .color-swatch.selected {
  border-color: #fff;
  box-shadow: 0 0 12px rgba(255, 255, 255, 0.55);
}
/* ---- profile editor: connected-apps icon row + popover ----
   Replaces six always-visible typed URL fields with one logo per app.
   Reuses .ctx-icon-btn's clip-path/size so it matches the profile CARD's
   own social row exactly -- same shape language, editable here instead of
   just clickable. Empty vs filled is the only state that needs to read at
   a glance, so it's a dot, not a label. */
.profile-apps-row {
  display: flex;
  flex-wrap: wrap;
  gap: 26px;
  padding: 6px 0 2px;
}
/* Bigger than the shared .ctx-icon-btn base -- scoped to this row so context
   menus and the read-only card elsewhere, which reuse the same class at its
   original size, are untouched. */
.profile-apps-row .ctx-icon-btn { width: 64px; height: 64px; }
.profile-apps-row .ctx-icon-btn svg { width: 34px; height: 34px; }
/* Rides the same drift the panels and their borders already do -- this row
   was still authored-green while everything around it cycled, which is why
   it read as frozen. Each icon rotates itself (rather than the row doing it
   once for all of them) so hover below can switch this one element's own
   filter off without fighting a parent's. */
.profile-app-btn {
  position: relative;
  filter: hue-rotate(var(--ui-hue, 0deg));
  transition: filter 0.16s ease-out;
}
.profile-app-btn svg { fill: #6a8a62; color: #6a8a62; transition: fill 0.16s ease-out, color 0.16s ease-out; }
.profile-app-btn:hover svg { fill: #eaffe3; color: #eaffe3; }
/* On hover the icon should say which app it is, not whatever colour the
   ambient cycle happens to be passing through -- so the drift turns off
   right here (a filter with no hue-rotate function cancels this element's
   OWN rotation; it is not a parent's to cancel) and each one lights up in
   its real brand colour instead of the shared green.
   drop-shadow, not box-shadow, for the glow -- same clip-path corner cut as
   .ctx-icon-btn above, same fix: box-shadow gets erased exactly at the
   diagonal cut, drop-shadow is cast from the already-clipped shape so it
   wraps the corner instead. Folded into ONE filter declaration per icon
   (rather than filter:none plus a separate box-shadow) since a second
   `filter` rule would simply replace the first, not add to it. */
.profile-app-btn:hover { filter: none; }
.profile-app-btn[data-social="youtube"]:hover {
  border-color: #ff0000;
  background: rgba(255, 0, 0, 0.16);
  filter: drop-shadow(0 0 8px rgba(255, 0, 0, 0.65));
}
.profile-app-btn[data-social="youtube"]:hover svg { fill: #ff4d4d; color: #ff4d4d; }
.profile-app-btn[data-social="spotify"]:hover {
  border-color: #1db954;
  background: rgba(29, 185, 84, 0.16);
  filter: drop-shadow(0 0 8px rgba(29, 185, 84, 0.65));
}
.profile-app-btn[data-social="spotify"]:hover svg { fill: #1ed760; color: #1ed760; }
.profile-app-btn[data-social="soundcloud"]:hover {
  border-color: #ff5500;
  background: rgba(255, 85, 0, 0.16);
  filter: drop-shadow(0 0 8px rgba(255, 85, 0, 0.65));
}
.profile-app-btn[data-social="soundcloud"]:hover svg { fill: #ff7733; color: #ff7733; }
.profile-app-btn[data-social="genius"]:hover {
  border-color: #ffff64;
  background: rgba(255, 255, 100, 0.14);
  filter: drop-shadow(0 0 8px rgba(255, 255, 140, 0.6));
}
.profile-app-btn[data-social="genius"]:hover svg { fill: #ffff8c; color: #ffff8c; }
.profile-app-btn[data-social="instagram"]:hover {
  border-color: #e4405f;
  background: rgba(228, 64, 95, 0.16);
  filter: drop-shadow(0 0 8px rgba(228, 64, 95, 0.65));
}
.profile-app-btn[data-social="instagram"]:hover svg { fill: #ff6f91; color: #ff6f91; }
/* X has no bright brand colour (it's black-on-white) -- a black glow would
   not read on a black panel, so this one lights up white instead, the only
   choice that is both "true to the brand" and visible here. */
.profile-app-btn[data-social="twitter"]:hover {
  border-color: #ffffff;
  background: rgba(255, 255, 255, 0.14);
  filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.65));
}
.profile-app-btn[data-social="twitter"]:hover svg { fill: #ffffff; color: #ffffff; }
.profile-app-btn.pa-connected {
  border-color: rgba(134, 255, 102, 0.8);
  background: rgba(40, 88, 26, 0.55);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.35);
}
.profile-app-btn.pa-connected svg { fill: #9fe88a; color: #9fe88a; }
/* The green connected-status dot is gone -- pa-connected still changes the
   icon's own border/background/fill (see above), that alone says "this one
   is filled in" without a light bolted on top of it. */

/* The one popover every icon click reuses, repositioned by JS. Same
   plate language as #mp-ctx (dark fill, hard green outline via
   clip-path) but a single input, not a menu. */
#social-popover {
  position: fixed;
  z-index: 960;
  width: 260px;
  padding: 14px 16px 16px;
  background: rgba(8, 16, 8, 0.97);
  border: 1px solid rgba(134, 255, 102, 0.45);
  clip-path: polygon(0 8%, 6% 0, 94% 0, 100% 8%, 100% 100%, 0 100%);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px rgba(57, 255, 20, 0.25);
}
#social-popover-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
#social-popover-icon { width: 20px; height: 20px; flex: none; display: flex; }
#social-popover-icon svg { width: 100%; height: 100%; fill: #9fe88a; color: #9fe88a; }
#social-popover-label {
  flex: 1;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 2px;
  color: #b9dcb0;
}
#social-popover-close {
  background: none; border: none; padding: 0 2px;
  color: #7fae72; font-size: 15px; cursor: pointer; line-height: 1;
}
#social-popover-close:hover { color: #ff8a8a; }
#social-popover-input {
  width: 100%;
  padding: 9px 10px;
  background: rgba(4, 10, 4, 0.9);
  border: 1px solid rgba(134, 255, 102, 0.35);
  color: #eaffe3;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1px;
}
#social-popover-input:focus { outline: none; border-color: #86ff66; }
#social-popover .btn-row { margin: 10px 0 0; flex-wrap: nowrap; }
#social-popover .jp-btn { padding: 8px 0; font-size: 14px; letter-spacing: 2px; flex: 1; text-align: center; }
.jp-btn {
  padding: 13px 56px;
  background: rgba(10, 20, 8, 0.9);
  border: 2px solid #86ff66;
  clip-path: polygon(18px 0, 100% 0, calc(100% - 18px) 100%, 0 100%);
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 28px;
  letter-spacing: 2px;
  color: #86ff66;
  /* The label carries its own depth, separate from the slab's. */
  text-shadow:
    1px 1px 0 rgba(15, 90, 8, 0.8),
    2px 2px 0 rgba(10, 55, 5, 0.55),
    0 0 8px rgba(57, 255, 20, 0.7);
  cursor: pointer;
  /* Raised slab even at rest -- two hard depth steps under it always. */
  /* --btn-hue: per-button constant tint (e.g. orange PREVIEW) layered on
     top of the app-wide drift -- the whole extrusion recipe rotates with
     it, so variants keep the exact same depth behavior. */
  filter:
    drop-shadow(2px 2px 0 rgba(20, 110, 10, 0.7))
    drop-shadow(4px 4px 0 rgba(10, 55, 5, 0.5))
    hue-rotate(var(--ui-hue, 0deg))
    hue-rotate(var(--btn-hue, 0deg));
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    color 0.12s,
    filter 0.15s,
    text-shadow 0.15s,
    letter-spacing 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Hover: MASSIVE extrusion -- big lift plus a compounding four-step depth
   stack (each drop-shadow extrudes the previous result, so the trail gets
   chunky fast) and a hot glow. The LABEL pushes out even harder: its own
   six-step trail plus a slight spread of the letters. */
.jp-btn:hover:not(:disabled) {
  transform: translate(-7px, -7px) scale(1.07);
  color: #fff;
  letter-spacing: 5px;
  text-shadow:
    1px 1px 0 rgba(34, 165, 18, 1),
    2px 2px 0 rgba(30, 150, 15, 0.98),
    3px 3px 0 rgba(26, 132, 13, 0.95),
    4px 4px 0 rgba(22, 115, 11, 0.9),
    5px 5px 0 rgba(19, 98, 9, 0.85),
    6px 6px 0 rgba(16, 82, 8, 0.78),
    7px 7px 0 rgba(13, 66, 6, 0.7),
    8px 8px 0 rgba(10, 52, 5, 0.6),
    9px 9px 0 rgba(8, 40, 4, 0.5),
    10px 10px 0 rgba(6, 28, 3, 0.4),
    11px 11px 0 rgba(4, 18, 2, 0.3),
    12px 12px 0 rgba(2, 10, 1, 0.2),
    0 0 14px rgba(57, 255, 20, 1),
    0 0 34px rgba(57, 255, 20, 0.7);
  /* slab steps stay SOFT: each drop-shadow re-copies the whole render,
     letter trails included -- high alphas here smear the label's own
     extrusion into a streak (the PREVIEW/BUY bug). */
  filter:
    drop-shadow(3px 3px 0 rgba(24, 130, 12, 0.85))
    drop-shadow(3px 3px 0 rgba(18, 100, 8, 0.65))
    drop-shadow(3px 3px 0 rgba(12, 70, 6, 0.48))
    drop-shadow(3px 3px 0 rgba(8, 45, 4, 0.32))
    drop-shadow(0 0 20px rgba(57, 255, 20, 0.8))
    hue-rotate(var(--ui-hue, 0deg))
    hue-rotate(var(--btn-hue, 0deg));
}
.jp-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
/* Armed destructive state: red, pulsing, exempt from the hue cycle so the
   danger color always reads as danger. */
.jp-btn-danger {
  border-color: #ff5c5c;
  color: #ff8585;
  text-shadow: 0 0 8px rgba(255, 60, 60, 0.8);
  filter:
    drop-shadow(2px 2px 0 rgba(120, 20, 20, 0.7))
    drop-shadow(4px 4px 0 rgba(70, 10, 10, 0.5));
  animation: danger-pulse 0.8s ease-in-out infinite;
}
@keyframes danger-pulse {
  0%, 100% { box-shadow: 0 0 8px rgba(255, 60, 60, 0.3); }
  50% { box-shadow: 0 0 22px rgba(255, 60, 60, 0.7); }
}
/* Press: slams flat -- slab depth AND letter depth collapse together. */
.jp-btn:active:not(:disabled) {
  transform: translate(0, 0) scale(0.99);
  letter-spacing: 2px;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
  filter:
    drop-shadow(1px 1px 0 rgba(20, 110, 10, 0.8))
    drop-shadow(0 0 10px rgba(57, 255, 20, 0.6))
    hue-rotate(var(--ui-hue, 0deg))
    hue-rotate(var(--btn-hue, 0deg));
  transition-duration: 0.06s;
}
/* min-height reserves the line's space PERMANENTLY, whether or not there is
   text in it. Empty, this used to be 0px tall; the instant "Saving..."
   appeared the frame grew by that line's height, and because the whole
   screen centres the frame vertically (#screen-profile.active's own
   justify-content:center), a taller frame recentres -- which moved the
   frame's TOP edge (and everything in it, including Connected Apps) up by
   about half the height change. Reserving the space up front means the
   frame's total height never changes when the message comes and goes, so
   there is nothing for the centring to react to. */
#profile-status {
  min-height: 1.4em;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  color: #86ff66;
  letter-spacing: 1px;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#screen-profile .gang-empty { font-size: 18px; }

/* ---------- gang hub: 4-tab crew HQ ---------- */
#gang-tabs {
  display: flex;
  gap: 10px;
  margin: 18px 0 26px;
}
.gang-tab {
  padding: 10px 30px;
  background: rgba(8, 18, 8, 0.6);
  border: 1px solid rgba(134, 255, 102, 0.35);
  clip-path: polygon(10px 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 2px;
  color: #6dcf5a;
  cursor: pointer;
  filter: hue-rotate(var(--ui-hue, 0deg));
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.12s, background 0.12s;
}
.gang-tab:hover { transform: translate(-2px, -2px); color: #d8ffd0; }
.gang-tab.active {
  background: rgba(30, 70, 20, 0.85);
  border-color: #86ff66;
  color: #fff;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.35);
}
#gang-banner {
  display: block;
  width: 100%;
  height: 190px;
  object-fit: cover;
  border: 2px solid #86ff66;
  clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
  box-shadow: 0 0 16px rgba(57, 255, 20, 0.35);
  margin-bottom: 18px;
  filter: saturate(1.1);
}
#gang-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 44px;
  letter-spacing: 3px;
  color: #86ff66;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.9), 0 0 26px rgba(57, 255, 20, 0.5);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#gang-rank {
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 3px;
  color: #ffd75e;
  text-shadow: 0 0 8px rgba(255, 200, 60, 0.6);
  margin: 4px 0 14px;
}
#gang-level-row {
  display: flex;
  align-items: center;
  gap: 14px;
  max-width: 560px;
}
#gang-level {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.7);
  white-space: nowrap;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#gang-xp-bar {
  flex: 1;
  height: 12px;
  background: rgba(57, 255, 20, 0.18);
  border: 1px solid rgba(134, 255, 102, 0.4);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#gang-xp-fill {
  height: 100%;
  width: 0%;
  background: #86ff66;
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.9);
  transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
#gang-xp-pct {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  color: #6dcf5a;
  white-space: nowrap;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#gang-motto {
  font-family: 'Courier New', monospace;
  font-style: italic;
  font-size: 19px;
  color: #b8e8ac;
  margin: 16px 0 20px;
}
#gang-stats {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 14px;
  margin-top: 22px;
  padding-top: 18px;
  border-top: 1px dashed rgba(134, 255, 102, 0.3);
}
.gang-stat { text-align: center; }
.gs-value {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 28px;
  color: #86ff66;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.7);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.gs-label {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 2px;
  color: #6dcf5a;
  margin-top: 4px;
}
/* Body of the manage.cfg window -- no divider chrome, the window head
   already frames it. */
#gang-manage-box { margin-top: 4px; }
/* The danger action (DELETE/LEAVE) holds the right edge of its row,
   same as it did on the gang card. */
#gang-manage-actions { margin-top: auto; padding-top: 12px; }
/* Missions */
/* ---------- missions.dat: a task file, not a quest log ----------
   Lives only inside GANG-OS, so it follows the terminal's rules: one
   font, phosphor greens, gold reserved for the XP bounty. */
.msn { margin-bottom: 11px; font-family: 'Courier New', monospace; }
.msn-head {
  display: flex;
  align-items: baseline;
  gap: 6px;
  margin-bottom: 4px;
}
.msn-check {
  font-size: 10px;
  font-weight: bold;
  color: #7dff5f;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
}
.msn-name {
  flex: 1;
  min-width: 0;
  font-size: 10px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #c8ffbe;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.msn-reward { font-size: 9px; font-weight: bold; color: #ffd75e; }
.msn-bar {
  height: 6px;
  background: rgba(4, 10, 4, 0.8);
  border: 1px solid rgba(110, 255, 90, 0.3);
}
.msn-fill {
  height: 100%;
  width: 0;
  background: #7dff5f;
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.7);
}
.msn-meta {
  display: flex;
  justify-content: space-between;
  font-size: 8px;
  color: #4d8f44;
  margin-top: 3px;
}
/* Paid missions recede: the file keeps the record, the eye moves on. */
.msn.done .msn-check { color: #4d8f44; text-shadow: none; }
.msn.done .msn-name { color: #4d8f44; text-decoration: line-through; }
.msn.done .msn-reward { color: #4d8f44; }
.msn.done .msn-fill { background: #2f5f2a; box-shadow: none; }
/* Recruit */
#recruit-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
  margin-top: 24px;
  padding-top: 18px;
  border-top: 1px dashed rgba(134, 255, 102, 0.3);
}
.gang-empty {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  color: #5a7a54;
  margin: 6px 0 0;
}
/* Activity feed */
.activity-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 11px 2px;
  border-bottom: 1px dashed rgba(134, 255, 102, 0.2);
  font-family: 'Courier New', monospace;
  font-size: 16px;
  color: #9ec298;
}
.activity-row:last-child { border-bottom: none; }
.activity-name {
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.activity-time {
  margin-left: auto;
  font-size: 12px;
  color: #5a7a54;
}
/* Gang chat */
#gang-chat-list {
  height: 380px;
  overflow-y: auto;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  padding-right: 6px;
  margin-bottom: 14px;
}
/* Discord-style clusters: avatar rail + name/timestamp header, follow-up
   lines from the same author hang under one header. Hover tints the whole
   group like Discord's message hover -- but every color stays in the
   GANG-OS phosphor family (see CLAUDE.md: the phone owns its chrome). */
.chat-group {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 6px 8px 6px 4px;
  margin-top: 10px;
  border-radius: 4px;
}
.chat-group:first-child { margin-top: 0; }
.chat-group:hover { background: rgba(134, 255, 102, 0.06); }
.chat-avatar {
  flex-shrink: 0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  font-size: 17px;
  background: rgba(134, 255, 102, 0.12);
  border: 1px solid rgba(134, 255, 102, 0.35);
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.chat-body { flex: 1; min-width: 0; }
.chat-head {
  display: flex;
  align-items: baseline;
  gap: 9px;
}
.chat-author {
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.chat-time {
  font-size: 11px;
  color: #5a7a54;
}
.chat-line {
  color: #c9e4c2;
  line-height: 1.45;
  padding: 1px 0;
  overflow-wrap: anywhere;
}
/* Per-author username/avatar tint -- Discord's colored names, kept to
   four phosphor-green shades so nothing inside the glass breaks palette. */
.chat-tone-0 .chat-author, .chat-tone-0 .chat-avatar { color: #86ff66; }
.chat-tone-1 .chat-author, .chat-tone-1 .chat-avatar { color: #d2ffc0; }
.chat-tone-2 .chat-author, .chat-tone-2 .chat-avatar { color: #57d63f; }
.chat-tone-3 .chat-author, .chat-tone-3 .chat-avatar { color: #b8ff4d; }
/* Real profile pictures in the rail -- but pushed through a phosphor wash
   (grayscale -> green) so a full-color Discord avatar doesn't punch a hole
   in the terminal skin. The phone owns its chrome (see CLAUDE.md). */
.chat-avatar img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  display: block;
  filter: grayscale(1) sepia(1) saturate(2.4) hue-rotate(60deg) brightness(0.95);
}
.chat-clickable { cursor: pointer; }
.chat-author.chat-clickable:hover { text-decoration: underline; }
.chat-avatar.chat-clickable:hover { border-color: rgba(134, 255, 102, 0.8); }
/* Each line hosts its own hover [ REPLY ]; the quote of what it answers
   sits above the text, IRC-style, one dimmed line with a phosphor rule. */
.chat-line { position: relative; }
.chat-reply-btn {
  position: absolute;
  right: 2px;
  top: 0;
  display: none;
  background: rgba(4, 10, 4, 0.9);
  border: 1px solid rgba(134, 255, 102, 0.35);
  border-radius: 3px;
  color: #86ff66;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  padding: 1px 5px;
  cursor: pointer;
}
.chat-line:hover .chat-reply-btn { display: block; }
.chat-reply-btn:hover { border-color: rgba(134, 255, 102, 0.8); color: #d2ffc0; }
.chat-quote {
  font-size: 11px;
  color: #6f9a66;
  border-left: 2px solid rgba(134, 255, 102, 0.35);
  padding-left: 7px;
  margin: 2px 0 1px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}
.chat-quote:hover { color: #a8d69e; }
/* Landing flash when a quote jump finds its original. */
@keyframes chat-flash-kf {
  0% { background: rgba(134, 255, 102, 0.22); }
  100% { background: transparent; }
}
.chat-flash { animation: chat-flash-kf 1.2s ease-out; }
#gang-chat-replybar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 10px;
  margin-bottom: 6px;
  background: rgba(134, 255, 102, 0.07);
  border: 1px solid rgba(134, 255, 102, 0.25);
  border-radius: 6px;
  font-family: 'Courier New', monospace;
}
#gang-chat-replybar.hidden { display: none; }
#gang-chat-replyto {
  flex: 1;
  min-width: 0;
  font-size: 11px;
  color: #a8d69e;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#btn-gang-chat-cancelreply {
  background: none;
  border: none;
  color: #86ff66;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  cursor: pointer;
  padding: 0;
}
#btn-gang-chat-cancelreply:hover { color: #d2ffc0; }
#gang-chat-inputrow {
  display: flex;
  gap: 12px;
  align-items: stretch;
}
#gang-chat-input {
  flex: 1;
  padding: 12px 14px;
  background: rgba(4, 10, 4, 0.85);
  border: 1px solid rgba(134, 255, 102, 0.35);
  border-radius: 8px;
  color: #d8ffd0;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#gang-chat-input:focus {
  border-color: #86ff66;
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.35);
}
#gang-chat-inputrow .jp-btn { font-size: 18px; padding: 10px 30px; }
/* Gangless state: leaderboard rows + create form live in jp-panels. */
#clan-create-box input[type="text"],
#clan-create-box textarea { max-width: 520px; display: block; }
#clan-list .lobby-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  margin-bottom: 10px;
  background: rgba(4, 10, 4, 0.6);
  border: 1px solid rgba(134, 255, 102, 0.2);
  font-family: 'Courier New', monospace;
  font-size: 16px;
  color: #b8d8b0;
}
#clan-list .clan-tag {
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
  cursor: pointer;
  font-weight: bold;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#clan-list .lobby-row > span:nth-child(2) { flex: 1; }
#clan-list .jp-btn { font-size: 16px; padding: 8px 24px; }

/* ---------- gang phone: the entire gang page is a Nokia handset.
   The screen div sits exactly over the handset's dark display cutout
   (bounds measured from the PNG); everything inside is compact phone-scale
   overrides of the shared components.
   GANG-OS is deliberately its own chrome (like the store): a burner
   terminal you broke into -- dim phosphor green, monospace everything,
   CRT scanlines, no menu glow tokens and no app-store color. ---------- */
#gang-phone-wrap {
  position: relative;
  width: min(620px, calc(58 * var(--dvh, 1vh)));
  margin: 0 auto;
}
#gang-phone-img {
  display: block;
  width: 100%;
  pointer-events: none;
  user-select: none;
  filter: drop-shadow(0 0 26px rgba(57, 255, 20, 0.14));
}
#phone-screen {
  position: absolute;
  left: 24.5%;
  top: 15.3%;
  width: 52%;
  height: 38.2%;
  overflow: hidden;
  background: radial-gradient(ellipse at center, #061006 0%, #020502 78%);
  box-shadow: inset 0 0 26px rgba(0, 0, 0, 0.9), inset 0 0 14px rgba(57, 255, 20, 0.07);
  font-family: 'Courier New', monospace;
  color: #6fcf5c;
  text-align: left;
}
/* CRT pass drawn OVER the UI: scanlines + vignette (::after) and a slow
   refresh band crawling down the glass (::before). pointer-events off so
   they never eat a click; z-index above the views (which stack at auto/6),
   below the fullscreen power-off button (9). */
#phone-screen::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 8;
  pointer-events: none;
  background:
    repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.26) 0 1px, transparent 1px 3px),
    radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.5) 100%);
}
#phone-screen::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: 16%;
  z-index: 8;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent, rgba(134, 255, 102, 0.05), transparent);
  animation: crt-band 8s linear infinite;
}
@keyframes crt-band {
  0% { top: -18%; }
  100% { top: 110%; }
}
@keyframes term-cursor {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
#phone-screen ::-webkit-scrollbar { width: 4px; }
#phone-screen ::-webkit-scrollbar-thumb { background: rgba(134, 255, 102, 0.4); }
/* The rain lives UNDER everything: z 0 against views at auto/6, with the
   CRT pass (z 8) still crawling over it. Dimmed twice -- canvas opacity
   here, dim glyph colors in the draw -- so it reads as activity deep in
   the glass, never as content. */
#phone-matrix {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  opacity: 0.55;
}
.phone-view:not(.hidden) {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 9px 10px;
  overflow-y: auto;
}
/* Status bar reads as a shell prompt: blinking block cursor after the
   hostname, signal meter squared off in brackets. */
.phone-status {
  display: flex;
  justify-content: space-between;
  flex-shrink: 0;
  font-size: 9px;
  letter-spacing: 1px;
  color: #3f7a38;
  margin-bottom: 10px;
}
.phone-status span:first-child::after {
  content: '▊';
  margin-left: 2px;
  color: #6fcf5c;
  animation: term-cursor 1.1s steps(1) infinite;
}
/* Flex-wrap instead of grid: five programs since gang.sys moved into the
   showcase plate, and flex centers the short last row for free. */
#phone-apps {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  align-content: flex-start;
}
#phone-apps .phone-app { width: calc((100% - 16px) / 3); }
/* Terminal tiles: dim phosphor programs on dark glass, not app-store
   candy. Hover = the program lights up, nothing bounces. */
.phone-app {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  padding: 7px 2px 6px;
  background: rgba(8, 18, 8, 0.55);
  border: 1px solid rgba(110, 255, 90, 0.16);
  cursor: pointer;
  transition: border-color 0.12s, background 0.12s, transform 0.12s;
}
.phone-app:hover {
  border-color: rgba(134, 255, 102, 0.6);
  background: rgba(18, 38, 15, 0.72);
  transform: translateY(-1px);
}
.phone-app:active { transform: translateY(0) scale(0.97); }
.phone-app:hover .pa-icon {
  color: #c8ffbe;
  text-shadow: 0 0 9px rgba(57, 255, 20, 0.9);
}
.pa-icon {
  font-family: 'Courier New', monospace;
  font-size: 19px;
  line-height: 1;
  color: #7dff5f;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
  transition: color 0.12s, text-shadow 0.12s;
}
.pa-label {
  font-family: 'Courier New', monospace;
  font-size: 8px;
  letter-spacing: 0.4px;
  color: #4d8f44;
  transition: color 0.12s;
}
.phone-app:hover .pa-label { color: #a8ffa0; }
/* Tile metadata (ls -la style) is fullscreen-only: a third text line at
   handset scale would be sub-pixel mush. */
/* ---------- home showcase: your gang's ID card on the boot screen ----------
   The showcase IS gang.sys -- a wide plate above the program grid, banner
   photo (desaturated, behind a scrim) with tag/name/level/rank/motto over
   it. It rides .phone-app for the click wiring but lays out as a plate,
   not a tile. */
#ph-showcase {
  position: relative;
  display: block;
  width: 100%;
  flex-shrink: 0;
  margin-bottom: 9px;
  padding: 0;
  text-align: left;
  border-color: rgba(110, 255, 90, 0.25);
  overflow: hidden;
}
/* Same light-up outline as the program tiles. Restated here because the
   plate's own ID-weight border-color out-ranks .phone-app:hover -- only
   the tile LIFT is suppressed (a wide plate bouncing reads wrong). */
#ph-showcase:hover {
  transform: none;
  border-color: rgba(134, 255, 102, 0.6);
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.12);
}
#ph-showcase:active { transform: scale(0.99); }
#ph-sc-banner {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  filter: saturate(0.6) brightness(0.55);
  opacity: 0;
  transition: opacity 0.2s;
}
#ph-showcase.has-banner #ph-sc-banner { opacity: 1; }
/* The banner shows PROUD -- only a light even scrim. Legibility comes
   from the solid black backing strips on the title and statline instead
   (the pfp lesson: back the text, don't murder the art). */
#ph-sc-banner::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(2, 6, 2, 0.35);
}
#ph-sc-body {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  /* Bottom padding reserves the lane the absolute statline rides in. */
  padding: 16px 10px 34px;
}
/* No backing bars: the text sits straight on the banner. Legibility is a
   dark halo baked into the text-shadow stack (dark first, phosphor after)
   instead of a strip that covers the art. */
#ph-sc-title {
  display: flex;
  align-items: baseline;
  gap: 8px;
  max-width: 100%;
  font-family: 'Courier New', monospace;
  font-weight: bold;
}
#ph-sc-tag {
  font-size: 15px;
  color: #39ff14;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95), 0 0 9px rgba(57, 255, 20, 0.8);
}
#ph-sc-name {
  font-size: 15px;
  color: #eaffe6;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95), 0 0 10px rgba(57, 255, 20, 0.45);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Statline pinned to the plate's bottom edge, full width: LVL | bar | rank. */
#ph-sc-statline {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 6px 12px;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-weight: bold;
  color: #a8ffa0;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95);
}
#ph-sc-xpbar {
  flex: 1;
  height: 6px;
  background: rgba(4, 10, 4, 0.8);
  border: 1px solid rgba(110, 255, 90, 0.4);
}
#ph-sc-xpfill {
  height: 100%;
  width: 0;
  background: #7dff5f;
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.7);
  transition: width 0.3s;
}
#ph-sc-rank { color: #4d8f44; letter-spacing: 1px; }
/* The reference plate is identity + standing only -- the motto lives on
   the gang card, not the showcase. */
#ph-sc-motto { display: none; }
/* View header as a command line: "> TITLE" with a live cursor, ESC in
   brackets to bail out. */
.phone-view-head {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  font-family: 'Courier New', monospace;
  font-weight: bold;
  font-size: 11px;
  letter-spacing: 2px;
  color: #86ff66;
  padding-bottom: 6px;
  margin-bottom: 8px;
  border-bottom: 1px solid rgba(110, 255, 90, 0.2);
}
.phone-view-head > span::before { content: '> '; color: #4d8f44; }
.phone-view-head > span::after {
  content: '▊';
  margin-left: 3px;
  animation: term-cursor 1.1s steps(1) infinite;
}
.phone-back {
  background: none;
  border: none;
  color: #4d8f44;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  padding: 2px;
  cursor: pointer;
  transition: color 0.12s;
}
.phone-back::before { content: '['; }
.phone-back::after { content: ']'; }
.phone-back:hover { color: #ff8585; text-shadow: 0 0 6px rgba(255, 60, 60, 0.6); }
/* Compact overrides for the shared components living inside the screen.
   The house display font stays outside the glass: everything in here is
   monospace, like the machine only has one font installed. */
#phone-screen #gang-name,
#phone-screen .gs-value,
#phone-screen .jp-label,
#phone-screen .jp-btn {
  font-family: 'Courier New', monospace;
  font-weight: bold;
}
#phone-screen #gang-banner { width: 100%; height: 44px; object-fit: cover; display: block; border: 1px solid rgba(134, 255, 102, 0.35); box-shadow: none; clip-path: none; filter: saturate(0.5) brightness(0.85); }
#phone-screen #gang-banner.hidden { display: none; }
/* The title sits in its own little plate; with a banner above it rides
   up onto the photo's bottom edge like a nameplate. (The ~ skips the
   hidden file input between them.) */
#gang-title-plate {
  position: relative;
  z-index: 1;
  display: inline-block;
  padding: 5px 11px 6px;
  margin: 0 0 8px;
  background: rgba(3, 8, 3, 0.95);
  border: 1px solid rgba(110, 255, 90, 0.35);
  box-shadow: 0 0 12px rgba(0, 0, 0, 0.8);
}
#gang-banner-box:not(.hidden):not(.empty) ~ #gang-title-plate { margin-top: -14px; margin-left: 6px; }
/* The name is the headline of the card -- big letters, hot phosphor. */
#phone-screen #gang-name { font-size: 21px; letter-spacing: 1px; color: #c8ffbe; text-shadow: 0 0 10px rgba(57, 255, 20, 0.7), 0 0 24px rgba(57, 255, 20, 0.3); }
#phone-screen #gang-rank { font-size: 10px; letter-spacing: 2px; margin: 2px 0 0; }
/* The level reads as an achievement, not a footnote: big bold number on
   a thicker bar. */
#phone-screen #gang-level-row { gap: 8px; max-width: none; align-items: center; }
#phone-screen #gang-level { font-size: 16px; font-weight: bold; color: #a8ffa0; text-shadow: 0 0 9px rgba(57, 255, 20, 0.6); }
#phone-screen #gang-xp-bar { height: 9px; }
#phone-screen #gang-xp-pct { font-size: 9px; }
#phone-screen #btn-gang-stats { margin-top: 8px; align-self: flex-start; }
#phone-screen #gang-motto { font-size: 11px; margin: 7px 0; }
#phone-screen #gang-stats { grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 8px; padding-top: 8px; }
#phone-screen .gs-value { font-size: 12px; }
#phone-screen .gs-label { font-size: 7px; letter-spacing: 1px; margin-top: 2px; }
#phone-screen #gang-actions { gap: 6px; margin-top: 9px; }
/* Buttons are bracketed commands: [ INVITE ]. Hover inverts to a solid
   phosphor block -- the terminal "selected" look, no jump-out transform. */
#phone-screen .jp-btn {
  font-size: 10px;
  letter-spacing: 1px;
  padding: 5px 10px;
  background: transparent;
  border: 1px solid rgba(110, 255, 90, 0.35);
  clip-path: none;
  color: #7dff5f;
  text-shadow: none;
  filter: hue-rotate(var(--ui-hue, 0deg));
  transition: background 0.1s, color 0.1s, border-color 0.1s;
}
#phone-screen .jp-btn::before { content: '[ '; color: #4d8f44; }
#phone-screen .jp-btn::after { content: ' ]'; color: #4d8f44; }
#phone-screen .jp-btn:hover:not(:disabled) {
  transform: none;
  letter-spacing: 1px;
  background: #7dff5f;
  border-color: #7dff5f;
  color: #04140a;
  text-shadow: none;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#phone-screen .jp-btn:hover:not(:disabled)::before,
#phone-screen .jp-btn:hover:not(:disabled)::after { color: #04140a; }
#phone-screen .jp-btn:active:not(:disabled) {
  transform: scale(0.97);
  letter-spacing: 1px;
  text-shadow: none;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#phone-screen .jp-btn-danger { border-color: rgba(255, 92, 92, 0.5); color: #ff8585; }
#phone-screen #gang-manage-box { margin-top: 9px; padding-top: 7px; }
/* Manage box as labeled command rows: prompt-style label left, one
   control per line, destructive action alone under a divider. The native
   file picker never shows -- [ UPLOAD BANNER ] proxies it. */
.gm-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 7px;
}
.gm-label {
  flex-shrink: 0;
  width: 48px;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: #4d8f44;
}
#phone-screen input.gm-motto { flex: 1; margin: 0; }
/* Roster-size dial: a bare phosphor track with a square cursor block --
   the numeric readout beside it is the value, the slider is just the hand. */
#phone-screen input.gm-limit {
  flex: 1;
  min-width: 0;
  margin: 0;
  padding: 0;
  appearance: none;
  -webkit-appearance: none;
  height: 14px;
  background: transparent;
  border: none;
  cursor: pointer;
}
#phone-screen input.gm-limit::-webkit-slider-runnable-track {
  height: 3px;
  background: #1d3a19;
  border: 1px solid #2c5426;
}
#phone-screen input.gm-limit::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 8px;
  height: 13px;
  margin-top: -6px;
  background: #7dff5f;
  border: none;
  border-radius: 0;
  box-shadow: 0 0 6px rgba(125, 255, 95, 0.6);
}
#phone-screen input.gm-limit::-moz-range-track {
  height: 3px;
  background: #1d3a19;
  border: 1px solid #2c5426;
}
#phone-screen input.gm-limit::-moz-range-thumb {
  width: 8px;
  height: 13px;
  background: #7dff5f;
  border: none;
  border-radius: 0;
  box-shadow: 0 0 6px rgba(125, 255, 95, 0.6);
}
#phone-screen .gm-limit-val {
  flex-shrink: 0;
  min-width: 24px;
  text-align: right;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  font-weight: bold;
  color: #7dff5f;
}
/* The banner is the upload button, profile-picture style: owners hover
   to reveal CHANGE BANNER over the image, or get a dashed ADD BANNER
   strip when no image exists. Non-owners: inert image or nothing. */
#gang-banner-box {
  position: relative;
  display: block;
  width: 100%;
  padding: 0;
  margin-bottom: 6px;
  background: none;
  border: none;
  cursor: default;
}
#gang-banner-box.owner { cursor: pointer; }
#gang-banner-add { display: none; }
#gang-banner-box.owner.empty #gang-banner-add {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 44px;
  border: 1px dashed rgba(110, 255, 90, 0.35);
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 2px;
  color: #4d8f44;
  transition: color 0.12s, border-color 0.12s;
}
#gang-banner-box.owner.empty:hover #gang-banner-add {
  color: #a8ffa0;
  border-color: rgba(134, 255, 102, 0.7);
}
#gang-banner-hint {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  text-align: center;
  white-space: pre-line; /* i18n sweep writes \n, not <br> -- see .gf-title */
  background: rgba(2, 6, 2, 0.68);
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 2px;
  color: #c8ffbe;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.6);
}
#gang-banner-box.owner:hover:not(.empty) #gang-banner-hint { display: flex; }
#gm-status {
  flex: 1;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  color: #a8ffa0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Bottom action row: INVITE/MANAGE lead, the danger slot rides the right
   edge past the stretching status line. */
#gang-actions { flex-wrap: nowrap; }
#gang-actions-danger { margin-left: auto; flex-shrink: 0; }
#gang-delete-confirm { margin-top: 9px; }
#gang-delete-confirm .gm-row { margin-bottom: 0; }
#gang-delete-confirm .gm-label { color: #ff8585; }
#phone-screen input[type="text"],
#phone-screen input[type="file"],
#phone-screen textarea {
  width: 100%;
  font-size: 11px;
  font-family: 'Courier New', monospace;
  padding: 6px 8px;
  margin: 3px 0;
  display: block;
  background: rgba(4, 10, 4, 0.7);
  border: 1px solid rgba(110, 255, 90, 0.22);
  color: #a8ffa0;
  caret-color: #7dff5f;
}
#phone-screen input:focus,
#phone-screen textarea:focus {
  outline: none;
  border-color: rgba(134, 255, 102, 0.6);
}
#phone-screen ::placeholder { color: #3f7a38; opacity: 1; }
#phone-screen .jp-label { font-size: 10px; margin: 8px 0 3px; }
#phone-screen .activity-row { font-size: 10px; gap: 6px; padding: 6px 1px; }
#phone-screen .activity-time { font-size: 8px; }
#phone-screen #gang-chat-list { flex: 1; min-height: 0; height: auto; font-size: 10px; margin-bottom: 6px; }
#phone-screen .chat-group { gap: 6px; padding: 3px 4px 3px 2px; margin-top: 5px; }
#phone-screen .chat-avatar { width: 19px; height: 19px; font-size: 10px; }
#phone-screen .chat-head { gap: 5px; }
#phone-screen .chat-time { font-size: 8px; }
#phone-screen .chat-line { line-height: 1.4; }
#phone-screen .chat-quote { font-size: 8px; padding-left: 5px; }
#phone-screen .chat-reply-btn { font-size: 8px; padding: 0 3px; }
#phone-screen #gang-chat-replybar { padding: 3px 6px; margin-bottom: 4px; gap: 5px; }
#phone-screen #gang-chat-replyto { font-size: 8px; }
#phone-screen #btn-gang-chat-cancelreply { font-size: 8px; }
#phone-screen #gang-chat-inputrow { flex-shrink: 0; gap: 6px; }
#phone-screen #gang-chat-input { flex: 1; font-size: 10px; padding: 6px 8px; margin: 0; }
#phone-screen #clan-list .jp-btn { font-size: 9px; padding: 3px 8px; }
#phone-screen .gang-empty { font-size: 9px; }
/* ---------- RECRUIT.EXE ---------- */
.recruit-readout {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 0.5px;
  color: #c8ffbe;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.5);
  padding: 4px 4px 4px 8px;
  border: 1px solid rgba(134, 255, 102, 0.3);
  background: rgba(57, 255, 20, 0.04);
}
#recruit-code-line {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
  user-select: all; /* one click grabs the whole link */
}
/* The dice: reroll the code (and revoke the old one). */
#btn-reroll-code {
  flex-shrink: 0;
  font-size: 15px;
  line-height: 1;
  padding: 3px 7px;
  background: none;
  border: 1px solid rgba(134, 255, 102, 0.4);
  color: #86ff66;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s, text-shadow 0.12s;
}
#btn-reroll-code:hover:not(:disabled) {
  color: #d6ffcf;
  border-color: rgba(134, 255, 102, 0.8);
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
#btn-reroll-code:disabled { opacity: 0.35; cursor: default; }
/* Picker tabs: FRIENDS | FIND, bracketed like every other command. */
#recruit-tabs { display: flex; gap: 6px; margin: 0 0 5px; }
.rtab {
  font-family: 'Courier New', monospace;
  font-weight: bold;
  font-size: 10px;
  letter-spacing: 1px;
  padding: 3px 8px;
  background: none;
  border: 1px solid rgba(134, 255, 102, 0.25);
  color: #4d8f44;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.rtab::before { content: '[ '; }
.rtab::after { content: ' ]'; }
.rtab.active {
  color: #04140a;
  background: #7dff5f;
  border-color: #7dff5f;
  text-shadow: none;
}
.rtab:hover:not(.active) { color: #a8ffa0; border-color: rgba(134, 255, 102, 0.5); }
#phone-screen #recruit-search { margin: 0 0 4px; font-size: 10px; padding: 5px 8px; width: 100%; }
/* Condensed: the picker is a fixed-height scroll box, not a page-long list. */
#recruit-picker { max-height: 150px; overflow-y: auto; }
.recruit-btn-row { display: flex; gap: 6px; margin: 7px 0 0; }
#phone-screen .recruit-btn-row .jp-btn { font-size: 9px; padding: 4px 8px; }
/* Scanner polarity is non-negotiable: dark modules on a bright field.
   The tile is the one deliberately BRIGHT surface inside the glass. */
#recruit-qr-box { display: flex; justify-content: center; margin-top: 8px; }
#recruit-qr-box.hidden { display: none; }
#recruit-qr {
  image-rendering: pixelated;
  width: min(180px, 70%);
  border: 1px solid rgba(134, 255, 102, 0.5);
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.25);
}
.recruit-row {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 3px 1px;
  border-bottom: 1px dashed rgba(134, 255, 102, 0.15);
  font-size: 10px;
}
.recruit-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: #a8ffa0;
}
.recruit-state {
  font-size: 8px;
  letter-spacing: 1px;
  color: #3f7a38;
}
.recruit-dim .recruit-name { color: #4d8f44; }
#phone-screen .recruit-row .jp-btn { font-size: 8px; padding: 3px 7px; }
/* Provenance stamp on a queue row: this knock came through somebody's
   invite link. Dim -- it qualifies the name, it doesn't compete with it. */
.recruit-via {
  margin-left: 7px;
  font-size: 8px;
  letter-spacing: 1px;
  color: #3f7a38;
}
/* LINK TYPE ([ 1x ] | [ inf ]) rides the INVITE LINK label row itself --
   the toggle costs zero vertical space. Same .rtab chrome as FRIENDS/FIND
   so "selected mode" reads the same everywhere in the window. */
.recruit-link-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
#recruit-type-tabs { display: flex; gap: 4px; }
#phone-screen .ltab { font-size: 10px; padding: 1px 4px; line-height: 1.3; }
/* Picker tabs and the search field share one row (they used to stack). */
#recruit-pick-row { display: flex; align-items: stretch; gap: 6px; margin: 0 0 4px; }
#recruit-pick-row #recruit-tabs { margin: 0; flex-shrink: 0; }
#phone-screen #recruit-pick-row #recruit-search { margin: 0; flex: 1; width: auto; min-width: 0; }
#recruit-footer { margin-top: 10px; opacity: 0.65; }
/* ---------- create view: filling in a record, not a web form ----------
   Prompt labels with counters riding the right edge, and a live stdout
   preview under the fields -- the tag/name pair rendered the way
   ranks.db and rosters will actually print it. The preview starts
   .empty (dim, no glow) and lights up phosphor once a tag exists. */
.gc-req { color: #ff8585; }
.gc-field { flex-shrink: 0; margin-bottom: 8px; }
.gc-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
}
.gc-label {
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-weight: bold;
  letter-spacing: 2px;
  text-transform: uppercase;
  color: #4d8f44;
}
.gc-count {
  font-family: 'Courier New', monospace;
  font-size: 8px;
  color: #3f7a38;
}
.gc-count.maxed { color: #ff8585; }
#phone-view-create input,
#phone-view-create textarea { margin: 3px 0 0; }
#input-clan-tag { text-transform: uppercase; letter-spacing: 3px; }
#gc-preview {
  flex-shrink: 0;
  margin: 2px 0 10px;
  padding: 7px 9px;
  border: 1px dashed rgba(110, 255, 90, 0.28);
  background: rgba(4, 10, 4, 0.5);
  font-family: 'Courier New', monospace;
}
#gc-preview-cap {
  display: block;
  font-size: 8px;
  letter-spacing: 1px;
  color: #3f7a38;
  margin-bottom: 5px;
}
#gc-preview-row {
  display: flex;
  align-items: baseline;
  gap: 7px;
  font-size: 12px;
  font-weight: bold;
}
#gc-prev-tag {
  color: #7dff5f;
  text-shadow: 0 0 7px rgba(57, 255, 20, 0.55);
  transition: color 0.12s, text-shadow 0.12s;
}
#gc-prev-tag::before { content: '['; color: #4d8f44; }
#gc-prev-tag::after { content: ']'; color: #4d8f44; }
#gc-prev-name {
  color: #a8ffa0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.12s;
}
#gc-preview.empty #gc-prev-tag { color: #3f7a38; text-shadow: none; }
#gc-preview.empty #gc-prev-name { color: #3f7a38; }
#gc-prev-motto {
  font-size: 9px;
  font-style: italic;
  color: #4d8f44;
  margin-top: 4px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#btn-create-clan { align-self: flex-start; }
#phone-screen #gang-create-status { color: #ff8585; margin: 7px 0 0; }
/* ---------- ranks.db: podium on top, table dump under it ----------
   The quick-play leaderboard's construction (see #rk-podium) restated in
   GANG-OS chrome: the top three gangs take gold/silver/bronze pedestals
   (champion centre and tallest), the table below starts at 04, your own
   gang prints YOURS where REQUEST would be. Same laws as the big board:
   metals are static gradient stacks, the ONE animated layer is the
   champion's glint (transform, parked off-edge -- reduced-motion just
   stops it invisible), and the halo is drop-shadow on the WRAPPER while
   the clip lives on the plate child. Courier New and phosphor greens
   only -- the podium metals are the podium's, not menu tokens. */
#clan-list { display: block; margin: 0; }
#gg-podium {
  display: flex;
  align-items: stretch;
  gap: 7px;
  height: 158px;
  flex: none;
  padding: 6px 2px 10px;
}
.gg-ped { flex: 1; display: flex; align-items: flex-end; min-width: 0; }
.gg-ped-2 { order: 1; --ped-c: #dfe9f2; --ped-soft: rgba(223, 233, 242, 0.4); --ped-dim: rgba(223, 233, 242, 0.1); }
.gg-ped-1 { order: 2; --ped-c: #ffd23e; --ped-soft: rgba(255, 210, 62, 0.5); --ped-dim: rgba(255, 210, 62, 0.12); }
.gg-ped-3 { order: 3; --ped-c: #d08a4e; --ped-soft: rgba(208, 138, 78, 0.4); --ped-dim: rgba(208, 138, 78, 0.1); }
/* height IS the placement: the skyline reads before any number */
.gg-ped-1 .gg-ped-plate { height: 100%; }
.gg-ped-2 .gg-ped-plate { height: 88%; }
.gg-ped-3 .gg-ped-plate { height: 81%; }
.gg-ped-1 { filter: drop-shadow(0 0 4px rgba(255, 210, 62, 0.4)) drop-shadow(0 0 12px rgba(255, 210, 62, 0.16)); }
.gg-ped-2, .gg-ped-3 { filter: drop-shadow(0 0 6px var(--ped-dim)); }
.gg-ped-plate {
  position: relative;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 3px;
  padding: 6px 5px 7px;
  overflow: hidden;
  font-family: 'Courier New', monospace;
  background: linear-gradient(170deg, var(--ped-dim) 0%, rgba(4, 10, 4, 0.92) 46%, rgba(2, 6, 2, 0.96) 100%);
  border: 1px solid var(--ped-soft);
  clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
}
.gg-ped-1 .gg-ped-plate::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 42%, rgba(255, 240, 190, 0.16) 50%, transparent 58%);
  transform: translateX(-160%);
  animation: rk-glint 5.5s ease-in-out infinite;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .gg-ped-1 .gg-ped-plate::after { animation: none; }
}
.gg-ped-rank { font-size: 12px; font-weight: bold; line-height: 1; letter-spacing: 1px; color: var(--ped-c); text-shadow: 0 0 6px var(--ped-soft); }
.gg-ped-1 .gg-ped-rank { font-size: 15px; }
.gg-ped-tag {
  font-size: 11px;
  font-weight: bold;
  color: #7dff5f;
  cursor: pointer;
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
}
.gg-ped-1 .gg-ped-tag { font-size: 13px; }
.gg-ped-tag:hover { text-shadow: 0 0 7px rgba(57, 255, 20, 0.8); }
.gg-ped-name {
  max-width: 100%;
  font-size: 9px;
  color: #b8d8b0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gg-ped-stat { display: flex; align-items: baseline; gap: 3px; }
.gg-ped-stat b { font-size: 14px; line-height: 1; color: var(--ped-c); text-shadow: 0 0 6px var(--ped-soft); }
.gg-ped-1 .gg-ped-stat b { font-size: 18px; }
.gg-ped-stat span { font-size: 7px; letter-spacing: 1px; color: #4d8f44; }
.gg-ped-crew { font-size: 7px; letter-spacing: 1px; color: #4d8f44; }
.gg-ped-plate .rr-act { margin-top: 2px; }
.gg-ped-mine .gg-ped-plate { background: linear-gradient(170deg, var(--ped-dim) 0%, rgba(18, 38, 15, 0.85) 100%); }
/* an unclaimed throne: dimmer, dashed, waiting -- never collapsed */
.gg-ped-empty .gg-ped-plate {
  justify-content: center;
  border-style: dashed;
  border-color: rgba(110, 255, 90, 0.22);
  background: linear-gradient(170deg, rgba(57, 255, 20, 0.04) 0%, rgba(2, 6, 2, 0.9) 100%);
}
.gg-ped-empty { filter: none; }
.gg-ped-vacant { font-size: 8px; letter-spacing: 2px; color: rgba(110, 255, 90, 0.35); }
.rank-row {
  display: grid;
  grid-template-columns: 2.2em 5.5em minmax(0, 1fr) 4.8em 2.8em auto;
  gap: 7px;
  align-items: center;
  padding: 5px 4px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  border-bottom: 1px solid rgba(110, 255, 90, 0.1);
}
.rank-head {
  font-size: 8px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #3f7a38;
  border-bottom: 1px solid rgba(110, 255, 90, 0.28);
  padding-bottom: 4px;
}
.rr-pos { color: #4d8f44; }
.rr-tag {
  font-weight: bold;
  color: #7dff5f;
  cursor: pointer;
  overflow: hidden;
  white-space: nowrap;
}
.rank-head .rr-tag { color: #3f7a38; cursor: default; font-weight: bold; }
.rr-tag:hover { text-shadow: 0 0 7px rgba(57, 255, 20, 0.8); }
.rank-head .rr-tag:hover { text-shadow: none; }
.rr-name {
  color: #b8d8b0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rr-pts { text-align: right; color: #a8ffa0; }
.rr-crew { text-align: right; color: #4d8f44; }
.rr-act { text-align: right; }
.rank-head .rr-pts, .rank-head .rr-crew { color: #3f7a38; }
.rank-mine { background: rgba(18, 38, 15, 0.55); }
.rr-yours {
  font-size: 8px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #a8ffa0;
}
.rank-eof {
  font-family: 'Courier New', monospace;
  font-size: 8px;
  letter-spacing: 1px;
  color: #3f7a38;
  padding: 6px 4px 0;
}
/* Real empty state: the command ran clean, offer the other road. */
#rank-none {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 5px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  color: #4d8f44;
  padding: 4px 2px;
}
#rank-none .jp-btn { margin-top: 6px; }
/* ---------- the fork: no gang yet -> CREATE / JOIN ----------
   One dark panel, two roads, divided by a massive slash. The halves are
   complementary diagonal clip-paths with a hair of seam between them;
   at rest the panel reads as one piece of glass with the glyph across
   it, and only hover reveals the split (the lit half stops dead at the
   diagonal). The slash is the divider -- no drawn seam line. */
#gang-fork {
  position: relative;
  flex: 1;
  min-height: 150px;
  border: 1px solid rgba(110, 255, 90, 0.16);
  background: rgba(8, 18, 8, 0.35);
  overflow: hidden;
}
.gf-half {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  background: transparent;
  border: none;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  transition: background 0.12s;
}
#gf-create {
  clip-path: polygon(0 0, 64% 0, 38% 100%, 0 100%);
  align-items: flex-start;
  padding: 0 0 0 9%;
  text-align: left;
}
#gf-join {
  clip-path: polygon(66% 0, 100% 0, 100% 100%, 40% 100%);
  align-items: flex-end;
  padding: 0 9% 0 0;
  text-align: right;
}
.gf-half:hover { background: rgba(18, 38, 15, 0.72); }
.gf-cmd {
  font-size: 9px;
  letter-spacing: 1px;
  color: #3f7a38;
  transition: color 0.12s;
}
.gf-title {
  font-size: 15px;
  font-weight: bold;
  letter-spacing: 2px;
  line-height: 1.3;
  /* The i18n sweep writes these as textContent with \n line breaks (no
     <br> survives it) -- pre-line keeps the two-line stack in every
     language. Same deal on #gang-banner-hint. */
  white-space: pre-line;
  color: #86ff66;
  transition: color 0.12s, text-shadow 0.12s;
}
.gf-half:hover .gf-cmd { color: #a8ffa0; }
.gf-half:hover .gf-title { color: #c8ffbe; text-shadow: 0 0 9px rgba(57, 255, 20, 0.9); }
/* THE slash: full-height stroke riding the seam, the one loud thing on
   the panel. non-scaling-stroke keeps its weight constant while the
   preserveAspectRatio="none" viewBox stretches the diagonal to match
   the halves' clip-paths at any panel size. */
#gf-slash {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  filter:
    drop-shadow(0 0 6px rgba(57, 255, 20, 0.8))
    drop-shadow(0 0 18px rgba(57, 255, 20, 0.3));
}
#gf-slash line {
  stroke: #7dff5f;
  stroke-width: 3.5px;
  stroke-linecap: round;
}

/* ---------- store: holo-foil shelves ----------
   Deliberately different chrome from the rest of the app: no dashes, no
   corner ticks -- clean thin frames, and the personality comes from the
   holographic foil shimmering behind each unknown item. Price bar sits
   under every item like a shelf tag. */
/* STORE as a faulty neon sign: rock steady most of the time, then the
   tube sputters -- two irregular flicker clusters per 7s loop, never
   fully dying (real neon keeps a faint ember). */
#store-neon {
  display: inline-block;
  animation: neon-flicker 7s linear infinite;
}
@keyframes neon-flicker {
  0%, 40.8%, 44%, 70.8%, 73%, 100% { opacity: 1; }
  41.2% { opacity: 0.3; }
  41.6% { opacity: 1; }
  42.3% { opacity: 0.5; }
  42.7% { opacity: 0.95; }
  43.1% { opacity: 0.35; }
  43.5% { opacity: 1; }
  71.1% { opacity: 0.4; }
  71.4% { opacity: 1; }
  72.1% { opacity: 0.6; }
  72.5% { opacity: 1; }
}

.store-shelf-label {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  letter-spacing: 4px;
  color: #86ff66;
  text-shadow:
    0 0 10px rgba(57, 255, 20, 0.9),
    0 0 26px rgba(57, 255, 20, 0.5);
  margin: 12px 0 6px 2px;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
.store-shelf {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 18px 24px;
  padding: 16px 24px;
  border: 1px solid rgba(134, 255, 102, 0.5);
  background-color: rgba(4, 9, 4, 0.45);
  /* The dim circuit etch lives on the .circuit-canvas child (procedural,
     regenerated per click). A canvas bitmap scales on the GPU during the
     catalog dolly; an SVG background here would re-rasterize per frame. */
  box-shadow:
    0 0 20px rgba(57, 255, 20, 0.14),
    inset 0 0 26px rgba(57, 255, 20, 0.05);
  /* The whole shelf drifts with the app's slow hue clock -- borders,
     prices, foils and the circuit etch recolor together. Driven as a
     compositor ANIMATION (not the --ui-hue var): var writes re-raster
     this huge subtree on every change, an animated filter doesn't. JS
     phase-syncs it to the var clock via --ui-phase/--ui-drift-dur. */
  animation: ui-hue-drift var(--ui-drift-dur, 96s) linear infinite var(--ui-phase, 0s);
}
@keyframes ui-hue-drift {
  from { filter: hue-rotate(0deg); }
  to { filter: hue-rotate(360deg); }
}
/* Live build surface: the board draws itself on this canvas at full
   frame rate, then hands off to the static etch and goes dormant. Sits
   above the (empty-during-build) etch, below the cards. */
.circuit-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
/* The VEINS (cursor spotlight) and the click shockwave both moved onto
   the .circuit-canvas renderer -- the old ::before/::after SVG layers
   re-masked and re-filtered the full shelf on every mouse move. */
.store-card {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  padding: 0;
  background: #060b06;
  border: 2px solid rgba(134, 255, 102, 0.7);
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.3);
  cursor: pointer;
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    border-color 0.15s,
    filter 0.15s,
    opacity 0.45s ease;
}
/* Card heights DERIVE from the viewport: featured + two catalog rows
   consume exactly the height left after the chrome (~408px), split at
   the original 330:170 proportions -- the shelves always fill the
   screen, and can never overflow it. Floors keep small windows sane. */
.store-shelf-featured .store-card { height: clamp(330px, calc((calc(100 * var(--dvh, 1vh)) - 408px) * 0.492), 520px); }
/* Featured cards are abstract angular plates (Apex-style): sheared
   top-right, notched right edge, slotted bottom, chamfered corners. The
   outline is drawn by four hard drop-shadows so it follows the clipped
   silhouette exactly (a normal border can't trace a clip-path). */
.store-shelf-featured .store-card {
  border: none;
  clip-path: polygon(
    0% 2.5%, 5% 0%, 72% 0%, 100% 8%,
    100% 55%, 96.5% 58%, 96.5% 68%, 100% 71%,
    100% 96%, 95% 100%, 60% 100%, 56% 96.5%,
    44% 96.5%, 40% 100%, 0% 100%
  );
  /* matte at rest: a faint rim keeps the plate legible, but the GLOW
     only ignites on hover (same power-on language as the catalog). */
  filter:
    drop-shadow(2px 0 0 rgba(134, 255, 102, 0.28))
    drop-shadow(-2px 0 0 rgba(134, 255, 102, 0.28))
    drop-shadow(0 2px 0 rgba(134, 255, 102, 0.28))
    drop-shadow(0 -2px 0 rgba(134, 255, 102, 0.28));
}
/* Chip detail top-left: a real microchip -- body, inner die, and legs
   with terminal pads radiating on all four sides. */
.store-shelf-featured .store-card::after,
#flip-card.from-featured #flip-front .face-inner::after {
  content: '';
  position: absolute;
  left: 11px;
  top: 12px;
  width: 28px;
  height: 28px;
  z-index: 2;
  opacity: 0.85;
  background-repeat: no-repeat;
  background-size: contain;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg stroke='rgb(134,255,102)' stroke-width='3' fill='none' stroke-linecap='square'%3E%3Crect x='36' y='36' width='28' height='28' rx='5' stroke-width='3.5'/%3E%3Crect x='43.5' y='43.5' width='13' height='13' rx='2.5' fill='rgb(134,255,102)' stroke='none'/%3E%3Cpath d='M41 36V26L34 19'/%3E%3Ccircle cx='32' cy='17' r='3.4'/%3E%3Cpath d='M46.5 36V16'/%3E%3Ccircle cx='46.5' cy='12.4' r='3.4'/%3E%3Cpath d='M53.5 36V22'/%3E%3Ccircle cx='53.5' cy='18.4' r='3.4'/%3E%3Cpath d='M59 36V26l7-7'/%3E%3Ccircle cx='68' cy='17' r='3.4'/%3E%3Cpath d='M41 64v10l-7 7'/%3E%3Ccircle cx='32' cy='83' r='3.4'/%3E%3Cpath d='M46.5 64v20'/%3E%3Ccircle cx='46.5' cy='87.6' r='3.4'/%3E%3Cpath d='M53.5 64v14'/%3E%3Ccircle cx='53.5' cy='81.6' r='3.4'/%3E%3Cpath d='M59 64v10l7 7'/%3E%3Ccircle cx='68' cy='83' r='3.4'/%3E%3Cpath d='M36 41H26l-7-7'/%3E%3Ccircle cx='17' cy='32' r='3.4'/%3E%3Cpath d='M36 46.5H16'/%3E%3Ccircle cx='12.4' cy='46.5' r='3.4'/%3E%3Cpath d='M36 53.5H22'/%3E%3Ccircle cx='18.4' cy='53.5' r='3.4'/%3E%3Cpath d='M36 59H26l-7 7'/%3E%3Ccircle cx='17' cy='68' r='3.4'/%3E%3Cpath d='M64 41h10l7-7'/%3E%3Ccircle cx='83' cy='32' r='3.4'/%3E%3Cpath d='M64 46.5h20'/%3E%3Ccircle cx='87.6' cy='46.5' r='3.4'/%3E%3Cpath d='M64 53.5h14'/%3E%3Ccircle cx='81.6' cy='53.5' r='3.4'/%3E%3Cpath d='M64 59h10l7 7'/%3E%3Ccircle cx='83' cy='68' r='3.4'/%3E%3C/g%3E%3C/svg%3E");
  filter: drop-shadow(0 0 4px rgba(57, 255, 20, 0.55));
}
/* Glowing accent bar riding the notched right edge. */
.store-shelf-featured .store-card::before,
#flip-card.from-featured #flip-front .face-inner::before {
  content: '';
  position: absolute;
  right: 7px;
  top: 14%;
  width: 5px;
  height: 32%;
  z-index: 2;
  background: rgba(134, 255, 102, 0.8);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.8);
}
/* Hover must re-state the silhouette outline alongside the extrusion,
   since filter lists replace wholesale. */
.store-shelf-featured .store-card:hover {
  filter:
    drop-shadow(2px 0 0 rgba(134, 255, 102, 0.9))
    drop-shadow(-2px 0 0 rgba(134, 255, 102, 0.9))
    drop-shadow(0 2px 0 rgba(134, 255, 102, 0.9))
    drop-shadow(0 -2px 0 rgba(134, 255, 102, 0.9))
    drop-shadow(3px 3px 0 rgba(24, 130, 12, 0.9))
    drop-shadow(4px 4px 0 rgba(12, 70, 6, 0.7))
    drop-shadow(0 0 24px rgba(57, 255, 20, 0.7));
}
.store-shelf-catalog .store-card { height: clamp(170px, calc((calc(100 * var(--dvh, 1vh)) - 408px) * 0.254), 270px); }
/* Catalog plates (per sketch): a THICK 3D SLAB. Front face = chamfered
   top-left + V-notch top edge; the visible side (the hatched face in the
   sketch) is built from compounding hard drop-shadow steps, so the
   block's depth follows every contour -- chamfer, notch, all of it --
   exactly like a physical extrusion. */
.store-shelf-catalog .store-card {
  border: none;
  background: none;
  box-shadow: none;
  /* PERSPECTIVE 3D: the slab is genuinely turned in space -- right edge
     toward the viewer, so the extruded side reads on the right like the
     sketch. The drop-shadow depth lives in the card's local space, so it
     foreshortens WITH the rotation like a real block's side. */
  transform: perspective(620px) rotateY(-9deg);
  /* At REST: no glow at all -- just the dark extruded side and the cast
     shadow, a matte component on the board. The top-right neon (rim +
     bloom) ignites only on hover, which makes the highlight feel like
     powering the component on. */
  filter:
    drop-shadow(2px 2px 0 rgba(30, 150, 15, 0.95))
    drop-shadow(2px 2px 0 rgba(24, 124, 12, 0.85))
    drop-shadow(2px 2px 0 rgba(18, 96, 9, 0.75))
    drop-shadow(2px 2px 0 rgba(14, 72, 7, 0.6))
    drop-shadow(2px 2px 0 rgba(10, 48, 5, 0.45))
    drop-shadow(2px 2px 0 rgba(6, 28, 3, 0.3))
    drop-shadow(9px 14px 10px rgba(0, 0, 0, 0.55));
}
/* Hover rides the SAME 3D space: the slab flies toward the camera
   (translateZ), which is true perspective growth -- not a flat scale.
   Active nudges back down; the boarded plate flattens square-on so the
   dive hologram isn't skewed. */
.store-shelf-catalog .store-card:hover:not(.card-boarding) {
  transform: perspective(620px) rotateY(-9deg) translate3d(-6px, -6px, 30px);
}
.store-shelf-catalog .store-card:active:not(.card-boarding) {
  transform: perspective(620px) rotateY(-9deg) translate3d(-1px, -1px, 5px);
}
.store-shelf-catalog .store-card.card-boarding {
  transform: none;
}
/* Neon accent line riding under the top edge, left of the notch -- the
   catalog's answer to the featured plates' edge bar (same always-lit
   accent language). */
.store-shelf-catalog .store-card::before {
  content: '';
  position: absolute;
  left: 7%;
  top: 9px;
  width: 30%;
  height: 5px;
  z-index: 2;
  background: rgba(134, 255, 102, 0.8);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.8);
}
/* Catalog price tags idle DARK -- the darker look is the resting state;
   the neon (and then the white-hot digit pop) only ignites on hover,
   same power-on rule as everything else in the store. */
.store-shelf-catalog .store-card-price {
  /* Raised from 0.4: resting prices were faint enough to miss entirely
     ("at first you can't really see the price") -- still clearly dimmer
     than the hover power-on, just legible before you touch anything. */
  color: rgba(134, 255, 102, 0.62);
  text-shadow: none;
  border-top-color: rgba(134, 255, 102, 0.22);
  transition: color 0.15s, border-top-color 0.15s;
}
.store-shelf-catalog .store-card:hover .store-card-price {
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.85), 0 0 20px rgba(57, 255, 20, 0.4);
}
.store-shelf-catalog .store-card-item {
  background: #060b06;
  clip-path: polygon(
    0 20px, 22px 0,
    42% 0, 47% 27px, 56% 27px, 60% 0,
    100% 0, 100% 100%, 0 100%
  );
}
/* Hover: the slab lifts and its side deepens -- restating the rim
   because filter lists replace wholesale. */
.store-shelf-catalog .store-card:hover {
  filter:
    drop-shadow(1.5px 0 0 rgba(134, 255, 102, 1))
    drop-shadow(0 -1.5px 0 rgba(134, 255, 102, 1))
    drop-shadow(2.5px 2.5px 0 rgba(30, 150, 15, 0.95))
    drop-shadow(2.5px 2.5px 0 rgba(24, 124, 12, 0.88))
    drop-shadow(2.5px 2.5px 0 rgba(18, 96, 9, 0.8))
    drop-shadow(2.5px 2.5px 0 rgba(14, 72, 7, 0.68))
    drop-shadow(2.5px 2.5px 0 rgba(10, 48, 5, 0.55))
    drop-shadow(2.5px 2.5px 0 rgba(6, 28, 3, 0.4))
    drop-shadow(8px -8px 16px rgba(57, 255, 20, 0.65))
    /* lifted higher off the board: the cast shadow spreads and softens */
    drop-shadow(16px 24px 18px rgba(0, 0, 0, 0.45));
}
.store-card-item {
  position: relative;
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
/* The holographic foil: pastel spectrum drifting slowly, offset per card
   so no two shimmer alike. Perf: the layer is oversized (320%) and PANNED
   with a transform -- pixel-identical to animating background-position,
   but composited on the GPU instead of repainting every card every
   frame. */
.store-card-item::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 320%;
  height: 320%;
  background: linear-gradient(
    125deg,
    rgba(255, 60, 200, 0.5) 0%,
    rgba(60, 240, 255, 0.45) 24%,
    rgba(150, 80, 255, 0.5) 48%,
    rgba(60, 255, 160, 0.42) 72%,
    rgba(255, 220, 80, 0.42) 100%
  );
  animation: holo-drift 9s ease-in-out infinite alternate;
}
/* A moving light sheen across the foil, like tilting a trading card.
   Same oversized-layer transform-pan trick as the foil. */
.store-card-item::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 300%;
  height: 300%;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.16) 46%,
    rgba(255, 255, 255, 0.34) 50%,
    rgba(255, 255, 255, 0.16) 54%,
    transparent 70%
  );
  animation: holo-sheen 7s ease-in-out infinite alternate;
}
/* Translation endpoints are the exact transform equivalents of the old
   background-position sweeps ((container - layer) offsets in layer %). */
@keyframes holo-drift {
  0% { transform: translate(0%, 0%); }
  100% { transform: translate(-68.75%, -68.75%); }
}
@keyframes holo-sheen {
  0% { transform: translate(-80%, 0%); }
  100% { transform: translate(13.333%, -66.667%); }
}
/* Every card gets its own slice of the spectrum (via a var so the hover
   flare below can compose with it instead of replacing it). */
.store-card-item::before {
  filter: hue-rotate(var(--holo-hue, 0deg));
}
.store-shelf .store-card:nth-child(1) { --holo-hue: 0deg; }
.store-shelf .store-card:nth-child(2) { --holo-hue: 45deg; }
.store-shelf .store-card:nth-child(3) { --holo-hue: 90deg; }
.store-shelf .store-card:nth-child(4) { --holo-hue: 135deg; }
.store-shelf .store-card:nth-child(5) { --holo-hue: 180deg; }
.store-shelf .store-card:nth-child(6) { --holo-hue: 225deg; }
.store-shelf .store-card:nth-child(7) { --holo-hue: 270deg; }
.store-shelf .store-card:nth-child(8) { --holo-hue: 315deg; }
.store-shelf .store-card:nth-child(1) .store-card-item::before { animation-delay: -1s; }
.store-shelf .store-card:nth-child(2) .store-card-item::before { animation-delay: -3s; }
.store-shelf .store-card:nth-child(3) .store-card-item::before { animation-delay: -5s; }
.store-shelf .store-card:nth-child(4) .store-card-item::before { animation-delay: -7s; }
.store-shelf .store-card:nth-child(5) .store-card-item::before { animation-delay: -2s; }
.store-shelf .store-card:nth-child(6) .store-card-item::before { animation-delay: -4s; }
.store-shelf .store-card:nth-child(7) .store-card-item::before { animation-delay: -6s; }
.store-shelf .store-card:nth-child(8) .store-card-item::before { animation-delay: -8s; }
.store-q {
  position: relative;
  z-index: 1;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 68px;
  color: #fff;
  text-shadow:
    0 0 10px rgba(255, 255, 255, 0.9),
    0 0 26px rgba(255, 255, 255, 0.55),
    0 0 48px rgba(134, 255, 102, 0.6);
}
.store-shelf-catalog .store-q { font-size: 40px; }
.store-card-price {
  border-top: 1px solid rgba(134, 255, 102, 0.55);
  padding: 10px 12px;
  background: rgba(6, 14, 6, 0.75);
  font-family: 'Courier New', monospace;
  font-size: 21px;
  font-weight: bold;
  letter-spacing: 2px;
  text-align: center;
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.85), 0 0 20px rgba(57, 255, 20, 0.4);
}
/* Featured plates: the clip-path's bottom-center notch rises 3.5% into
   the card (up to ~18px at full card height) exactly where the centered
   price sits -- extra bottom padding lifts the digits clear of the cut
   so the price is never clipped. */
.store-shelf-featured .store-card-price {
  padding-bottom: 28px;
}
/* Signature press-me hover: whole card lifts with hard depth steps and the
   foil flares. */
.store-card:hover {
  transform: translate(-7px, -7px) scale(1.04);
  border-color: #c8ffb8;
  filter:
    drop-shadow(3px 3px 0 rgba(24, 130, 12, 0.9))
    drop-shadow(3px 3px 0 rgba(16, 90, 8, 0.8))
    drop-shadow(3px 3px 0 rgba(10, 55, 5, 0.65))
    drop-shadow(0 0 26px rgba(57, 255, 20, 0.7));
}
.store-card:hover .store-card-item::before {
  filter: hue-rotate(var(--holo-hue, 0deg)) saturate(1.8) brightness(1.35);
}
.store-card:active { transform: translate(-1px, -1px); transition-duration: 0.06s; }
/* Hovering a card: the price SPRINGS OUT in 3D first (overshoot pop into
   the lifted pose, wedge blooming behind it), then each character
   breathes IN AND OUT (a slow scale pulse) sweeping left to right -- no
   vertical bounce, just depth. */
.price-ch {
  display: inline-block;
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    text-shadow 0.15s,
    color 0.15s;
}
.store-card:hover .price-ch {
  color: #fff;
  /* the springy transition covers the first 0.4s; the wave's first
     keyframe equals this pose, so the handoff is seamless */
  transform: translate(-2px, -2px) scale(1.18);
  animation: price-wave 2.4s ease-in-out infinite;
  animation-delay: calc(0.4s + var(--i, 0) * 0.22s);
  /* Dense 8-step wedge -- the number reads as a solid extruded block. */
  text-shadow:
    1px 1px 0 rgba(30, 150, 15, 1),
    2px 2px 0 rgba(26, 132, 13, 0.95),
    3px 3px 0 rgba(22, 112, 11, 0.88),
    4px 4px 0 rgba(18, 92, 9, 0.8),
    5px 5px 0 rgba(14, 72, 7, 0.7),
    6px 6px 0 rgba(10, 52, 5, 0.58),
    7px 7px 0 rgba(7, 36, 3, 0.45),
    8px 8px 0 rgba(4, 22, 2, 0.32),
    0 0 12px rgba(57, 255, 20, 1),
    0 0 30px rgba(57, 255, 20, 0.7);
}
/* Same 3D logic as the BUY letters -- NO size pulsing: each digit
   PRESSES INTO ITS OWN WEDGE and rises back out. The face travels
   down-right along the extrusion axis while the shadow steps collapse
   beneath it (face +5px, wedge 8 -> 3 steps, only the deepest dark
   layers left), so the block's base never moves. */
@keyframes price-wave {
  0%, 100% {
    transform: translate(-2px, -2px) scale(1.18);
    text-shadow:
      1px 1px 0 rgba(30, 150, 15, 1),
      2px 2px 0 rgba(26, 132, 13, 0.95),
      3px 3px 0 rgba(22, 112, 11, 0.88),
      4px 4px 0 rgba(18, 92, 9, 0.8),
      5px 5px 0 rgba(14, 72, 7, 0.7),
      6px 6px 0 rgba(10, 52, 5, 0.58),
      7px 7px 0 rgba(7, 36, 3, 0.45),
      8px 8px 0 rgba(4, 22, 2, 0.32),
      0 0 12px rgba(57, 255, 20, 1),
      0 0 30px rgba(57, 255, 20, 0.7);
  }
  50% {
    transform: translate(3px, 3px) scale(1.18);
    text-shadow:
      0 0 0 rgba(30, 150, 15, 1),
      0 0 0 rgba(26, 132, 13, 0.95),
      0 0 0 rgba(22, 112, 11, 0.88),
      0 0 0 rgba(18, 92, 9, 0.8),
      0 0 0 rgba(14, 72, 7, 0.7),
      1px 1px 0 rgba(10, 52, 5, 0.58),
      2px 2px 0 rgba(7, 36, 3, 0.45),
      3px 3px 0 rgba(4, 22, 2, 0.32),
      0 0 12px rgba(57, 255, 20, 1),
      0 0 30px rgba(57, 255, 20, 0.7);
  }
}

/* ---------- catalog detail: camera dolly INTO the hologram ----------
   Catalog cards don't flip: the store camera pushes toward the clicked
   card until its holo square centers on the POV, the "?" dissolves, and
   the black-market board materializes inside the square (the foil keeps
   glowing around its edges). Closing runs the exact reverse. The board is
   authored at the card's local scale and rides the screen zoom. */
/* NOTE: no will-change/filter/transform may sit on #screen-store at rest
   -- any of them would turn it into the containing block for the fixed
   store-detail overlay, shrinking the flip backdrop to the section box
   instead of the full screen. The dolly sets transform inline only while
   zoomed (when the flip overlay is never open). */
#cd-board {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  gap: 10px;
  padding: 9px 12px 7px 12px;
  background: rgba(4, 7, 4, 0.93);
  font-family: 'Courier New', monospace;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s ease;
}
.card-boarding #cd-board {
  opacity: 1;
  pointer-events: auto;
  transition-delay: 0.28s;
}
.cd-left { flex: 1.25; min-width: 0; }
.cd-right {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  /* clears the plate's top-edge V-notch, which dips into this column */
  padding-top: 22px;
}
#cd-tag { font-size: 6px; letter-spacing: 1px; color: #5a8f50; }
#cd-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 1px;
  color: #86ff66;
  text-shadow: 0 0 4px rgba(57, 255, 20, 0.9), 0 0 9px rgba(57, 255, 20, 0.5);
  margin: 2px 0 1px;
}
#cd-item-no { font-size: 6px; letter-spacing: 1px; color: #6dcf5a; }
#cd-desc { font-size: 6.5px; line-height: 1.45; color: #b9dcb0; margin-top: 3px; }
#cd-price {
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #86ff66;
  text-shadow: 0 0 4px rgba(57, 255, 20, 0.85), 0 0 10px rgba(57, 255, 20, 0.4);
}
.cd-btns { display: flex; gap: 5px; margin-top: auto; }
#cd-board .jp-btn {
  font-size: 8px;
  letter-spacing: 1px;
  padding: 4px 11px;
  border-width: 1px;
  clip-path: polygon(5px 0, 100% 0, calc(100% - 5px) 100%, 0 100%);
}
/* Same color locks as the featured board: BUY reads money-green, PREVIEW
   reads orange, no matter where the shelf's hue clock is. */
.store-shelf { --hue-counter: calc(-1 * var(--ui-hue, 0deg)); }
.store-shelf .jp-btn { --ui-hue: var(--hue-counter); }
#cd-preview { --btn-hue: -80deg; }
#cd-buy {
  border-color: #b9ff9e;
  color: #baffa6;
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.55), inset 0 0 5px rgba(57, 255, 20, 0.2);
}
#cd-status { font-size: 5.5px; min-height: 8px; margin: 3px 0 0; letter-spacing: 0.5px; color: #ffd75e; }
/* While diving, the zoomed screen overflows the page and would spawn
   side/bottom scrollbars. Hide the bars COSMETICALLY (scrollbar-width /
   ::-webkit-scrollbar) rather than overflow:hidden -- overflow on body
   makes the body box CLIP the zoomed world at its own edge (a giant
   grey bar slicing the card), and it also snaps the scroll position.
   Actual scrolling is already locked by the input blockers. */
html.cat-diving {
  scrollbar-width: none;
}
html.cat-diving::-webkit-scrollbar {
  width: 0;
  height: 0;
  display: none;
}
/* Board intel: NOTHING shows during the dolly (no half-zoomed words) --
   JS adds .cat-revealed 0.2s after the camera arrives, then the lines
   rise in staggered, exactly like the featured card's reveal. */
.cd-r {
  opacity: 0;
  transform: translateY(5px);
  transition: opacity 0.28s ease var(--d, 0s), transform 0.32s cubic-bezier(0.22, 1, 0.36, 1) var(--d, 0s);
}
#screen-store.cat-revealed .cd-r { opacity: 1; transform: none; }
/* While boarding: the "?" dissolves and the card stops acting hoverable
   -- NO highlight of any kind (each selector here must outweigh its
   hover counterpart further down, so specificity is deliberately one
   class heavier). */
.card-boarding .store-q { opacity: 0; transition: opacity 0.2s ease; }
.store-card.card-boarding { cursor: default; }
.store-card.card-boarding:hover {
  transform: none;
  border-color: rgba(134, 255, 102, 0.7);
}
/* boarded plate keeps its resting look on hover (plain none would erase
   the outline entirely) -- same top-left lighting as at rest */
.store-shelf-catalog .store-card.card-boarding:hover {
  filter:
    drop-shadow(1.5px 0 0 rgba(134, 255, 102, 0.85))
    drop-shadow(0 -1.5px 0 rgba(134, 255, 102, 0.85))
    drop-shadow(2px 2px 0 rgba(30, 150, 15, 0.95))
    drop-shadow(2px 2px 0 rgba(24, 124, 12, 0.85))
    drop-shadow(2px 2px 0 rgba(18, 96, 9, 0.75))
    drop-shadow(2px 2px 0 rgba(14, 72, 7, 0.6))
    drop-shadow(2px 2px 0 rgba(10, 48, 5, 0.45))
    drop-shadow(2px 2px 0 rgba(6, 28, 3, 0.3))
    drop-shadow(5px -5px 9px rgba(57, 255, 20, 0.4))
    drop-shadow(9px 14px 10px rgba(0, 0, 0, 0.55));
}
.store-card.card-boarding:hover .store-card-item::before {
  filter: hue-rotate(var(--holo-hue, 0deg));
}
.store-card.card-boarding:hover .price-ch {
  animation: none;
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.85), 0 0 20px rgba(57, 255, 20, 0.4);
  transform: none;
}
.store-card.card-boarding:hover .store-card-price {
  background: rgba(6, 14, 6, 0.75);
  border-top-color: rgba(134, 255, 102, 0.55);
}
#screen-store.cat-zoom .store-card:not(.card-boarding) { pointer-events: none; }
/* The dive darkens the world: every card except the boarded one, the
   shelf labels, the sign and the BACK row all fall away while the
   hologram holds center stage -- and fade back on the pull-out. */
.store-shelf-label,
#store-title,
#screen-store > .btn-row {
  transition: opacity 0.45s ease, filter 0.55s cubic-bezier(0.7, 0, 0.25, 1);
}
#screen-store.cat-zoom .store-card:not(.card-boarding),
#screen-store.cat-zoom .store-shelf-label,
#screen-store.cat-zoom #store-title,
#screen-store.cat-zoom > .btn-row {
  opacity: 0.13;
}
/* DIVE DEPTH-OF-FIELD: the world melts out of focus on a speed ramp
   (slow bite, hard pull, soft settle) while the boarded hologram stays
   crystal -- then racks back into focus on the pull-out. The blur rides
   the cards' CONTENT and the etch canvases, never .store-card itself, so
   it can't fight the hover filter transitions or the shelf's hue
   animation. */
.store-card-item,
.store-card-price,
.store-card::before,
.store-card::after,
.circuit-canvas {
  transition: filter 0.55s cubic-bezier(0.7, 0, 0.25, 1);
}
#screen-store.cat-zoom .store-card:not(.card-boarding) .store-card-item,
#screen-store.cat-zoom .store-card:not(.card-boarding) .store-card-price,
#screen-store.cat-zoom .store-card:not(.card-boarding)::before,
#screen-store.cat-zoom .circuit-canvas,
#screen-store.cat-zoom .store-shelf-label,
#screen-store.cat-zoom #store-title,
#screen-store.cat-zoom > .btn-row {
  filter: blur(5px);
}
/* The chip detail keeps its glow in the list so the blur interpolates
   instead of hard-swapping (mismatched filter lists don't animate). */
#screen-store.cat-zoom .store-shelf-featured .store-card:not(.card-boarding)::after {
  filter: drop-shadow(0 0 4px rgba(57, 255, 20, 0.55)) blur(5px);
}
/* catalog side-slats blur with the rest of the world during the dive */
#screen-store.cat-zoom .store-shelf-catalog .store-card:not(.card-boarding)::after {
  filter: blur(5px);
}

/* ---------- store item detail: flip -> center -> camera push ----------
   Clicking a card: it flies from its shelf slot to screen center while
   flipping to its black back (slight wind-up), the camera pushes in on a
   hard speed ramp, then the item intel reveals line by line. ---------- */
#store-detail {
  position: fixed;
  inset: 0;
  z-index: 70;
}
#store-detail.hidden { display: none; }
/* The instant a close starts, the overlay stops eating input -- the next
   card is pickable THE SECOND you click off this one. */
#store-detail.closing { pointer-events: none; }
/* Ghost of the outgoing card during a switch: purely visual, absolutely
   anchored at the stage center so its transform space matches the real
   card's (the real one keeps the flex slot). */
.flip-ghost {
  position: absolute !important;
  left: 50%;
  top: 50%;
  /* outgoing card sits UNDER the incoming one (the clone carries the
     #flip-card id, so this needs the important to win) */
  z-index: 0 !important;
  pointer-events: none !important;
}
#sd-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.78);
  backdrop-filter: blur(4px);
  opacity: 0;
  transition: opacity 0.6s ease;
}
#store-detail.open #sd-backdrop { opacity: 1; }
#flip-stage {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 1600px;
  /* The shelf hue-drifts everything (frame, price, foil) via --ui-hue;
     the replica lives outside the shelf so it must ride the same clock,
     or the card visibly recolors the instant it's clicked. Filter sits
     here (not on #flip-card) because a filter on the preserve-3d element
     would flatten the flip. */
  filter: hue-rotate(var(--ui-hue, 0deg));
}
#flip-card {
  position: relative;
  /* the LIVE card always rides above any outgoing ghost crossing its path */
  z-index: 1;
  width: min(500px, calc(88 * var(--dvw, 1vw)));
  height: min(640px, calc(86 * var(--dvh, 1vh)));
  transform-style: preserve-3d;
  will-change: transform;
}
#flip-front,
#flip-back {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
/* Both faces carry the featured plate's exact angular silhouette (the
   back mirrored, as the real back of that plate would be), drawn with the
   two-layer clip technique: face = rim color, inner = content. */
/* The highlight line tracing the whole silhouette is the face's rim color
   showing through the inner inset. It IGNITES during the flight (fade in,
   slight delay so it blooms as the card lifts) and dies off on the glide
   home (.closing) -- never just popping on or off. */
#flip-front {
  background: rgba(134, 255, 102, 0);
  transition: background-color 0.6s ease 0.15s;
}
#store-detail.open #flip-front {
  background: rgba(134, 255, 102, 0.75);
}
#store-detail.closing #flip-front {
  transition: background-color 0.35s ease;
}
.face-inner {
  position: absolute;
  inset: 2px;
  overflow: hidden;
}
/* Front face mirrors the card's true anatomy: an item area (holo + "?")
   with the price bar BELOW it in flow -- so the "?" centers exactly where
   the card centers it. */
#flip-front .face-inner {
  background: #060b06;
  display: flex;
  flex-direction: column;
}
#sd-front-item {
  position: relative;
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
/* Same moving sheen as the shelf cards, phase-synced by JS via --sheen-d
   (and the same oversized transform-pan as the shelf for perf). */
#sd-front-item::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 300%;
  height: 300%;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.16) 46%,
    rgba(255, 255, 255, 0.34) 50%,
    rgba(255, 255, 255, 0.16) 54%,
    transparent 70%
  );
  animation: holo-sheen 7s ease-in-out infinite alternate;
  animation-delay: var(--sheen-d, 0s);
}
#flip-back .face-inner {
  background:
    repeating-linear-gradient(to bottom, transparent 0 3px, rgba(57, 255, 20, 0.035) 3px 4px),
    #050705;
  box-shadow: inset 0 0 44px rgba(0, 0, 0, 0.65);
  /* compact: the card is landscape now (it inherits the shelf card's
     aspect), so the intel must fit WITHOUT a scrollbar */
  padding: 26px 34px 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow-y: auto;
  font-family: 'Courier New', monospace;
  color: #b9dcb0;
}
/* The angular plate silhouette only when the flip came from a FEATURED
   card -- catalog cards flip as the plain rectangles they are. */
#flip-card.from-featured #flip-front,
#flip-card.from-featured #flip-front .face-inner {
  clip-path: polygon(
    0% 2.5%, 5% 0%, 72% 0%, 100% 8%,
    100% 55%, 96.5% 58%, 96.5% 68%, 100% 71%,
    100% 96%, 95% 100%, 60% 100%, 56% 96.5%,
    44% 96.5%, 40% 100%, 0% 100%
  );
}
#flip-card.from-featured #flip-back,
#flip-card.from-featured #flip-back .face-inner {
  clip-path: polygon(
    100% 2.5%, 95% 0%, 28% 0%, 0% 8%,
    0% 55%, 3.5% 58%, 3.5% 68%, 0% 71%,
    0% 96%, 5% 100%, 40% 100%, 44% 96.5%,
    56% 96.5%, 60% 100%, 100% 100%
  );
}
#sd-front-holo {
  position: absolute;
  left: 0;
  top: 0;
  width: 320%;
  height: 320%;
  background: linear-gradient(
    125deg,
    rgba(255, 60, 200, 0.5) 0%,
    rgba(60, 240, 255, 0.45) 24%,
    rgba(150, 80, 255, 0.5) 48%,
    rgba(60, 255, 160, 0.42) 72%,
    rgba(255, 220, 80, 0.42) 100%
  );
  /* Same oversized crop as the shelf cards -- JS phase-syncs the drift
     via a negative animation-delay so the colors match exactly at
     takeoff. */
  /* saturate/brightness match the shelf card's HOVER flare -- the cursor
     is on the card at the click instant, so the hovered foil is what the
     replica must reproduce at takeoff. */
  filter: hue-rotate(var(--holo-hue, 0deg)) saturate(1.8) brightness(1.35);
}
/* Gliding home: the flare eases off in flight, so the foil touches down
   at the resting brightness of the (no longer hovered) shelf card it's
   about to become. Transition lives only here, so reopening snaps the
   flare back on instantly -- the card you just clicked IS hovered. */
#store-detail.closing #sd-front-holo {
  filter: hue-rotate(var(--holo-hue, 0deg));
  transition: filter 0.4s ease;
}
/* --sd-inv is the inverse of the launch scale (JS sets it): the front's
   visuals are authored at inverse size so that, scaled down to the shelf
   slot, they are pixel-identical to the card being replaced. */
#flip-front .store-q {
  position: relative;
  z-index: 1;
  font-size: calc(68px * var(--sd-inv, 1));
}
#flip-front .face-inner { inset: calc(2px * var(--sd-inv, 1)); }
#flip-card.from-featured #flip-front .face-inner::after {
  left: calc(11px * var(--sd-inv, 1));
  top: calc(12px * var(--sd-inv, 1));
  width: calc(28px * var(--sd-inv, 1));
  height: calc(28px * var(--sd-inv, 1));
}
#flip-card.from-featured #flip-front .face-inner::before {
  right: calc(7px * var(--sd-inv, 1));
  width: calc(5px * var(--sd-inv, 1));
}
#sd-front-price {
  padding: calc(10px * var(--sd-inv, 1)) 12px;
  text-align: center;
  border-top: 1px solid rgba(134, 255, 102, 0.55);
  background: rgba(6, 14, 6, 0.75);
  font-family: 'Courier New', monospace;
  font-size: calc(21px * var(--sd-inv, 1));
  font-weight: bold;
  letter-spacing: calc(2px * var(--sd-inv, 1));
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
/* The black card: same plate, mirrored (it's the back of the object).
   Same rim ignition as the front face. */
#flip-back {
  transform: rotateY(180deg);
  background: rgba(134, 255, 102, 0);
  transition: background-color 0.6s ease 0.15s;
}
#store-detail.open #flip-back {
  background: rgba(134, 255, 102, 0.6);
}
#store-detail.closing #flip-back {
  transition: background-color 0.35s ease;
}
/* No close button: clicking anywhere off the card closes it. The stage
   ignores pointer events so outside clicks fall through to the backdrop
   (which is wired to close); only the card itself is interactive. */
#flip-stage { pointer-events: none; }
#flip-card { pointer-events: auto; }
#sd-tag { font-size: 12px; letter-spacing: 3px; color: #5a8f50; }
#sd-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 34px;
  letter-spacing: 2px;
  color: #86ff66;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.9), 0 0 26px rgba(57, 255, 20, 0.5);
}
#sd-item-no { font-size: 15px; letter-spacing: 2px; color: #6dcf5a; }
.sd-body p { margin: 0 0 12px; font-size: 16.5px; line-height: 1.6; }
/* Two columns under the name: item hologram / price / preview on the
   left, intel / BUY on the right. The name wraps freely above -- a long
   name simply pushes the whole grid down. */
#sd-grid {
  flex: 1;
  min-height: 0;
  display: grid;
  /* minmax(0, ...) stops either column's content from stealing width from
     the other -- without it the buttons bully the description into a
     sliver and the text overflows the card. */
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  gap: 14px 30px;
  margin-top: 4px;
}
#sd-col-l,
#sd-col-r {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
}
#sd-col-r #sd-buy { margin-top: auto; }
/* ---- the floating item: a sealed holo-cache turning in the air ---- */
#sd-holo-scene {
  position: relative;
  flex: 1;
  min-height: 150px;
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 640px;
}
#sd-holo-float {
  transform-style: preserve-3d;
  animation: holo-bob 4.6s ease-in-out infinite;
}
#sd-holo-cube {
  width: 144px;
  height: 144px;
  transform-style: preserve-3d;
  animation: holo-spin 13s linear infinite;
}
.chf {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-style: normal;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 56px;
  color: #d2ffc4;
  text-shadow: 0 0 10px rgba(134, 255, 102, 0.95), 0 0 22px rgba(57, 255, 20, 0.5);
  background: rgba(57, 255, 20, 0.07);
  border: 1.5px solid rgba(134, 255, 102, 0.75);
  box-shadow:
    inset 0 0 20px rgba(57, 255, 20, 0.22),
    0 0 10px rgba(57, 255, 20, 0.25);
}
.chf-1 { transform: translateZ(72px); }
.chf-2 { transform: rotateY(90deg) translateZ(72px); }
.chf-3 { transform: rotateY(180deg) translateZ(72px); }
.chf-4 { transform: rotateY(-90deg) translateZ(72px); }
.chf-5 { transform: rotateX(90deg) translateZ(72px); }
.chf-6 { transform: rotateX(-90deg) translateZ(72px); }
@keyframes holo-spin {
  0% { transform: rotateX(-18deg) rotateY(0deg); }
  100% { transform: rotateX(-18deg) rotateY(360deg); }
}
@keyframes holo-bob {
  0%, 100% { transform: translateY(-9px); }
  50% { transform: translateY(6px); }
}
/* Light pooling on the "floor" beneath it -- tightens as the cube dips. */
#sd-holo-shadow {
  position: absolute;
  bottom: 4px;
  left: 50%;
  width: 200px;
  height: 16px;
  background: radial-gradient(ellipse, rgba(57, 255, 20, 0.4) 0%, transparent 70%);
  filter: blur(3px);
  animation: holo-shadow 4.6s ease-in-out infinite;
}
@keyframes holo-shadow {
  0%, 100% { transform: translateX(-50%) scaleX(0.8); opacity: 0.4; }
  50% { transform: translateX(-50%) scaleX(1.05); opacity: 0.85; }
}
/* PREVIEW and BUY hold their colors no matter where the hue clock is
   (like the danger button: BUY must always read as the green money
   button). The stage rotates the whole card by --ui-hue; these buttons'
   own hue-rotate counter-spins by the inverse, netting zero drift.
   --hue-counter is captured on the stage BEFORE the override below, so
   there's no var cycle. */
#flip-stage { --hue-counter: calc(-1 * var(--ui-hue, 0deg)); }
#flip-stage .jp-btn { --ui-hue: var(--hue-counter); }
/* PREVIEW: the orange one, centered under the price, and clearly the
   smaller of the pair. */
#sd-col-l .btn-row { justify-content: center; }
#sd-preview {
  --btn-hue: -80deg;
  align-self: center;
  font-size: 20px;
  letter-spacing: 2px;
  padding: 11px 36px;
}
/* The NEON GREEN BUY: the loudest, biggest thing on the card. */
#sd-buy {
  width: 100%;
  font-size: 27px;
  letter-spacing: 6px;
  padding: 17px 10px;
  border-color: #b9ff9e;
  color: #baffa6;
  box-shadow:
    0 0 18px rgba(57, 255, 20, 0.55),
    inset 0 0 16px rgba(57, 255, 20, 0.2);
  text-shadow:
    1px 1px 0 rgba(15, 90, 8, 0.8),
    2px 2px 0 rgba(10, 55, 5, 0.55),
    0 0 12px rgba(57, 255, 20, 1),
    0 0 30px rgba(57, 255, 20, 0.6);
}
/* Hover: IDENTICAL to PREVIEW -- pure stock .jp-btn hover behavior,
   zero overrides. The ONE difference: after a beat, the ENTIRE BUTTON
   starts the 3D press-wave -- the whole slab (label riding it as a
   printed surface) sinks along its extrusion axis while the depth steps
   collapse beneath it, base planted, then extrudes back out. The wave's
   first keyframe is the stock hover pose + filter VERBATIM, so the
   takeover is invisible. */
.buy-ch { display: inline-block; }
#sd-buy:hover:not(:disabled) {
  animation: buy-press 2.4s ease-in-out infinite;
  animation-delay: 0.6s;
}
@keyframes buy-press {
  0%, 100% {
    transform: translate(-7px, -7px) scale(1.07);
    filter:
      drop-shadow(3px 3px 0 rgba(24, 130, 12, 0.85))
      drop-shadow(3px 3px 0 rgba(18, 100, 8, 0.65))
      drop-shadow(3px 3px 0 rgba(12, 70, 6, 0.48))
      drop-shadow(3px 3px 0 rgba(8, 45, 4, 0.32))
      drop-shadow(0 0 20px rgba(57, 255, 20, 0.8))
      hue-rotate(var(--ui-hue, 0deg))
      hue-rotate(var(--btn-hue, 0deg));
  }
  50% {
    /* slab sunk 8px along the axis; its 12px stepped side collapses to
       4px -- face + remaining side = original base, so the block bottom
       never moves */
    transform: translate(1px, 1px) scale(1.07);
    filter:
      drop-shadow(1px 1px 0 rgba(24, 130, 12, 0.85))
      drop-shadow(1px 1px 0 rgba(18, 100, 8, 0.65))
      drop-shadow(1px 1px 0 rgba(12, 70, 6, 0.48))
      drop-shadow(1px 1px 0 rgba(8, 45, 4, 0.32))
      drop-shadow(0 0 20px rgba(57, 255, 20, 0.8))
      hue-rotate(var(--ui-hue, 0deg))
      hue-rotate(var(--btn-hue, 0deg));
  }
}
#sd-buy:active:not(:disabled) { animation: none; }
#sd-price-row {
  font-size: 46px;
  font-weight: bold;
  letter-spacing: 3px;
  color: #86ff66;
  /* dead-center under the hologram (the cube is centered in the scene
     above, this row spans the same column) */
  text-align: center;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.85), 0 0 24px rgba(57, 255, 20, 0.4);
}
#sd-status {
  min-height: 16px;
  margin: 2px 0 0;
  font-size: 12px;
  letter-spacing: 1px;
  color: #ffd75e;
}
/* Line-by-line intel reveal, each line on its own delay; the small
   per-line rise + soft settle is what makes it feel hand-animated. */
.sd-reveal {
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity 0.4s ease var(--d, 0s),
    transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) var(--d, 0s);
}
#store-detail.revealed .sd-reveal {
  opacity: 1;
  transform: translateY(0);
}
/* While the flip is in flight, the shelf slot sits empty -- there is only
   one card in the world. */
.store-card.card-lifted { visibility: hidden; }
/* The card's whole face celebrates too: hotter bar, bigger "?". */
.store-card:hover .store-card-price {
  background: rgba(18, 42, 14, 0.85);
  border-top-color: #86ff66;
}
.store-q { transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); }
.store-card:hover .store-q {
  transform: scale(1.18);
  text-shadow:
    0 0 12px rgba(255, 255, 255, 1),
    0 0 34px rgba(255, 255, 255, 0.7),
    0 0 60px rgba(134, 255, 102, 0.8);
}

/* ---------- phone power ritual: off -> boot -> camera dolly -> fullscreen.
   The dolly scales the whole handset around the screen's center (JS sets
   the transform); at arrival the screen node itself goes fixed-fullscreen
   with a zoom so the same DOM just becomes the page. ---------- */
#gang-phone-wrap {
  transform-origin: 50.5% 34.4%; /* center of the screen cutout */
}
/* Off: screen shows nothing but the dark glass. */
#gang-phone-wrap.off .phone-view { display: none; }
#phone-off-layer {
  position: absolute;
  inset: 0;
  z-index: 3;
  background: radial-gradient(ellipse at 42% 30%, #071007 0%, #020402 70%);
}
#gang-phone-wrap.on #phone-off-layer { display: none; }
/* Intrusion log: gangPhonePowerOn() types lines into this element while
   the camera dollies in. The cursor only blinks while booting -- off means
   dead glass. The .ok line (ACCESS GRANTED) flares full-bright. */
#phone-boot {
  position: absolute;
  inset: 0;
  padding: 10px 12px;
  overflow: hidden;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  line-height: 1.8;
  letter-spacing: 0.5px;
  color: #5fbf4e;
  text-shadow: 0 0 4px rgba(57, 255, 20, 0.35);
}
#gang-phone-wrap.booting #phone-boot::after {
  content: '▊';
  animation: term-cursor 0.7s steps(1) infinite;
}
#phone-boot .ok {
  color: #c8ffbe;
  font-weight: bold;
  letter-spacing: 2px;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.9), 0 0 20px rgba(57, 255, 20, 0.5);
  animation: phone-boot-flicker 0.5s ease-out;
}
@keyframes phone-boot-flicker {
  0% { opacity: 0; }
  30% { opacity: 1; }
  45% { opacity: 0.3; }
  60%, 100% { opacity: 1; }
}
/* The glowing D-pad center key IS the power button: an invisible hotspot
   sits exactly on the glow. Hovering it locks the flash to full-bright. */
#phone-power {
  position: absolute;
  left: 45.1%;
  top: 55.7%;
  width: 12.7%;
  height: 9.7%;
  background: none;
  border: none;
  cursor: pointer;
  z-index: 5;
}
#phone-power:hover + #phone-dpad-glow {
  animation: none;
  opacity: 1;
}
#gang-phone-wrap.on #phone-power,
#gang-phone-wrap.booting #phone-power,
#gang-phone-wrap.on #phone-dpad-glow,
#gang-phone-wrap.booting #phone-dpad-glow { display: none; }
/* The D-pad glow is an image overlay generated FROM the phone photo (a
   green stroke drawn on the purple ring's measured centerline, bloom
   baked in). Same canvas as the handset image, so inset:0 registers it
   pixel-perfectly at any display size. */
#phone-dpad-glow {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 4;
  animation: dpad-glow-pulse 2s linear infinite;
}
/* Double-blink like an incoming-call LED: flash, flash, dark rest. */
@keyframes dpad-glow-pulse {
  0% { opacity: 0.12; }
  7% { opacity: 1; }
  15% { opacity: 0.18; }
  22% { opacity: 1; }
  32% { opacity: 0.18; }
  40%, 100% { opacity: 0.12; }
}
#phone-hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -32px;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 3px;
  color: #4d8f44;
  animation: hint-pulse 2.2s ease-in-out infinite;
  pointer-events: none;
}
@keyframes hint-pulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}
#gang-phone-wrap.on #phone-hint,
#gang-phone-wrap.booting #phone-hint { display: none; }
/* Fullscreen: the handset vanishes, the screen node IS the page. The
   width/height divide by the zoom factor so the zoomed box lands exactly
   on the viewport. */
#phone-full-off { display: none; }
#screen-clans.phone-full #gang-phone-img,
#screen-clans.phone-full #phone-power,
#screen-clans.phone-full #phone-dpad-glow,
#screen-clans.phone-full #phone-hint { display: none; }
#screen-clans.phone-full #phone-screen {
  position: fixed;
  left: 0;
  top: 0;
  /* --vpw/--vph, NOT --dvw/--dvh: the glass must cover the REAL window.
     The design-grid units only match the viewport at exactly 16:9
     (fullscreen on the author's monitor); any windowed aspect left a
     black crop where the glass ended. See swApplyZoom. */
  width: calc(var(--vpw, 100vw) / 2.1);
  height: calc(var(--vph, 100vh) / 2.1);
  zoom: 2.1;
  z-index: 45;
  box-shadow: none;
}
#screen-clans.phone-full #phone-full-off {
  display: block;
  position: absolute;
  top: 7px;
  right: 9px;
  z-index: 9;
  padding: 3px 7px;
  background: rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(110, 255, 90, 0.35);
  color: #6fcf5c;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
  transition: border-color 0.12s, color 0.12s, text-shadow 0.12s;
}
#screen-clans.phone-full #phone-full-off:hover {
  border-color: rgba(255, 92, 92, 0.7);
  color: #ff8585;
  text-shadow: 0 0 8px rgba(255, 60, 60, 0.6);
}
/* Fullscreen GANG-OS: the showcase plate and the program grid sit as one
   centered block between the status bar and the bottom edge (auto margins
   above the plate and below the grid split the slack); apps open as
   terminal windows dead-center over the dimmed home. */
#screen-clans.phone-full #ph-showcase {
  width: min(540px, 88%);
  margin: auto auto 24px;
  /* The generic .phone-app fullscreen padding (below) would inset
     #ph-sc-body and un-pin the absolute statline from the plate's edge --
     the plate's spacing lives entirely on its body. */
  padding: 0;
}
#screen-clans.phone-full #phone-apps {
  flex: none;
  margin: 0 auto auto;
  max-width: 520px;
  gap: 26px 34px;
}
#screen-clans.phone-full #phone-apps .phone-app { width: 150px; }
#screen-clans.phone-full .phone-app { gap: 9px; padding: 20px 8px 15px; }
#screen-clans.phone-full .pa-icon { font-size: 42px; }
#screen-clans.phone-full .pa-label { font-size: 12px; }
/* Showcase at page scale. */
#screen-clans.phone-full #ph-sc-body { gap: 9px; padding: 30px 14px 52px; }
#screen-clans.phone-full #ph-sc-tag,
#screen-clans.phone-full #ph-sc-name { font-size: 26px; }
#screen-clans.phone-full #ph-sc-statline { font-size: 13px; gap: 12px; padding: 8px 14px; }
#screen-clans.phone-full #ph-sc-xpbar { height: 8px; }
#phone-home.dimmed,
#phone-view-fork.dimmed {
  filter: brightness(0.3) blur(2px);
  pointer-events: none;
  transition: filter 0.2s;
}
/* The rain is a SIBLING of the views, not a child of the root -- when a
   window dims the home behind it, the canvas must sink with it or the
   streams stay razor-sharp through the blur and read as being IN the
   window. */
#phone-screen:has(.phone-view.dimmed) #phone-matrix {
  filter: brightness(0.3) blur(2px);
}
#phone-matrix { transition: filter 0.2s; }
#screen-clans.phone-full .phone-view:not(#phone-home):not(.hidden) {
  position: absolute;
  inset: auto;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(430px, 84%);
  height: auto;
  max-height: 84%;
  min-height: 200px;
  padding: 12px 14px;
  background: rgba(3, 7, 3, 0.97);
  border: 1px solid rgba(110, 255, 90, 0.4);
  box-shadow:
    0 0 60px rgba(0, 0, 0, 0.9),
    0 0 18px rgba(57, 255, 20, 0.15),
    inset 0 0 26px rgba(57, 255, 20, 0.05);
  animation: term-open 0.18s ease-out;
  z-index: 6;
}
/* Chat needs a real height so its message list can flex-fill. */
#screen-clans.phone-full #phone-view-chat:not(.hidden) { height: 84%; }
/* Five stacked sections vs the 84% max-height: the window scrolls. */
#screen-clans.phone-full #phone-view-recruit:not(.hidden) { overflow-y: auto; }
/* The fork is the BOOT screen when you have no gang -- a root window,
   bigger than the app modals (it IS the phone until you pick a road).
   The explicit height gives the flex:1 fork something to fill; the
   min-height below is the floor for the transient handset moment. */
/* .phone-view is restated to out-rank the shared modal rule above: its
   :not(#phone-home) carries ID weight, so a bare #phone-view-fork loses
   the width fight despite reading more specific. */
#screen-clans.phone-full #phone-view-fork.phone-view:not(.hidden) {
  width: min(540px, 88%);
  height: 62%;
}
#screen-clans.phone-full #gang-fork { min-height: 210px; }
#screen-clans.phone-full .gf-title { font-size: 21px; }
#screen-clans.phone-full .gf-cmd { font-size: 11px; }
/* Windows open like a CRT warming up: a horizontal line that unfolds
   vertically, not a spring pop. */
@keyframes term-open {
  0% { transform: translate(-50%, -50%) scale(1, 0.04); opacity: 0.5; }
  60% { transform: translate(-50%, -50%) scale(1, 1.02); opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(1, 1); opacity: 1; }
}
/* Every GANG-OS window materializes through digital corruption: the
   panel arrives as torn horizontal bands with the phosphor blown out and
   hue knocked sideways, snapping (step-end -- corruption doesn't ease)
   through re-slices until the full frame locks in. phoneOpen() stamps
   .glitch-in on each real open. clip-path + filter only, no transform,
   so the same keyframes work on the handset (inset:0) and in fullscreen
   (translate-centered) without fighting either layout. */
#phone-screen .phone-view.glitch-in { animation: gm-glitch-in 0.42s step-end; }
/* Fullscreen restates BOTH animations -- .glitch-in alone would replace
   the shared term-open declaration and lose the unfold. The extra class
   out-ranks the shared modal rule at equal ID weight. */
#screen-clans.phone-full .phone-view.glitch-in:not(#phone-home):not(.hidden) {
  animation: term-open 0.18s ease-out, gm-glitch-in 0.42s step-end;
}
/* The boot cut rides the same corruption: gangPhonePowerOn stamps
   .glitch-in on the whole screen node as it goes fullscreen, so the
   dolly zoom lands ON the UI materializing instead of a hard swap
   behind the tear. No term-open -- the root isn't a window. */
#screen-clans.phone-full #phone-screen.glitch-in { animation: gm-glitch-in 0.42s step-end; }
@keyframes gm-glitch-in {
  0% {
    clip-path: polygon(0 4%, 100% 4%, 100% 12%, 0 12%, 0 38%, 100% 38%, 100% 44%, 0 44%, 0 70%, 100% 70%, 100% 79%, 0 79%);
    filter: hue-rotate(80deg) saturate(3) brightness(1.7) contrast(1.4);
  }
  18% {
    clip-path: polygon(0 0, 100% 0, 100% 26%, 0 26%, 0 33%, 100% 33%, 100% 58%, 0 58%, 0 64%, 100% 64%, 100% 90%, 0 90%);
    filter: hue-rotate(-60deg) saturate(2.4) brightness(1.4);
  }
  36% {
    clip-path: polygon(0 9%, 100% 9%, 100% 21%, 0 21%, 0 27%, 100% 27%, 100% 74%, 0 74%, 0 81%, 100% 81%, 100% 100%, 0 100%);
    filter: hue-rotate(40deg) saturate(2) brightness(1.8);
  }
  54% {
    clip-path: polygon(0 0, 100% 0, 100% 47%, 0 47%, 0 52%, 100% 52%, 100% 100%, 0 100%);
    filter: brightness(1.35) contrast(1.2);
  }
  72% {
    clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
    filter: brightness(1.6) saturate(1.6);
  }
  86%, 100% {
    clip-path: none;
    filter: none;
  }
}

button {
  padding: 8px 14px;
  cursor: pointer;
}
.btn-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 12px 0;
}
.btn-row button, .btn-row label { margin: 0; }

.form-block {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 12px 0;
  max-width: 420px;
}
.form-block label {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/* Live input-level meter next to the mic picker in Settings -- lets a
   player CONFIRM the selected device is actually picking up sound instead
   of trusting a permission popup that says nothing about whether the
   right physical device got chosen. */
#mic-meter {
  position: relative;
  height: 8px;
  border-radius: 4px;
  background: rgba(57, 255, 20, 0.12);
  border: 1px solid rgba(134, 255, 102, 0.3);
  overflow: hidden;
}
#mic-meter-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: #86ff66;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.7);
  transition: width 0.08s linear;
}
#mic-status-text {
  font-size: 13px;
  color: rgba(134, 255, 102, 0.75);
}

label { display: block; margin: 8px 0 4px; }
input[type="text"], input[type="number"], input[type="file"], select {
  padding: 6px;
  margin: 2px 0;
}
/* #word-options used to be in this group. It is now THE CUT's ballot (see
   the booth section) and its `flex-direction: column` survived into the new
   screen -- the same stale-rule trap that turned the beat rack on its side.
   .word-option went with it: nothing builds that class any more. */
#lobby-list, #clan-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 10px 0;
}
.lobby-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.lobby-row, .beat-option {
  border: 1px solid #444;
  padding: 8px;
  margin: 0;
  border-radius: 4px;
}
.beat-option.selected {
  border-color: #ffcc00;
  background: #222;
}
/* #beat-options (the raw beat-vote list, later THE BOOTH's rack) is gone
   entirely: the beat vote stands in the crate's wheel now -- see THE BOOTH
   IS THE CRATE'S WHEEL in main.js. */
#results-list { padding-left: 24px; }
#results-list li { margin: 6px 0; }
#pick-beat-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 10px 0;
}

.player-icon {
  position: relative;
  display: inline-block;
  width: 28px;
  height: 28px;
  line-height: 28px;
  text-align: center;
  border-radius: 50%;
  font-size: 16px;
}
/* A real photo fills the ring edge to edge. The emoji chip wears its own
   background colour; a photo must not, or a portrait with any transparency
   picks up a random green disc behind it. Solid black, not `none` -- `none`
   is a window onto whatever's rendering behind the element, which on
   #pv-pfp (sitting over #profile-view's translucent 0.7-alpha backdrop) let
   the main menu's animated logo-glitch slice paths show through any
   transparent part of the uploaded image as a stray red squiggle. */
.player-icon-photo { background: #000 !important; overflow: hidden; }
/* Every other surface that shows a face: the voice-bar chip and the
   profile card. Same rules, so a photo behaves identically wherever a
   person is drawn. */
.voice-peer-icon:has(img), #pv-pfp:has(img) { background: #000 !important; overflow: hidden; }
.voice-peer-icon img,
#pv-pfp img,
.player-icon img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* inherit, NOT 50%. These containers are not all circles: .player-icon
     and the voice chip are round, but #pv-pfp is a 140px angular HUD plate
     and .social-av is a square -- rounding those would quietly undo the
     shape they were drawn as. The photo takes whatever shape it lands in. */
  border-radius: inherit;
  /* Undo whatever hue the surrounding chrome is drifting through. Rosters
     sit inside panels that rotate hue to stay on-palette, which turns a
     person's face a cycling green-purple -- see .social-av img, same fix
     for the same reason. A photo has to be the colour it actually is. */
  filter: hue-rotate(calc(-1 * var(--ui-hue, 0deg)));
}

.pick-option {
  display: inline-block;
  padding: 6px;
  margin: 2px;
  border: 2px solid transparent;
  border-radius: 6px;
  cursor: pointer;
  font-size: 20px;
}
.pick-option.selected { border-color: #ffcc00; }
.color-swatch {
  display: inline-block;
  width: 26px;
  height: 26px;
  margin: 2px;
  border: 2px solid transparent;
  border-radius: 50%;
  cursor: pointer;
}
.color-swatch.selected { border-color: #ffcc00; }

#profile-view {
  position: fixed;
  inset: 0;
  /* Top interactive layer: above every screen/overlay/menu (holo 340,
     cart 640-652), below only the FILTER glass (700, click-through) --
     without an explicit z-index these popups landed UNDER whatever
     stacked UI was up and couldn't be clicked closed.
     Raised above the social drawer (692): friends are opened FROM that
     drawer, so at 680 the profile would have appeared behind the thing
     that launched it.
     NO LONGER under the glass. #crt-texture (700) paints a phosphor mask
     over everything below it -- rgba(255,0,0,.06) to rgba(0,0,255,.06)
     left to right -- which is invisible on chrome and unmissable on a
     140px photo of a person: red down one side, blue down the other. This
     card exists to show someone's face, so it joins the layer every other
     modal surface already sits on (#sw-modal 980, #mp-ctx 950).
     945, not 940: the gang card (#clan-view, 940) opens profiles from its
     plaque names and STAYS OPEN underneath -- being later in the DOM it
     would paint over this card at equal z. sw-modal/mp-ctx stay on top. */
  z-index: 945;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
}
#profile-view.hidden { display: none; }
/* The enlarged twin of the right-click mini card -- same anatomy
   (detached picture, NAME.LVL plate, social icons, description, stacked
   actions), scaled up. Shares the mini card's .ctx-icon-btn styling. */
#profile-view-card {
  position: relative;
  /* Plate is pushed down-right so the picture has its own space at the
     top-left and only kisses the plate's corner. The offset is tuned so
     the plate's CONTENT starts just clear of the picture's right edge --
     otherwise the picture sits on top of the name. */
  padding: 60px 0 0 120px;
  animation: pv-pop 0.2s cubic-bezier(0.34, 1.5, 0.64, 1);
}
@keyframes pv-pop {
  from { transform: scale(0.9); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
#pv-pfp {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2; /* the picture sits OVER the plate, never tucked behind it */
  width: 140px;
  height: 140px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 70px;
  line-height: 1;
  background: #12240f;
  background-size: cover;
  background-position: center;
  border: 3px solid rgba(134, 255, 102, 0.8);
  clip-path: polygon(0 20px, 20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%);
  box-shadow: 0 0 30px rgba(57, 255, 20, 0.4);
}
#pv-plate {
  position: relative;
  z-index: 1; /* under the picture */
  width: min(680px, calc(86 * var(--dvw, 1vw)));
  padding: 26px 26px 20px;
  background: rgba(8, 16, 8, 0.97);
  border: 2px solid rgba(134, 255, 102, 0.6);
  clip-path: polygon(0 0, calc(100% - 26px) 0, 100% 26px, 100% 100%, 26px 100%, 0 calc(100% - 26px));
  box-shadow: 0 0 34px rgba(57, 255, 20, 0.3);
}
#pv-cols { display: flex; gap: 18px; }
#pv-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
#pv-name {
  padding: 14px 20px 11px;
  background: rgba(20, 44, 14, 0.55);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.4);
  clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#pv-lvl { font-size: 28px; color: #ffcc44; text-shadow: 0 0 10px rgba(255, 204, 68, 0.5); }
#pv-socials { display: flex; flex-wrap: wrap; gap: 11px; min-height: 4px; }
#pv-socials .ctx-social { width: 54px; height: 54px; }
#pv-socials .ctx-social svg { width: 30px; height: 30px; }
#pv-bio {
  padding: 16px 18px;
  min-height: 128px;
  max-height: calc(34 * var(--dvh, 1vh));
  overflow-y: auto;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  line-height: 1.5;
  letter-spacing: 1px;
  color: #b9dcb0;
  background: rgba(14, 30, 10, 0.6);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.28);
  clip-path: polygon(0 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%);
  overflow-wrap: anywhere;
}
#pv-bio.ctx-desc-empty { color: #5a8f50; font-style: italic; }
#pv-actions { flex: 0 0 auto; display: flex; flex-direction: column; gap: 12px; }
/* ID-scoped so these beat the shared .ctx-icon-btn sizing that comes
   later in the sheet -- the big card's actions must actually be big. */
#pv-actions .pv-icon-btn { width: 76px; height: 76px; }
#pv-actions .pv-icon-btn svg { width: 40px; height: 40px; }
#pv-friend.ctx-is-friend {
  background: rgba(60, 46, 8, 0.7);
  border-color: rgba(255, 204, 68, 0.6);
}
#pv-friend.ctx-is-friend svg { fill: #ffcc44; }
#pv-friend.ctx-is-friend .pv-friend-bar { display: none; } /* plus -> minus */
#pv-note {
  min-height: 18px;
  margin-top: 12px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  color: #ffcc44;
  text-align: center;
}
#btn-close-profile {
  display: block;
  width: 100%;
  margin-top: 12px;
  padding: 13px 0 10px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 4px;
  color: #b9dcb0;
  background: rgba(20, 44, 14, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.35);
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
  cursor: pointer;
  transition: background 0.16s ease-out, color 0.16s ease-out;
}
#btn-close-profile:hover { background: rgba(134, 255, 102, 0.18); color: #eaffe3; }

/* narrow: actions go horizontal under the details */
@media (max-width: 700px) {
  #pv-cols { flex-direction: column; }
  #pv-actions { flex-direction: row; }
  #profile-view-card { padding-left: 62px; padding-top: 40px; }
  #pv-pfp { width: 112px; height: 112px; font-size: 56px; }
}

textarea { padding: 6px; }

#clan-view {
  position: fixed;
  inset: 0;
  z-index: 940; /* same top interactive layer as #profile-view -- above the glass, see there */
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  align-items: center;
  justify-content: center;
}
#clan-view.hidden { display: none; }
/* A profile opened from a plaque name floats OVER the gang card (945 vs
   940); the card sinks into a blur underneath -- same treatment as the
   phone's dimmed root -- and comes back sharp when the profile closes. */
body:has(#profile-view:not(.hidden)) #clan-view-card {
  filter: brightness(0.55) blur(3px);
}
#clan-view-card { transition: filter 0.2s; }
/* The card: #pv-plate's recipe (angled corners, phosphor frame) with the
   showcase plate as its header. Padding 0 so the plate runs edge to edge;
   everything below lives in #cv-lower. */
#clan-view-card {
  width: min(760px, 90dvw);
  padding: 0;
  background: rgba(8, 16, 8, 0.97);
  border: 2px solid rgba(134, 255, 102, 0.6);
  clip-path: polygon(0 0, calc(100% - 26px) 0, 100% 26px, 100% 100%, 26px 100%, 0 calc(100% - 26px));
  box-shadow: 0 0 34px rgba(57, 255, 20, 0.3);
  text-align: left;
  animation: pv-pop 0.2s cubic-bezier(0.34, 1.5, 0.64, 1);
  overflow: hidden;
}
/* Header = the GANG-OS showcase plate, cv-sc-* mirrors of ph-sc-* (those
   ids are singletons owned by the phone home). Banner proud behind a
   light even scrim; legibility is the dark-then-phosphor shadow stack. */
#cv-plate {
  position: relative;
  min-height: 200px;
  overflow: hidden;
  border-bottom: 1px solid rgba(110, 255, 90, 0.3);
}
#cv-sc-banner {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  filter: saturate(0.6) brightness(0.55);
  opacity: 0;
  transition: opacity 0.2s;
}
#cv-plate.has-banner #cv-sc-banner { opacity: 1; }
#cv-sc-banner::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(2, 6, 2, 0.35);
}
#cv-sc-body {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-height: 200px;
  padding: 34px 20px 54px;
}
#cv-sc-title {
  display: flex;
  align-items: baseline;
  gap: 8px;
  max-width: 100%;
  font-family: 'Courier New', monospace;
  font-weight: bold;
}
#cv-sc-tag {
  font-size: 28px;
  color: #39ff14;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95), 0 0 9px rgba(57, 255, 20, 0.8);
}
#cv-sc-name {
  font-size: 28px;
  color: #eaffe6;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95), 0 0 10px rgba(57, 255, 20, 0.45);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#cv-sc-statline {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 18px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  font-weight: bold;
  color: #a8ffa0;
  text-shadow: 0 0 5px rgba(0, 0, 0, 0.95);
}
#cv-sc-xpbar {
  flex: 1;
  height: 9px;
  background: rgba(4, 10, 4, 0.8);
  border: 1px solid rgba(110, 255, 90, 0.4);
}
#cv-sc-xpfill {
  height: 100%;
  width: 0;
  background: #7dff5f;
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.7);
  transition: width 0.3s;
}
#cv-sc-rank { color: #7dff5f; letter-spacing: 1px; }
#cv-lower { padding: 16px 24px 20px; }
/* Plain type, no italics -- the slanted Courier read as a different font
   (user call: "remove this font"). */
#cv-motto {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  color: #4d8f44;
  margin-bottom: 12px;
}
/* Members chip: rides the plate under the title, a little dark badge. */
#cv-members-chip {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  margin-top: 8px;
  padding: 6px 14px 5px;
  background: rgba(0, 0, 0, 0.82);
  border: 1px solid rgba(110, 255, 90, 0.35);
  font-family: 'Courier New', monospace;
}
#cv-members-chip b {
  font-size: 15px;
  color: #c8ffbe;
  text-shadow: 0 0 7px rgba(57, 255, 20, 0.5);
}
#cv-members-chip span {
  font-size: 8px;
  letter-spacing: 1.6px;
  color: #4d8f44;
}
/* The dossier: owner + MVP plaques. Each row is a small card -- glyph
   tile, label over name, value chip on the right edge. MVPs are lifetime
   stats filtered by membership; the same name repeats on every category
   it leads. Owner wears the gold accent (the clan-tag color); everything
   else stays phosphor. */
#cv-people {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 15px;
}
.cv-person {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 15px;
  background: rgba(8, 18, 8, 0.5);
  border: 1px solid rgba(110, 255, 90, 0.14);
  border-left-width: 3px;
  font-family: 'Courier New', monospace;
  transition: background 0.12s, border-color 0.12s;
}
.cv-person:hover { background: rgba(14, 30, 12, 0.65); border-color: rgba(134, 255, 102, 0.35); }
.cv-p-ico {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border: 1px solid rgba(110, 255, 90, 0.3);
  background: rgba(0, 0, 0, 0.45);
  font-size: 19px;
  color: #7dff5f;
  text-shadow: 0 0 7px rgba(57, 255, 20, 0.6);
}
.cv-p-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.cv-p-k {
  font-size: 11px;
  font-weight: bold;
  letter-spacing: 1.6px;
  color: #4d8f44;
}
.cv-p-name {
  font-size: 22px;
  font-weight: bold;
  color: #eaffe6;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.12s, text-shadow 0.12s;
}
/* No glow on hover (user call) -- the brighten + underline is the whole
   click language. */
.cv-p-name:hover {
  color: #ffffff;
  text-shadow: none;
  text-decoration: underline;
}
.cv-p-val {
  flex-shrink: 0;
  margin-left: auto;
  padding: 6px 13px;
  border: 1px solid rgba(134, 255, 102, 0.3);
  background: rgba(0, 0, 0, 0.45);
  font-size: 14px;
  font-weight: bold;
  color: #a8ffa0;
}
/* The owner plaque wears gold -- the same accent every [TAG] badge uses. */
.cv-owner-row { border-left-color: rgba(255, 204, 0, 0.6); }
.cv-owner-row .cv-p-ico { color: #ffcc00; border-color: rgba(255, 204, 0, 0.4); text-shadow: 0 0 8px rgba(255, 204, 0, 0.5); }
.cv-owner-row .cv-p-k { color: #b89b3f; }
#cv-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}
/* The card is GANG-OS territory: buttons are bracketed terminal commands,
   NOT the store's skewed slab (which is what bare .jp-btn renders as
   outside #phone-screen -- giant cyan display-font, the "mest up" look).
   Same recipe as #phone-screen .jp-btn. */
#clan-view .jp-btn {
  font-family: 'Courier New', monospace;
  font-weight: bold;
  font-size: 13px;
  letter-spacing: 1px;
  padding: 8px 15px;
  background: transparent;
  border: 1px solid rgba(110, 255, 90, 0.35);
  clip-path: none;
  color: #7dff5f;
  text-shadow: none;
  filter: none;
  transition: background 0.1s, color 0.1s, border-color 0.1s;
}
#clan-view .jp-btn::before { content: '[ '; color: #4d8f44; }
#clan-view .jp-btn::after { content: ' ]'; color: #4d8f44; }
#clan-view .jp-btn:hover:not(:disabled) {
  transform: none;
  background: #7dff5f;
  border-color: #7dff5f;
  color: #04140a;
}
#clan-view .jp-btn:hover:not(:disabled)::before,
#clan-view .jp-btn:hover:not(:disabled)::after { color: #04140a; }
#clan-view .jp-btn:active:not(:disabled) { transform: scale(0.97); }
#clan-view .jp-btn:disabled { opacity: 0.45; cursor: default; }
.clan-tag {
  color: #ffcc00;
  font-weight: bold;
  cursor: pointer;
}
.clan-tag:hover { text-decoration: underline; }

.hidden { display: none !important; }

/* ==========================================================================
   ONBOARDING (#login-gate)

   The first thing a new player ever sees. The design rule, stated by the
   owner: DARK and SIMPLE, no noise of any kind -- the menus are the only
   thing that pops. So: a flat near-black ground, no CRT glass over it
   (z-index sits ABOVE the texture (700) and glitch (800) layers, and the
   FILTER's tremble/skew list deliberately excludes this element), no
   scanlines, no sfx hooks, no hue drift. One quiet room, one lit panel.

   Step-based for what's coming: each onboarding phase is an .ob-step
   inside .ob-shell; .is-on shows it. Login is step one.
   ========================================================================== */
#login-gate {
  position: fixed;
  inset: 0;
  background: #060606;
  display: flex;
  align-items: center;
  justify-content: center;
  /* ABOVE the PRESS START cover (#boot-gate, 9500): onboarding comes
     BEFORE press start. A new player signs in, answers the onboarding
     steps (fullscreen prompt first), and only then reaches the boot
     gate. A returning player's auth resolves immediately and this is
     hidden before they ever see it. */
  z-index: 9600;
}
#login-gate.hidden { display: none; }
.ob-shell {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}
.ob-step { display: none; text-align: center; }
.ob-step.is-on { display: block; }
/* THE FIRST PAINT, before a single line of main.js has run. js/bootgate.js
   stamps this on <html> in the head when a login is stored, so the gate's
   opening frame is the waiting step and the login button never flashes.
   ID selectors, so they outrank .is-on until main.js takes the wheel and
   drops the class (obBootGate). */
html.sw-resuming #ob-step-login { display: none; }
html.sw-resuming #ob-step-resume { display: block; }
.ob-logo {
  margin: 0 0 6px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(40px, calc(4 * var(--dvw, 1vw)), 64px);
  letter-spacing: 8px;
  color: #fff;
  text-shadow: 0 0 18px rgba(57, 255, 20, 0.45);
}
.ob-sub {
  margin: 0 0 42px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 6px;
  color: rgba(157, 255, 176, 0.55);
}
/* The one lit thing in the room. Angular plate in the house language,
   and the house springy hover pop -- the menu is what moves, nothing
   else on the screen so much as breathes. */
.ob-btn {
  padding: 18px 46px;
  background: rgba(134, 255, 102, 0.07);
  border: 1px solid rgba(134, 255, 102, 0.45);
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  letter-spacing: 4px;
  color: #d8ffd0;
  cursor: pointer;
  transition:
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    color 0.16s ease-out,
    border-color 0.16s ease-out,
    background 0.16s ease-out,
    filter 0.18s ease-out;
}
.ob-btn:hover, .ob-btn:focus-visible {
  transform: translate(-3px, -3px) scale(1.04);
  color: #fff;
  border-color: #86ff66;
  background: rgba(134, 255, 102, 0.13);
  filter:
    drop-shadow(3px 3px 0 rgba(10, 28, 8, 0.9))
    drop-shadow(3px 3px 0 rgba(10, 28, 8, 0.6))
    drop-shadow(0 0 18px rgba(57, 255, 20, 0.45));
}
.ob-btn:active { transform: translate(0, 0) scale(0.98); filter: none; }
.ob-actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}
/* The secondary path: present, honest, and clearly not the main event. */
.ob-btn-quiet {
  padding: 10px 26px;
  font-size: 14px;
  letter-spacing: 3px;
  color: rgba(157, 255, 176, 0.5);
  border-color: rgba(134, 255, 102, 0.18);
  background: transparent;
}
.ob-btn-quiet:hover, .ob-btn-quiet:focus-visible {
  color: #d8ffd0;
  background: rgba(134, 255, 102, 0.06);
}

/* The waiting rail on #ob-step-resume. Deliberately NOT a spinner: this
   room is flat and quiet by design, so it is one lit segment travelling a
   dim track, in the same green as everything else here. TRANSFORM only --
   the frame-motion rule applies to any looping thing on screen -- and the
   travel is stated in % of the rail so it cannot drift at other widths. */
.ob-load {
  width: 260px;
  max-width: 60vw;
  height: 3px;
  margin: 0 auto;
  overflow: hidden;
  background: rgba(134, 255, 102, 0.12);
}
.ob-load span {
  display: block;
  width: 34%;
  height: 100%;
  background: linear-gradient(90deg, rgba(134, 255, 102, 0), #86ff66 50%, rgba(134, 255, 102, 0));
  animation: ob-load-sweep 1.5s ease-in-out infinite;
}
@keyframes ob-load-sweep {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(294%); }   /* 100/34 of its own width = the full rail */
}
@media (prefers-reduced-motion: reduce) {
  /* Parked mid-rail rather than hidden: the bar is the only thing saying
     "something is still happening". */
  .ob-load span { animation: none; transform: translateX(97%); }
}

.beat-option { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.beat-option audio { width: 100%; height: 32px; }
/* #prod-beat-player is styled with the artist-mode strip -- see THE DOCK'S
   BEAT DECK further down. The naked <audio controls> rules that lived here
   died with the element. */

/* ---------- multiplayer menu (BO2 lobby): one screen per mode ----------
   Left column: the menu list (QUICK PLAY / SERVER BROWSER / BACK) in the
   signature extrusion type. Right column: the lobby panel -- an angular
   HUD plate holding exactly maxPlayers slot rows, BO2 "12 Players (12
   Max)" style. Everything quickplay does happens INSIDE this screen:
   searching sweeps a scanline over the empty slots, match-found ignites
   the rows one by one and runs the pre-game countdown in the panel's
   footer bar. The mode only recolors the accents: producer runs hot red,
   artist runs cool blue, the chassis stays green CRT. */
#screen-mpmenu {
  --mp-accent: #39ff14;
  --mp-accent-soft: rgba(57, 255, 20, 0.5);
  --mp-accent-dim: rgba(57, 255, 20, 0.16);
}
#screen-mpmenu.mp-producer {
  --mp-accent: #ff5a5a;
  --mp-accent-soft: rgba(255, 60, 60, 0.5);
  --mp-accent-dim: rgba(255, 60, 60, 0.14);
}
#screen-mpmenu.mp-artist {
  --mp-accent: #6e96ff;
  --mp-accent-soft: rgba(90, 130, 255, 0.5);
  --mp-accent-dim: rgba(90, 130, 255, 0.14);
}
/* Full-bleed like home/mode-select: the global 700px body column would
   crush a two-half lobby layout into a postage stamp. */
body:has(#screen-mpmenu.active) { max-width: none; padding: 0; }
/* ---------- LOBBY ATMOSPHERE ----------
   The blank half of this screen needed to feel like somewhere you're
   standing, not empty space. Four layers, all decorative and
   click-through, all behind the UI:
     haze    -- a soft pool of light, so the dark isn't flat
     disc    -- one huge record turning slowly, low and to the left,
                doing the job BO2's globe does: an object big enough to
                anchor the frame but too dim to compete with the menu
     dust    -- specks drifting up through the light
     horizon -- a single line far down the frame, which is what makes
                the space read as a ROOM rather than a void
   Deliberately cheap: no canvas, no JS, no per-frame work. Two elements
   animate `transform` and one animates `background-position`, all of
   which the compositor handles on its own. */
#mp-atmos {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}
.mp-atmos-haze {
  position: absolute;
  left: -10%;
  bottom: -30%;
  width: 90%;
  height: 110%;
  background: radial-gradient(ellipse at 50% 50%,
    rgba(57, 255, 20, 0.055), transparent 62%);
}

/* ---- the room ----
   Floor and ceiling laid in perspective, converging on one horizon.
   EVERYTHING here shares that single vanishing point, and that is the
   whole reason it reads as a place. A wireframe tunnel was tried instead
   and it looked like loose shapes scattered on the screen -- its rings
   were a second geometry with its own centre, fighting the room rather
   than belonging to it.
   NOT a grid: the cross-lines are gone, leaving only the lines running
   AWAY from you -- what a polished floor does with overhead light. And
   static; the crawl made it a motion graphic.
   Pushed wide (-60%/+60%) so the vanishing edges leave the frame instead
   of stopping inside it. */
.mp-atmos-floor,
.mp-atmos-ceil {
  position: absolute;
  left: -60%;
  right: -60%;
  /* Measured, not guessed: at 62% tall the plane projected to a ~70px
     strip -- technically in perspective, far too shallow to read as a
     surface going anywhere. 320% with a long perspective is what carries
     the convergence up to the horizon. */
  height: 320%;
  background-image:
    repeating-linear-gradient(to right,
      rgba(134, 255, 102, 0.055) 0 2px,
      transparent 2px 210px),
    repeating-linear-gradient(to right,
      rgba(134, 255, 102, 0.03) 0 1px,
      transparent 1px 97px);
}
.mp-atmos-floor {
  bottom: 0;
  transform: perspective(900px) rotateX(50deg);
  transform-origin: bottom center;
  -webkit-mask-image: linear-gradient(to top, #000 2%, rgba(0, 0, 0, 0.4) 34%, transparent 72%);
  mask-image: linear-gradient(to top, #000 2%, rgba(0, 0, 0, 0.4) 34%, transparent 72%);
}
/* Dimmer than the floor -- ceilings catch less light, and that asymmetry
   is what stops it reading as a mirrored tunnel. */
.mp-atmos-ceil {
  top: 0;
  opacity: 0.55;
  transform: perspective(900px) rotateX(-50deg);
  transform-origin: top center;
  -webkit-mask-image: linear-gradient(to bottom, #000 2%, rgba(0, 0, 0, 0.3) 32%, transparent 68%);
  mask-image: linear-gradient(to bottom, #000 2%, rgba(0, 0, 0, 0.3) 32%, transparent 68%);
}
/* A wash of light lying at your feet, so the near edge of the room has a
   source rather than just being brighter. */
.mp-atmos-floor::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 26%;
  background: linear-gradient(to top, rgba(57, 255, 20, 0.05), transparent);
}

/* (Vertical columns lived here, and a horizon line across the middle.
   Both removed: crossing each other in the empty centre of the screen
   they read as a stray grid floating in mid-air, with nothing around
   them to belong to. The floor and ceiling already converge on that
   horizon -- the space doesn't need it drawn in.) */

/* Distance fog sitting on the horizon. Without this the two planes meet
   at a visible seam; with it they disappear into the far end of the room,
   which is what actually sells the size. */
.mp-atmos-fog {
  position: absolute;
  left: 0;
  right: 0;
  top: 33%;
  height: 26%;
  background: linear-gradient(to bottom,
    transparent,
    rgba(10, 30, 14, 0.55) 42%,
    rgba(10, 30, 14, 0.55) 58%,
    transparent);
}
/* (A slowly-turning record used to hang at the far end here. Removed --
   a big soft circle in the corner read as a smudge rather than an object,
   and nothing about it looked built.) */

/* Specks drifting up through the light. One element, two gradient layers,
   moved by background-position -- no particle system. */
.mp-atmos-dust {
  position: absolute;
  inset: -10% 0;
  background-image:
    radial-gradient(circle, rgba(180, 255, 160, 0.5) 0.9px, transparent 1.4px),
    radial-gradient(circle, rgba(134, 255, 102, 0.35) 0.7px, transparent 1.2px);
  background-size: 340px 300px, 210px 260px;
  opacity: 0.5;
  animation: mp-atmos-drift 70s linear infinite;
}
@keyframes mp-atmos-drift {
  to { background-position: 90px -300px, -70px -260px; }
}
/* The real UI rides above all of it. */
#mp-wrap { position: relative; z-index: 1; }

/* The whole viewport, split in two: mode title + menu own the left,
   the lobby panel owns the right. */
#mp-wrap {
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  gap: calc(5 * var(--dvw, 1vw));
  width: 100%;
  min-height: calc(100 * var(--dvh, 1vh));
  padding: clamp(32px, calc(5 * var(--dvh, 1vh)), 80px) calc(5 * var(--dvw, 1vw)) calc(4 * var(--dvh, 1vh));
}
#mp-left {
  flex: 0 0 44%;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* Was a flat calc(6 * var(--dvh, 1vh)). The challenges board is a tall element at the bottom
     of this column, so on a 1080p screen the old spacing pushed it off
     the bottom edge -- the empty space between blocks gives before the
     content does. */
  gap: calc(2.4 * var(--dvh, 1vh));
}

/* Header: huge mode name, tagline riding its baseline, katakana floated
   right, then an angular rule that tapers off -- the BO2 title strip
   translated into the house language. */
#mp-head { position: relative; }
#mp-head-main { display: flex; align-items: baseline; gap: 22px; flex-wrap: wrap; }
#mp-mode-name {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  /* Sized to fit the LONGEST gamemode name on one line. The headline now
     carries the gamemode (BEAT SHOWCASE / SAMPLE FLIP / SONG WARS), and at
     the old calc(7 * var(--dvw, 1vw)) "BEAT SHOWCASE" wrapped to two lines -- which grew the header
     by 91px and shoved the whole menu down the screen every time you switched
     to it. The swap has to change what the title SAYS without changing the
     space it occupies. */
  font-size: clamp(38px, calc(4.4 * var(--dvw, 1vw)), 82px);
  letter-spacing: 6px;
  /* One line, always -- this is what actually holds the layout still. */
  white-space: nowrap;
  /* `clip`, not `hidden`: hidden would guillotine the glow in a flat
     rectangle at the text edge. clip is the one value in that family that
     accepts an overflow-clip-margin. */
  overflow: clip;
  overflow-clip-margin: 48px;
  color: var(--mp-accent);
  text-shadow: 0 0 12px var(--mp-accent-soft), 0 0 38px var(--mp-accent-dim);
}
#mp-mode-tagline {
  font-size: clamp(17px, calc(1.4 * var(--dvw, 1vw)), 23px);
  letter-spacing: 5px;
  color: #b9dcb0;
}
#mp-mode-kata {
  position: absolute;
  right: 0;
  top: 10px;
  font-size: clamp(15px, calc(1.2 * var(--dvw, 1vw)), 20px);
  letter-spacing: 7px;
  color: #5a8f50;
}
#mp-head-rule {
  margin-top: 14px;
  height: 3px;
  background: linear-gradient(to right,
    var(--mp-accent) 0,
    var(--mp-accent) 38%,
    var(--mp-accent-dim) 62%,
    transparent 100%);
  box-shadow: 0 0 12px var(--mp-accent-dim);
}

#mp-menu {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: calc(2.4 * var(--dvh, 1vh));
}

/* Menu items: left-aligned take on the home-menu extrusion language.
   On hover the title pops off the screen and the katakana ghost fades in
   (the old ▸ hover arrow is gone -- user call). */
.mp-item {
  position: relative;
  display: flex;
  align-items: baseline;
  gap: 16px;
  background: none;
  border: none;
  padding: 10px 0;
  cursor: pointer;
  font-size: clamp(34px, calc(3.1 * var(--dvw, 1vw)), 58px);
}
.mp-item-label {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  letter-spacing: 2px;
  color: #39ff14;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.7), 0 0 14px rgba(57, 255, 20, 0.35);
  transition:
    text-shadow 0.15s,
    color 0.15s,
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mp-item-kata {
  font-size: 17px;
  letter-spacing: 4px;
  color: #5a8f50;
  opacity: 0;
  transition: opacity 0.25s 0.08s;
}
/* :focus-visible, not :focus. Plain :focus fires on a MOUSE click too, and
   since it shares this exact hover styling, clicking QUICK PLAY (to open
   the gamemode panel) then clicking elsewhere to close it left the button
   stuck glowing like the cursor was still over it -- because it was, in
   the browser's eyes, still "focused". :focus-visible only lights it for
   real keyboard navigation (Tab), which is what this was meant to serve. */
.mp-item:hover .mp-item-kata,
.mp-item:focus-visible .mp-item-kata { opacity: 1; }
/* The signature pop: lift up-left, extrusion steps trail down-right. */
.mp-item:hover .mp-item-label,
.mp-item:focus-visible .mp-item-label {
  transform: translate(-3px, -3px) scale(1.06);
  color: var(--mp-accent);
  text-shadow:
    1px 1px 0 rgba(24, 140, 10, 0.95),
    2px 2px 0 rgba(19, 112, 8, 0.85),
    3px 3px 0 rgba(15, 88, 6, 0.75),
    4px 4px 0 rgba(11, 64, 5, 0.6),
    5px 5px 0 rgba(8, 44, 3, 0.45),
    0 0 10px var(--mp-accent-soft),
    0 0 26px var(--mp-accent-dim);
}
/* Press: squish flat, same slam as everywhere else in the app. */
.mp-item:active .mp-item-label {
  transform: translate(0, 0) scale(0.94);
  text-shadow: 0 0 8px var(--mp-accent-soft);
  transition-duration: 0.06s;
}
.mp-item-dim .mp-item-label { color: #4f7a48; text-shadow: none; font-size: 0.7em; }

/* LEAVE LOBBY reads red -- it's the way out, not another green action.
   Muted at rest like the rest of the dim items, hot on hover. */
#btn-mp-leave .mp-item-label,
.mp-item-danger .mp-item-label { color: #a85454; }
#btn-mp-leave:hover .mp-item-label,
#btn-mp-leave:focus .mp-item-label,
.mp-item-danger:hover .mp-item-label,
.mp-item-danger:focus .mp-item-label {
  color: #ff5a5a;
  text-shadow:
    1px 1px 0 rgba(140, 24, 24, 0.95),
    2px 2px 0 rgba(112, 18, 18, 0.85),
    3px 3px 0 rgba(88, 14, 14, 0.75),
    4px 4px 0 rgba(64, 10, 10, 0.6),
    5px 5px 0 rgba(44, 6, 6, 0.45),
    0 0 10px rgba(255, 90, 90, 0.8),
    0 0 26px rgba(255, 60, 60, 0.5);
}
/* .mp-item-danger keeps full size -- .mp-item-dim shrank BACK to 0.7em to
   push it down the hierarchy, but this is a real exit and should read at
   the same weight as the actions above it. */

/* REJOIN ROUND reads gold and breathes. While you are sat out this is the
   only item on the menu you would actually want to press -- every other
   action is hidden -- so it should pull the eye instead of sitting in the
   same green rank as everything else. Slow build and cool, never a blink:
   the glow swells and falls, it does not switch. */
.mp-item-hot .mp-item-label {
  color: #ffcc00;
  text-shadow: 0 0 8px rgba(255, 204, 0, 0.75), 0 0 18px rgba(255, 204, 0, 0.3);
  animation: mp-hot-breathe 2.6s ease-in-out infinite;
}
.mp-item-hot .mp-item-kata { color: #a8863a; }
@keyframes mp-hot-breathe {
  0%, 100% {
    text-shadow: 0 0 8px rgba(255, 204, 0, 0.75), 0 0 18px rgba(255, 204, 0, 0.3);
  }
  50% {
    text-shadow: 0 0 14px rgba(255, 204, 0, 0.95), 0 0 30px rgba(255, 204, 0, 0.5);
  }
}
@media (prefers-reduced-motion: reduce) {
  .mp-item-hot .mp-item-label { animation: none; }
}

/* ---------- QUICK PLAY gamemode panel ----------
   Drops open under the QUICK PLAY item instead of the click firing a
   search straight away. NOT toggled with .hidden: that class is
   `!important; display:none`, which kills a transition dead rather than
   playing it. Closed is max-height:0 + opacity:0 on the panel itself, so
   it can animate open and shut. */
/* The panel itself is now the plate -- SELECT GAMEMODE and the chip both
   sit inside one bordered card instead of floating loose on the screen's
   own background. Same left-accent-bar language as #mp-search-readout
   just below it on this screen, so the two read as the same kind of
   object rather than two different UI languages sharing a menu. */
/* One gamemode chip. Same hover-pop language as .mp-item (lift + stepped
   extrusion trail) so it reads as part of the same menu, not a different
   widget dropped in. Checkbox glyph does double duty as the toggle state. */

/* Nothing selected: the confirm has nothing to confirm. Visibly dead
   rather than silently ignoring the click. */

/* Searching readout: lives under the menu, in BO2's "Time remaining"
   spot. The dots build one at a time -- searching is a process. */
#mp-search-readout {
  margin-top: calc(3 * var(--dvh, 1vh));
  padding: 16px 22px;
  border-left: 4px solid var(--mp-accent);
  background: var(--mp-accent-dim);
  font-size: 18px;
  letter-spacing: 3px;
  color: #b9dcb0;
}
#mp-search-elapsed {
  margin-top: 6px;
  font-family: 'Courier New', monospace;
  font-size: 34px;
  color: var(--mp-accent);
  text-shadow: 0 0 8px var(--mp-accent-soft);
}
.mp-dots::after {
  content: '';
  animation: mp-dots 1.2s steps(4, end) infinite;
}
@keyframes mp-dots {
  0% { content: ''; }
  25% { content: '.'; }
  50% { content: '..'; }
  75% { content: '...'; }
}

/* The lobby panel: angular plate, cut corners top-right/bottom-left,
   faked border via two stacked clip-path layers (house technique). */
#mp-lobby-panel {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  clip-path: polygon(0 0, calc(100% - 34px) 0, 100% 34px, 100% 100%, 34px 100%, 0 calc(100% - 34px));
  background: linear-gradient(160deg, var(--mp-accent-soft), rgba(134, 255, 102, 0.25));
}
#mp-lobby-panel::before {
  content: '';
  position: absolute;
  inset: 3px;
  clip-path: polygon(0 0, calc(100% - 32px) 0, 100% 32px, 100% 100%, 32px 100%, 0 calc(100% - 32px));
  background: rgba(6, 12, 6, 0.92);
}
#mp-panel-head {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 22px 30px 16px;
  border-bottom: 1px solid var(--mp-accent-dim);
}
#mp-panel-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(28px, calc(2.4 * var(--dvw, 1vw)), 42px);
  letter-spacing: 4px;
  color: var(--mp-accent);
  text-shadow: 0 0 8px var(--mp-accent-soft);
}
.mp-panel-kata { font-family: 'Courier New', monospace; font-size: 17px; letter-spacing: 3px; color: #5a8f50; }
#mp-player-count { font-size: clamp(17px, calc(1.4 * var(--dvw, 1vw)), 22px); letter-spacing: 2px; color: #b9dcb0; white-space: nowrap; }

/* Slot rows. Empty = dim dashed placeholders; filled = live players. */
#mp-slots {
  position: relative;
  list-style: none;
  margin: 0;
  padding: calc(2 * var(--dvh, 1vh)) 26px;
  display: flex;
  flex-direction: column;
  justify-content: space-evenly;
  gap: calc(1.2 * var(--dvh, 1vh));
  /* The slot list is what soaks up the panel's full height -- seven rows
     spread evenly over the whole right side of the screen. */
  flex: 1 1 auto;
  min-height: 0;
}
/* THE FACE IS THE ROW. At 28px a person's photo was a smudge, and the
   roster is the one place you actually look at who you are playing with.
   The row grows to fit the face rather than the face being squeezed
   into the row -- and empty slots grow with it, or the panel reads as a
   ladder with uneven rungs. */
.mp-slot {
  display: flex;
  align-items: center;
  gap: 16px;
  min-height: 78px;
  padding: 8px 22px;
  font-size: clamp(20px, calc(1.7 * var(--dvw, 1vw)), 27px);
  letter-spacing: 1px;
}
.mp-slot-empty {
  justify-content: center;
  color: rgba(134, 255, 102, 0.22);
  border: 1px dashed rgba(134, 255, 102, 0.14);
  font-size: clamp(15px, calc(1.3 * var(--dvw, 1vw)), 19px);
  letter-spacing: 4px;
}
.mp-slot-filled {
  color: #d8ffd0;
  border: 1px solid var(--mp-accent-dim);
  background: linear-gradient(to right, var(--mp-accent-dim), transparent 55%);
}
/* flex: 0 0 auto or a long name squeezes the face out of shape. */
.mp-slot .player-icon {
  flex: 0 0 auto;
  width: 56px;
  height: 56px;
  line-height: 56px;
  font-size: 30px;
  border: 2px solid rgba(134, 255, 102, 0.3);
  /* SQUARE in the roster, not the site-wide circle: profile banners will
     eventually wrap this frame, and a banner wants a card-shaped plate to
     hug. The img inside inherits this radius (see .player-icon img). */
  border-radius: 6px;
}
/* The rank column: just the number, CoD lobby style, left of the face.
   Fixed width so a lvl 7 and a lvl 30 line their faces up at the same x. */
.mp-slot-lvl {
  flex: 0 0 auto;
  min-width: 2.2ch;
  text-align: center;
  font-size: clamp(17px, calc(1.4 * var(--dvw, 1vw)), 22px);
  letter-spacing: 1px;
  color: rgba(134, 255, 102, 0.8);
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.4);
}
.mp-slot-name {
  /* Vertically centers the name's own line box regardless of the
     equipped font's ascent/descent -- a decorative font's metrics can
     put its glyphs off-center inside a plain line-height box even when
     the box itself sits centered in the row. */
  display: flex;
  align-items: center;
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* Player names wear the house display font everywhere (user call). */
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  /* Fallback before mpFitSlotName's JS sizing runs (or if it can't).
     This MUST match mpFitSlotName's own max (44px), not just "something
     bigger than the row's 27px inherited size" -- when it capped at 34px
     the name painted at 34, then the fit set it to 44 the moment it ran,
     and that 34->44 pop was visible on every single lobby open. The fit
     only ever shrinks from here now, and a name short enough to keep the
     max (most of them) never changes size at all. */
  font-size: 44px;
  letter-spacing: 2px;
}
.mp-slot-ready {
  font-size: clamp(14px, calc(1.1 * var(--dvw, 1vw)), 17px);
  letter-spacing: 4px;
  color: #39ff14;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.7);
}
.mp-slot-away { color: #997722; text-shadow: none; }
.mp-slot-notready { color: #5c6862; text-shadow: none; }
/* Same badge/mute language as the floating voice-bar chips (see
   .voice-mute-badge) -- self-muted or locally-muted-by-you, toggled by
   voice.js after every roster rebuild. */
.mp-slot-mute-badge { font-size: 15px; color: #ff8a8a; }
.mp-slot-mute-badge.hidden { display: none; }
/* Your READY UP item stays lit in the accent color while you're armed --
   the menu itself tells you your own state at a glance. */
.mp-item-armed .mp-item-label {
  color: var(--mp-accent);
  text-shadow: 0 0 8px var(--mp-accent-soft), 0 0 20px var(--mp-accent-dim);
}
/* Match-found reveal: rows power on one after another down the panel --
   the ignition radiates from the header, top row first. One-shot class
   (mp-ignite), removed right after so roster rebuilds don't replay it. */
.mp-ignite .mp-slot-filled {
  animation: mp-slot-ignite 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--slot-i, 0) * 70ms);
}
@keyframes mp-slot-ignite {
  from { opacity: 0; transform: translateX(-14px); filter: brightness(2.4); }
  60% { filter: brightness(1.6); }
  to { opacity: 1; transform: translateX(0); filter: brightness(1); }
}

/* Searching scanline: a beam sweeping down the slot area on loop. */
#mp-scanline {
  position: absolute;
  left: 12px;
  right: 12px;
  top: 80px;
  height: 2px;
  background: var(--mp-accent);
  box-shadow: 0 0 10px var(--mp-accent-soft), 0 0 26px var(--mp-accent-dim);
  opacity: 0;
  pointer-events: none;
}
.mp-searching #mp-scanline {
  opacity: 1;
  animation: mp-scan 2s linear infinite;
}
/* Sweeps the panel's FULL height. Transform in vh, not `top` between px
   and calc(%): px<->calc pairs can refuse to interpolate (a discrete
   mid-loop jump instead of a sweep), and the panel is viewport-tall now,
   so vh distance tracks its real height on any screen. Overshoot is
   harmless -- the panel's clip-path swallows it while opacity fades. */
@keyframes mp-scan {
  from { transform: translateY(0); opacity: 0.9; }
  85% { opacity: 0.9; }
  to { transform: translateY(calc(76 * var(--dvh, 1vh))); opacity: 0; }
}

/* Footer: idle/searching note, or MATCH FOUND bar with the countdown. */
#mp-panel-note {
  position: relative;
  padding: 18px 30px 24px;
  font-size: clamp(15px, calc(1.3 * var(--dvw, 1vw)), 19px);
  letter-spacing: 4px;
  color: #5a8f50;
  border-top: 1px dashed var(--mp-accent-dim);
}
.mp-searching #mp-panel-note { color: var(--mp-accent); text-shadow: 0 0 8px var(--mp-accent-dim); }
/* You're the holdout: the footer turns hot red and pulses -- unmissable. */
#mp-panel-note.mp-note-warn {
  color: #ff5a5a;
  text-shadow: 0 0 10px rgba(255, 60, 60, 0.6);
  animation: mp-found-pulse 0.9s ease-in-out infinite;
}
#mp-found-bar {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 30px 26px;
  border-top: 1px solid var(--mp-accent-dim);
  animation: mp-found-flash 0.45s ease-out 1;
}
@keyframes mp-found-flash {
  from { background: var(--mp-accent-soft); }
  to { background: transparent; }
}
#mp-found-info { display: flex; flex-direction: column; gap: 6px; }
#mp-found-flash {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(32px, calc(2.8 * var(--dvw, 1vw)), 50px);
  letter-spacing: 5px;
  color: var(--mp-accent);
  text-shadow: 0 0 10px var(--mp-accent-soft), 0 0 26px var(--mp-accent-dim);
  animation: mp-found-pulse 1s ease-in-out infinite;
}
@keyframes mp-found-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}
#mp-found-mode { font-size: clamp(16px, calc(1.3 * var(--dvw, 1vw)), 20px); letter-spacing: 4px; color: #b9dcb0; }
#mp-count-box { display: flex; align-items: baseline; gap: 20px; }
#mp-count-label { font-size: clamp(15px, calc(1.3 * var(--dvw, 1vw)), 19px); letter-spacing: 4px; color: #5a8f50; }
#mp-countdown {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(64px, calc(6 * var(--dvh, 1vh)), 96px);
  line-height: 1;
  min-width: 70px;
  text-align: center;
  color: var(--mp-accent);
  text-shadow: 0 0 12px var(--mp-accent-soft), 0 0 34px var(--mp-accent-dim);
}
/* Each tick slams the digit in from slightly oversized -- the same
   press-squish energy, once a second. */
#mp-countdown.mp-tick { animation: mp-tick 0.28s cubic-bezier(0.22, 1, 0.36, 1) 1; }
@keyframes mp-tick {
  from { transform: scale(1.45); filter: brightness(2); }
  to { transform: scale(1); filter: brightness(1); }
}

/* Stack the halves on narrow screens; the panel matters more than the
   menu once you're searching. */
@media (max-width: 900px) {
  #mp-wrap { flex-direction: column; gap: calc(4 * var(--dvh, 1vh)); }
  #mp-left { flex: none; }
  #mp-lobby-panel { min-height: calc(55 * var(--dvh, 1vh)); }
}

/* ---------- right-click player menu ----------
   A pocket cousin of the store's featured plate: same angular language
   (sheared top-right, notched right edge, chamfered corners), outline
   drawn by hard drop-shadows so it traces the clipped silhouette, glowing
   accent bar riding the notch. Floats at the cursor, above the CRT glass. */
/* The regular right-click player menu: a pocket cousin of the store's
   featured plate (same angular language -- sheared top-right, notched
   right edge, chamfered corners), outline drawn by hard drop-shadows so
   it traces the clipped silhouette, glowing accent bar riding the notch.
   Floats at the cursor. VIEW PROFILE here opens the big profile card. */
#mp-ctx {
  position: fixed;
  z-index: 950;
  min-width: 280px;
  padding: 16px 20px 14px;
  background: rgba(8, 16, 8, 0.97);
  clip-path: polygon(
    0% 8%, 6% 0%, 68% 0%, 100% 12%,
    100% 46%, 96% 50%, 96% 64%, 100% 68%,
    100% 92%, 92% 100%, 0% 100%
  );
  filter:
    drop-shadow(2px 0 0 rgba(134, 255, 102, 0.6))
    drop-shadow(-2px 0 0 rgba(134, 255, 102, 0.6))
    drop-shadow(0 2px 0 rgba(134, 255, 102, 0.6))
    drop-shadow(0 -2px 0 rgba(134, 255, 102, 0.6))
    drop-shadow(0 0 18px rgba(57, 255, 20, 0.35));
}
/* Glowing accent bar on the notched right edge, like the featured card. */
#mp-ctx::before {
  content: '';
  position: absolute;
  right: 6px;
  top: 48%;
  width: 4px;
  height: 18%;
  background: rgba(134, 255, 102, 0.8);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.8);
}
/* Spawn: springs open from the cursor corner -- quick, never instant. */
#mp-ctx.ctx-pop {
  transform-origin: top left;
  animation: ctx-pop 0.16s cubic-bezier(0.34, 1.5, 0.64, 1) 1;
}
@keyframes ctx-pop {
  from { transform: scale(0.7); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
#mp-ctx-head {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding-bottom: 10px;
  margin-bottom: 8px;
  border-bottom: 1px solid rgba(134, 255, 102, 0.2);
}
#mp-ctx-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  letter-spacing: 2px;
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.5);
  overflow: clip;
  overflow-clip-margin: 10px;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 280px;
}
#mp-ctx-sub {
  font-size: 13px;
  letter-spacing: 3px;
  color: #5a8f50;
}
#mp-ctx button {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 9px 8px;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: #b9dcb0;
  transition: background 0.12s, color 0.12s, padding-left 0.15s;
}
#mp-ctx button:hover {
  background: rgba(134, 255, 102, 0.12);
  color: #86ff66;
  padding-left: 14px;
}
#mp-ctx-kick { color: #ff8a8a; }
#mp-ctx-kick:hover { background: rgba(255, 60, 60, 0.14); color: #ff5a5a; }
/* Friend-row items. Unadd is destructive, so it reads like the kick. */
#mp-ctx-unfriend { color: #ff8a8a; }
#mp-ctx-unfriend:hover { background: rgba(255, 60, 60, 0.14); color: #ff5a5a; }
#mp-ctx-partykick { color: #ff8a8a; }
#mp-ctx-partykick:hover { background: rgba(255, 60, 60, 0.14); color: #ff5a5a; }
/* SEND MESSAGE is shown but inert until DMs exist -- visible-but-dimmed reads
   as "coming", where hiding it would read as missing. */
/* Admin rows read as a different authority than the player rows above
   them -- the discipline red, not the house green. */
#mp-ctx button.ctx-admin { color: #ff8a8a; }
#mp-ctx button.ctx-admin:hover { background: rgba(80, 18, 18, 0.85); color: #ffd9d9; }

/* In-game admin room controls: a small fixed strip, bottom-right, above the
   game screens but below modals. Only ever visible to admin accounts. */
#admin-hud {
  position: fixed;
  right: 14px;
  bottom: 14px;
  z-index: 220;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: rgba(20, 4, 6, 0.88);
  border: 1px solid rgba(255, 96, 96, 0.5);
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
#admin-hud.hidden { display: none; }
#ah-tag {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 3px;
  color: #ff8a8a;
  text-shadow: 0 0 10px rgba(255, 80, 80, 0.6);
}
#admin-hud button {
  padding: 7px 12px 5px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #ffd9d9;
  background: rgba(60, 12, 14, 0.7);
  border: 1px solid rgba(255, 96, 96, 0.4);
  cursor: pointer;
}
#admin-hud button:hover { background: rgba(120, 24, 28, 0.9); color: #fff; }
#admin-hud button.hidden { display: none; }

#mp-ctx button:disabled {
  opacity: 0.34;
  cursor: default;
}
#mp-ctx button:disabled:hover { background: none; color: inherit; }
#mp-ctx button:disabled::after {
  content: ' — SOON';
  font-size: 0.8em;
  opacity: 0.7;
}
/* Mute button reads red while ITS action would un-mute -- i.e. that mic
   is currently silent -- so the plate always shows the current state,
   not just the next click's verb. */
#mp-ctx-mute.mp-ctx-muted-state { color: #ff8a8a; }
#mp-ctx-mute.mp-ctx-muted-state:hover { background: rgba(255, 60, 60, 0.14); color: #ff5a5a; }

/* ---- shared profile-card parts (used by the big profile view) ---- */
/* socials: icons only, one per platform the player actually filled in */
.ctx-social {
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: rgba(20, 44, 14, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.35);
  clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.16s ease-out;
}
/* `color` as well as `fill`: the Genius glyph draws its ring and inner
   crescent with stroke="currentColor" (they're arcs, not solid shapes). */
.ctx-social svg { width: 19px; height: 19px; fill: #9fe88a; color: #9fe88a; }
.ctx-social:hover, .ctx-social:focus-visible {
  transform: translate(-2px, -2px) scale(1.12);
  background: rgba(40, 88, 26, 0.9);
}
.ctx-social:hover svg { fill: #eaffe3; color: #eaffe3; }

/* the big icon actions (friend / message / party) */
.ctx-icon-btn {
  position: relative;
  width: 52px;
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: rgba(20, 44, 14, 0.55);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.45);
  clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.16s ease-out;
}
.ctx-icon-btn svg { width: 28px; height: 28px; fill: #9fe88a; transition: fill 0.16s ease-out; }
/* drop-shadow, not box-shadow: this element has a clip-path cutting its
   top-left and bottom-right corners, and clip-path erases a same-element
   box-shadow exactly at the corners it cuts -- so the offset "3px 3px" slab
   shadow and the glow both vanished right where the diagonal cut is,
   reading as the border disappearing there on hover. drop-shadow is cast
   from the rendered (already-clipped) shape's own silhouette, so it wraps
   the cut corner instead of being erased by it. */
.ctx-icon-btn:hover, .ctx-icon-btn:focus-visible {
  transform: translate(-3px, -3px) scale(1.08);
  background: rgba(40, 88, 26, 0.9);
  filter:
    drop-shadow(2px 2px 0 rgba(20, 60, 12, 0.8))
    drop-shadow(0 0 9px rgba(57, 255, 20, 0.4));
}
.ctx-icon-btn:hover svg { fill: #eaffe3; }
.ctx-icon-btn:active { transform: scale(0.94); }
/* Hover label for the action icons: a bar that ROLLS OUT to the right of
   whichever icon you're on. It is NOT a ::after on the button -- the
   plate's angular clip-path clips every descendant, which sliced the
   label off the moment it reached the plate's edge. It's a single shared
   element parked outside the plate, moved + filled by JS on hover.
   Rolls open by growing its width (not scaleX, which would squash the
   letters); overflow:hidden wipes the text in left-to-right. */
#pv-tip {
  position: absolute;
  z-index: 3;
  padding: 7px 0 6px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  white-space: nowrap;
  color: #eaffe3;
  background: rgba(6, 18, 4, 0.97);
  border: 1px solid rgba(134, 255, 102, 0.55);
  box-shadow: 0 0 16px rgba(57, 255, 20, 0.3);
  clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
  pointer-events: none;
  transform: translateY(-50%);
  max-width: 0;
  overflow: hidden;
  opacity: 0;
  transition:
    max-width 0.24s cubic-bezier(0.22, 1, 0.36, 1),
    padding 0.24s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.1s ease-out;
}
#pv-tip.on {
  max-width: 300px;
  padding-left: 14px;
  padding-right: 14px;
  opacity: 1;
}

/* Profile card name: same house display font as every other player name --
   see --player-name-font at :root, the one place that font is now named. */
#pv-name {
  font-family: var(--player-name-font);
  font-size: 30px;
  letter-spacing: 3px;
  color: #86ff66;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
}

/* ---------- SERVER BROWSER: massive green terminal ----------
   Full-bleed screen (like home/mode-select/mp-menu): a huge glass panel
   floating over a live circuit-board backdrop, corner HUD brackets, a
   slow scan beam, a blinking path cursor in the titlebar -- the "giant
   terminal" reading leans on the same green-CRT language as the rest of
   the app, just turned up to the actual literal terminal. */
body:has(#screen-lobbylist.active) { max-width: none; padding: 0; }
/* The draw is full-bleed like the two above. Without this it inherited the
   body's 700px cap and rendered as a narrow column -- while the hand-off
   animation, which paints the pane outside that constraint, showed it at
   full width. That mismatch is why the layout appeared to change as the
   transition finished. */
body:has(#screen-roulette.active) { max-width: none; padding: 0; }
#screen-lobbylist {
  position: relative;
  min-height: calc(100 * var(--dvh, 1vh));
  overflow: hidden;
  background: radial-gradient(ellipse at 50% 20%, rgba(20, 50, 20, 0.5), rgba(2, 6, 3, 0.98) 65%);
}

/* The glass panel itself, floating centered on the screen -- its
   background is the SAME live circuit-board renderer the store shelves
   use (see wireCircuitBoard/.circuit-canvas in main.js): builds outward
   from a click, dissolves the old wiring away radially as the new board
   etches in, cursor spotlight, the works. .circuit-canvas is prepended
   into #term-frame by circuitState() the first time it's touched -- no
   extra markup needed here. */
#term-wrap {
  position: relative;
  z-index: 1;
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(5 * var(--dvh, 1vh)) calc(4 * var(--dvw, 1vw));
}
#term-frame {
  position: relative;
  width: min(1400px, calc(92 * var(--dvw, 1vw)));
  height: min(900px, calc(86 * var(--dvh, 1vh)));
  display: flex;
  flex-direction: column;
  background: rgba(6, 16, 9, 0.86);
  border: 1px solid rgba(134, 255, 102, 0.35);
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.6),
    0 0 30px rgba(57, 255, 20, 0.07),
    inset 0 0 40px rgba(57, 255, 20, 0.03);
  backdrop-filter: blur(1.5px);
  overflow: hidden;
}
/* HUD corner brackets -- the "targeting scope" language that reads as
   terminal/tactical framing at a glance. */
.term-corner {
  position: absolute;
  width: 22px;
  height: 22px;
  z-index: 3;
  pointer-events: none;
  border-color: #86ff66;
  filter: drop-shadow(0 0 3px rgba(57, 255, 20, 0.35));
}
.tc-tl { top: -1px; left: -1px; border-top: 2px solid; border-left: 2px solid; }
.tc-tr { top: -1px; right: -1px; border-top: 2px solid; border-right: 2px solid; }
.tc-bl { bottom: -1px; left: -1px; border-bottom: 2px solid; border-left: 2px solid; }
.tc-br { bottom: -1px; right: -1px; border-bottom: 2px solid; border-right: 2px solid; }

/* Slow scan beam sweeping the whole panel top to bottom, on loop --
   ambient "this thing is live" motion, independent of any load state. */
#term-scan {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(to right, transparent, rgba(134, 255, 102, 0.3), transparent);
  box-shadow: 0 0 7px rgba(57, 255, 20, 0.25);
  animation: term-scan-sweep 5s ease-in-out infinite;
}
@keyframes term-scan-sweep {
  0%   { top: 0%; opacity: 0; }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { top: 100%; opacity: 0; }
}

#term-titlebar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 22px 30px 18px;
  border-bottom: 1px solid rgba(134, 255, 102, 0.22);
  font-family: 'Courier New', monospace;
  font-size: clamp(22px, calc(2.1 * var(--dvw, 1vw)), 30px);
  letter-spacing: 1px;
  color: #86ff66;
  /* Glow pulled WAY back across this screen -- it was a hot core plus a
     wide bloom on every element at once, which turned the whole panel
     into a haze. One soft halo is enough to read as neon. */
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.35);
}
.term-path-dim { color: #4d8f44; }
#term-path-mode { color: #39ff14; }
#term-cursor {
  color: #39ff14;
  animation: term-cursor-blink 1s step-end infinite;
}
@keyframes term-cursor-blink { 50% { opacity: 0; } }

#term-toolbar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 26px;
  padding: 18px 30px;
  border-bottom: 1px solid rgba(134, 255, 102, 0.22);
}
.term-btn {
  background: none;
  border: none;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  font-size: clamp(19px, calc(1.7 * var(--dvw, 1vw)), 24px);
  letter-spacing: 1px;
  color: #39ff14;
  text-shadow: 0 0 5px rgba(57, 255, 20, 0.35);
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), text-shadow 0.15s, color 0.15s;
}
.term-btn:hover { transform: translate(-2px, -2px) scale(1.05); text-shadow: 0 0 8px rgba(57, 255, 20, 0.6); }
.term-btn:active { transform: translate(0, 0) scale(0.95); transition-duration: 0.06s; }
.term-btn-dim { color: #5a8f50; text-shadow: none; }
.term-btn-dim:hover { color: #86ff66; text-shadow: 0 0 6px rgba(57, 255, 20, 0.4); }

/* NEW SERVER: the primary action on this screen, so it's the biggest
   thing in the bar and the only element still allowed to move on its
   own. The sweep is a light passing over the text every few seconds --
   enough to catch the eye on an empty browser without adding more glow. */
.term-btn-primary {
  position: relative;
  font-size: clamp(23px, calc(2.1 * var(--dvw, 1vw)), 30px);
  padding: 4px 2px;
  overflow: hidden;
}
.term-btn-primary::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 42%, rgba(180, 255, 160, 0.42) 50%, transparent 58%);
  transform: translateX(-160%);
  animation: term-primary-sweep 4.5s ease-in-out infinite;
  pointer-events: none;
}
@keyframes term-primary-sweep {
  0%, 72% { transform: translateX(-160%); }
  92%, 100% { transform: translateX(160%); }
}
/* BACK is the way OUT of this screen, so it's red like every other
   backing-out control in the game. */
.term-btn-back {
  color: #d68a8a;
  text-shadow: 0 0 5px rgba(255, 70, 70, 0.3);
}
.term-btn-back:hover { color: #ffd9d9; text-shadow: 0 0 8px rgba(255, 80, 80, 0.6); }

#term-count {
  margin-left: auto;
  font-family: 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 2px;
  color: #6da762;
}

#term-columns {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: 56px minmax(0, 2.6fr) minmax(0, 1.4fr) 130px 160px 130px;
  gap: 12px;
  padding: 14px 30px;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 2px;
  color: #4d8f44;
  border-bottom: 1px dashed rgba(134, 255, 102, 0.18);
}
.term-col-players, .term-col-genre { text-align: center; }

/* ---- sortable headers ----
   Real buttons, so they're keyboard-reachable; styled to look like the
   plain labels they replaced until you hover one. */
.term-sort {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 0;
  background: none;
  border: none;
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  cursor: pointer;
  transition: color 0.14s ease-out;
}
.term-col-players.term-sort, .term-col-genre.term-sort { justify-content: center; }
.term-sort:hover { color: #86ff66; }
.term-sort.sort-asc, .term-sort.sort-desc { color: #39ff14; }
/* A small solid triangle, not a full arrow: pointing DOWN is high->low,
   UP is low->high. Hidden until the column is the active sort. */
.term-arrow {
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  opacity: 0;
  transition: opacity 0.14s ease-out;
}
.term-sort.sort-desc .term-arrow { border-top: 6px solid currentColor; opacity: 1; }
.term-sort.sort-asc .term-arrow { border-bottom: 6px solid currentColor; opacity: 1; }
/* a ghost of the arrow on hover, so the column reads as sortable before
   you commit to clicking it */
.term-sort:hover .term-arrow { border-top: 6px solid currentColor; opacity: 0.35; }
.term-sort.sort-asc:hover .term-arrow { border-top: none; border-bottom: 6px solid currentColor; opacity: 1; }
.term-sort.sort-desc:hover .term-arrow { opacity: 1; }

.term-body {
  position: relative;
  z-index: 1;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 6px 10px 16px;
}
.term-body::-webkit-scrollbar { width: 8px; }
.term-body::-webkit-scrollbar-track { background: transparent; }
.term-body::-webkit-scrollbar-thumb { background: rgba(134, 255, 102, 0.25); }
.term-body::-webkit-scrollbar-thumb:hover { background: rgba(134, 255, 102, 0.45); }

/* Empty state: a terminal message, cursor and all. */
#lobby-list .term-empty {
  padding: 40px 22px;
  font-family: 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 1px;
  color: #5a8f50;
}
#lobby-list .term-empty::after {
  content: '\2588';
  margin-left: 4px;
  color: #39ff14;
  animation: term-cursor-blink 1s step-end infinite;
}

/* Data rows -- monospace, grid-aligned to the header, springy extrusion
   pop on hover (house standard), a hard-edged JOIN bracket-button. */
.term-row {
  display: grid;
  /* must stay identical to #term-columns or the headers stop lining up
     with the data underneath them */
  grid-template-columns: 56px minmax(0, 2.6fr) minmax(0, 1.4fr) 130px 160px 130px;
  align-items: center;
  gap: 12px;
  padding: 18px 18px;
  margin: 4px 0;
  font-family: 'Courier New', monospace;
  font-size: clamp(18px, calc(1.5 * var(--dvw, 1vw)), 22px);
  color: #cdeecb;
  border: 1px solid transparent;
  border-left: 2px solid rgba(134, 255, 102, 0.25);
  background: rgba(134, 255, 102, 0.03);
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.15s, border-color 0.15s;
}
.term-row:hover {
  transform: translate(-2px, -2px);
  background: rgba(134, 255, 102, 0.09);
  border-color: rgba(134, 255, 102, 0.5);
  border-left-color: #39ff14;
}
.term-row-idx { color: #4d8f44; }
.term-row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eafff0; }
.term-row-host { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #86ff66; }
.term-row-players { text-align: center; letter-spacing: 1px; }
.term-row-players.term-full { color: #ffb14d; }
.term-row-genre {
  text-align: center;
  font-size: 16px;
  letter-spacing: 1px;
  color: #5a8f50;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.term-row-join {
  justify-self: end;
  background: none;
  border: 1px solid rgba(57, 255, 20, 0.5);
  padding: 6px 14px;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 2px;
  color: #39ff14;
  transition: background 0.12s, box-shadow 0.12s, transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.term-row-join:hover {
  background: rgba(57, 255, 20, 0.16);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
  transform: scale(1.06);
}
.term-row-join:active { transform: scale(0.94); transition-duration: 0.06s; }

@media (max-width: 760px) {
  #term-frame { width: calc(96 * var(--dvw, 1vw)); height: calc(90 * var(--dvh, 1vh)); }
  #term-columns, .term-row {
    grid-template-columns: 30px minmax(0, 1.6fr) 78px 78px;
  }
  .term-col-host, .term-row-host, .term-col-genre, .term-row-genre { display: none; }
}

/* ---------- LEADERBOARDS: standings under PLAY ----------
   The server browser's sibling: same CRT-green terminal chassis, one
   centred angular plate. Global standings, so no per-mode recolour --
   the accent is the staging screen's neutral green, restated locally
   because this screen never carries mpmenu's mode classes.
   SCALING: the root zoom (swApplyZoom) already shows every screen as the
   same picture at every window size, so everything here is design-grid px
   with --dvw/--dvh only where the plate must breathe with the window, and
   the .active screen fills var(--vph) (THE GREY BAR rule) so no dead tail
   ever shows below a short list. */
body:has(#screen-ranks.active) { max-width: none; padding: 0; }
#screen-ranks.active {
  position: relative;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: var(--vph, 100vh);
  padding: calc(3 * var(--dvh, 1vh)) calc(2 * var(--dvw, 1vw));
  /* THE NIGHT, top to bottom of the stack (owner call: the background
     is STARS now -- the godrays, side shafts and zenith burst were
     replaced wholesale): a FLOOR COVER gradient that
     goes opaque below the horizon so the tiled stars stop at the ground
     instead of lying on it (background layers can't be masked one by
     one -- covering them is the trick); FOUR tiled star layers on
     mutually-offset clocks of different tile sizes so no two layers'
     dots ever line up into a lattice -- a couple of rare soft halos
     (bokeh, the blurry-lights call carried forward) over fields of
     small sharp points; a soft vignette; and a sky base that falls from
     deep green to near-black at the zenith. All static paint.
     (A horizon pool + wide bloom sat on top of this stack for one
     revision -- circled in red by the owner and cut: pinned at a screen
     percentage, it hung in mid-air beside the plate instead of lying
     where the floor fades. The retired-record lesson from mp-atmos --
     a soft glow with nothing to belong to reads as a smudge. The
     floor's own far-edge glow, ON the plane, is the light that stays.) */
  background:
    linear-gradient(180deg, transparent 69%, rgba(4, 9, 4, 0.94) 75%, rgba(3, 6, 3, 0.98) 100%),
    radial-gradient(circle 1.5px at 50% 50%, rgba(224, 255, 214, 0.85) 0%, rgba(224, 255, 214, 0.25) 45%, transparent 100%) 0 0 / 520px 430px,
    radial-gradient(circle 1px at 50% 50%, rgba(186, 255, 171, 0.55) 0%, transparent 100%) 133px 57px / 290px 238px,
    radial-gradient(circle 1px at 50% 50%, rgba(160, 230, 150, 0.38) 0%, transparent 100%) 41px 172px / 212px 184px,
    radial-gradient(circle 3.5px at 50% 50%, rgba(200, 255, 190, 0.2) 0%, rgba(200, 255, 190, 0.07) 45%, transparent 100%) 258px 122px / 640px 560px,
    radial-gradient(ellipse 145% 135% at 50% 45%, transparent 64%, rgba(0, 0, 0, 0.4) 100%),
    radial-gradient(ellipse 120% 115% at 50% 100%, #081208 0%, #040a04 45%, #010301 100%);
}
/* the floor: a perspective plane running from under the plate away to
   the horizon line -- mp-atmos's construction (that room is the depth
   effect the owner pointed at), so the same three laws apply: lines
   running AWAY only, never a cross-grid (a grid is a surface, away-lines
   are DISTANCE); pushed wide (-60%) so the vanishing edges leave the
   frame instead of stopping inside it; and a mask that dissolves the
   plane into the horizon glow so the seam never shows. Anchored at the
   FAR edge (origin 50% 0 at the horizon), unlike mp-atmos's near-edge
   anchor -- same picture, pinned where this room's horizon lives. */
#screen-ranks.active::before {
  content: '';
  position: absolute;
  left: -60%; right: -60%;
  top: 68%; bottom: -180%;
  pointer-events: none;
  transform: perspective(900px) rotateX(58deg);
  transform-origin: 50% 0;
  background:
    linear-gradient(180deg, rgba(57, 255, 20, 0.14) 0%, transparent 42%),
    repeating-linear-gradient(90deg, rgba(134, 255, 102, 0.14) 0 2px, transparent 2px 210px),
    repeating-linear-gradient(90deg, rgba(134, 255, 102, 0.075) 0 1px, transparent 1px 97px);
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.9) 26%, #000 60%);
  mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.9) 26%, #000 60%);
}
/* (A ceiling plane mirroring the floor lived on ::after for one
   revision -- retired when the sky became stars: a wireframe ceiling
   over a starfield is two skies. ::after is free again. The sweeping
   searchlights died on the same pseudo one revision earlier; whatever
   moves in this room next, the answer so far has always been "nothing".) */
/* the plate stands IN the room, above the floor, under the stars */
#rk-wrap { width: 100%; display: flex; justify-content: center; position: relative; z-index: 1; }
#rk-frame {
  /* narrower than it was (96dvw -> 90dvw): the hall only exists if the
     room shows around the plate */
  width: min(1360px, 90 * var(--dvw, 1vw));
  /* FIXED height, not max-height: the plate must not breathe when a tab
     swaps in a shorter list or a scope rail comes and goes -- the list
     flexes and scrolls INSIDE it instead (owner call: the board is a
     fixture, not a balloon). */
  height: calc(var(--vph, 100vh) - 9 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  padding: 26px 34px 22px;
  /* the glass catches the hall: a touch of the zenith light pools in
     the plate's top edge, and the border runs hotter than it did --
     a monument's case, lit by the room it stands in */
  background:
    linear-gradient(180deg, rgba(57, 255, 20, 0.07) 0%, transparent 18%),
    rgba(5, 12, 5, 0.88);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.55);
  /* the house angular plate: two clipped corners, same 20px cut as the
     challenge board -- chrome may bevel, content never does */
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
}
#rk-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid rgba(57, 255, 20, 0.25);
}
#rk-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(34px, calc(3 * var(--dvw, 1vw)), 52px);
  letter-spacing: 2px;
  color: #86ff66;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.55), 0 0 26px rgba(57, 255, 20, 0.25);
}
#rk-title-kata {
  font-family: 'Courier New', monospace;
  font-size: 0.45em;
  letter-spacing: 3px;
  color: rgba(134, 255, 102, 0.55);
  text-shadow: none;
}
#rk-sub {
  margin-top: 4px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 6px;
  color: rgba(255, 210, 62, 0.75);
  text-shadow: 0 0 8px rgba(255, 210, 62, 0.35);
}
.rk-sub-kata { color: rgba(134, 255, 102, 0.4); letter-spacing: 3px; text-shadow: none; }
#rk-tabs { display: flex; gap: 8px; padding: 0 0 10px; }
/* Scope rails: smaller cousins of the board tabs, two stacked rows (the
   family, then PRODUCER's gamemodes). They sit ABOVE the board tabs, so
   the first rail carries the gap under the header. Wrap on narrow plates
   rather than overflow -- chips are the one thing here allowed to
   reflow. */
#rk-scopes { display: flex; flex-wrap: wrap; gap: 6px; padding: 14px 0 8px; }
#rk-modes { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; }
/* Hidden rails KEEP THEIR SPACE (visibility, not display): the tabs, the
   column header and the list must sit at the same y whichever board or
   scope is up, or the whole plate appears to twitch on every click. */
#rk-scopes.hidden, #rk-modes.hidden { visibility: hidden; }
/* On LEVEL/EARNINGS the scope rails DIM instead of vanishing (owner
   call: chips that disappear on a tab click read as a bug) -- still
   unclickable, because those boards genuinely can't scope, but the
   furniture stays where the eye left it. */
#rk-scopes.rk-rail-off, #rk-modes.rk-rail-off { opacity: 0.3; pointer-events: none; }
.rk-scope {
  padding: 5px 16px;
  background: none;
  border: 1px solid rgba(57, 255, 20, 0.22);
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 2px;
  color: #6ea862;
  cursor: pointer;
  clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 7px 100%, 0 calc(100% - 7px));
  transition: color 0.14s, background 0.14s, border-color 0.14s;
}
.rk-scope:hover { color: #baffab; background: rgba(57, 255, 20, 0.08); }
.rk-scope-on {
  color: #baffab;
  border-color: rgba(57, 255, 20, 0.65);
  background: rgba(57, 255, 20, 0.14);
}
.rk-tab {
  padding: 9px 26px;
  background: rgba(57, 255, 20, 0.06);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.3);
  font-family: 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 2px;
  color: #7fbf6e;
  cursor: pointer;
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  transition: color 0.14s, background 0.14s, text-shadow 0.14s;
}
.rk-tab:hover { color: #baffab; background: rgba(57, 255, 20, 0.12); }
.rk-tab-on {
  color: #061106;
  background: #39ff14;
  text-shadow: none;
  font-weight: bold;
}
.rk-tab-on:hover { color: #061106; background: #5aff3a; }
/* ---------- THE PODIUM ----------
   The top three leave the table: champion centre and tallest on gold,
   2nd left on silver, 3rd right on bronze. Construction follows the
   house frame rules -- the metal is a static gradient stack painted
   once, the ONE animated layer is the champion's glint (a travelling
   bar, SLAB's law: 40% of the cycle crossing, the rest parked off the
   edge, so reduced-motion just stops it invisible), and the glow is
   drop-shadow on the WRAPPER while the clip lives on the plate child,
   because filter before clip-path on one element slices its own halo. */
#rk-podium {
  position: relative;
  display: flex;
  align-items: stretch;
  gap: 20px;
  /* Sized to hold the champion stack (rank + face + name + stat) with
     real headroom -- the plates bottom-justify, so ANY overflow eats the
     rank numeral off the top first. If a pedestal's content grows, this
     number grows with it; the harness check is scrollHeight vs
     clientHeight on the plates, not eyeballing. */
  height: 396px;
  flex: none;
  padding: 16px 4px 14px;
}
/* THE HALL: three spotlight cones falling from above the plate onto the
   thrones -- conic wedges, static paint, drawn once. The pseudo paints
   before its filtered siblings (tree order), so the beams land BEHIND
   the pedestals; the gold cone is widest and brightest because the
   champion owns the light. Positions follow the visual order
   (silver / gold / bronze), not the DOM order. */
#rk-podium::before {
  content: '';
  position: absolute;
  inset: -14px 0 0;
  pointer-events: none;
  background:
    conic-gradient(from 180deg at 50% -22%, transparent 41%, rgba(255, 220, 110, 0.10) 47%, rgba(255, 235, 170, 0.16) 50%, rgba(255, 220, 110, 0.10) 53%, transparent 59%),
    conic-gradient(from 180deg at 17% -18%, transparent 44%, rgba(223, 233, 242, 0.07) 50%, transparent 56%),
    conic-gradient(from 180deg at 83% -18%, transparent 44%, rgba(208, 138, 78, 0.07) 50%, transparent 56%);
}
/* the stage line: pools of each metal's light on the floor the thrones
   stand on, fading to nothing a few px down -- the podium stops ending
   in mid-air */
#rk-podium::after {
  content: '';
  position: absolute;
  left: 4px; right: 4px; bottom: 2px;
  height: 12px;
  pointer-events: none;
  background:
    radial-gradient(ellipse 46% 100% at 50% 0%, rgba(255, 210, 62, 0.22) 0%, transparent 70%),
    radial-gradient(ellipse 30% 100% at 15% 0%, rgba(223, 233, 242, 0.12) 0%, transparent 70%),
    radial-gradient(ellipse 30% 100% at 85% 0%, rgba(208, 138, 78, 0.12) 0%, transparent 70%);
}
.rk-ped { flex: 1; display: flex; align-items: flex-end; min-width: 0; }
.rk-ped-2 { order: 1; --ped-c: #dfe9f2; --ped-soft: rgba(223, 233, 242, 0.4); --ped-dim: rgba(223, 233, 242, 0.12); --ped-glint: rgba(235, 243, 252, 0.13); }
.rk-ped-1 { order: 2; --ped-c: #ffd23e; --ped-soft: rgba(255, 210, 62, 0.5); --ped-dim: rgba(255, 210, 62, 0.14); --ped-glint: rgba(255, 240, 190, 0.24); }
.rk-ped-3 { order: 3; --ped-c: #d08a4e; --ped-soft: rgba(208, 138, 78, 0.4); --ped-dim: rgba(208, 138, 78, 0.12); --ped-glint: rgba(255, 205, 160, 0.11); }
/* height IS the placement: the eye reads the skyline before any number.
   The steps are deeper than they were (86/78 -> 82/72) -- a skyline you
   have to squint at isn't a skyline. Headroom checked: the 2nd/3rd
   stacks clear these heights with >40px to spare. */
.rk-ped-1 .rk-ped-plate { height: 100%; }
.rk-ped-2 .rk-ped-plate { height: 82%; }
.rk-ped-3 .rk-ped-plate { height: 72%; }
/* the champion is physically wider than the court -- presence before
   any colour reads */
.rk-ped-1 { flex: 1.18; }
/* the halo rides the wrapper (clip is on the plate): the bloom curve is
   THREE radii on every metal (the CHROMA law -- two radii is a hot edge
   plus a halo with a seam), alphas falling faster than the radii grow.
   Silver and bronze glow in their own metal, dimmer -- a court, lit. */
.rk-ped-1 { filter: drop-shadow(0 0 5px rgba(255, 210, 62, 0.55)) drop-shadow(0 0 16px rgba(255, 210, 62, 0.26)) drop-shadow(0 0 48px rgba(255, 210, 62, 0.12)); }
.rk-ped-2 { filter: drop-shadow(0 0 4px rgba(223, 233, 242, 0.3)) drop-shadow(0 0 12px rgba(223, 233, 242, 0.14)) drop-shadow(0 0 30px rgba(223, 233, 242, 0.06)); }
.rk-ped-3 { filter: drop-shadow(0 0 4px rgba(208, 138, 78, 0.3)) drop-shadow(0 0 12px rgba(208, 138, 78, 0.14)) drop-shadow(0 0 30px rgba(208, 138, 78, 0.06)); }
.rk-ped-plate {
  position: relative;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  padding: 12px 12px 20px;
  overflow: hidden;
  /* Trophy construction, one static paint, top to bottom: a metal
     CAP-BAND under the chamfered crown (bright core, darker at both
     rails -- a bar of the pedestal's own metal, not a border), the dark
     stone body, and a metal PLINTH the whole stack stands on (drawn
     above the body layer, under the cap). All in the plate's own vars,
     so the three thrones are the same construction in three metals. */
  background:
    linear-gradient(180deg, var(--ped-soft) 0%, var(--ped-dim) 4px, transparent 4px) no-repeat,
    linear-gradient(90deg, var(--ped-dim) 0%, var(--ped-soft) 30%, var(--ped-c) 50%, var(--ped-soft) 70%, var(--ped-dim) 100%) 0 100% / 100% 7px no-repeat,
    linear-gradient(180deg, transparent calc(100% - 10px), var(--ped-dim) calc(100% - 10px)) no-repeat,
    linear-gradient(170deg, var(--ped-dim) 0%, rgba(4, 8, 4, 0.92) 46%, rgba(3, 5, 3, 0.96) 100%);
  border: 1px solid var(--ped-soft);
  clip-path: polygon(0 16px, 16px 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 0 100%);
}
/* every throne glints -- SLAB's law (a moving highlight never has to be
   dark to prove itself): ~40% of the cycle crossing, the rest parked off
   the edge, so it is invisible at rest and reduced-motion just stops it.
   Still ONE transform-animated layer per plate; the three run on
   different clocks so the court never salutes in unison. */
.rk-ped-plate::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg, transparent 42%, var(--ped-glint) 50%, transparent 58%);
  transform: translateX(-160%);
  animation: rk-glint 5.5s ease-in-out infinite;
  pointer-events: none;
}
.rk-ped-2 .rk-ped-plate::after { animation-duration: 8.5s; animation-delay: 2.1s; }
.rk-ped-3 .rk-ped-plate::after { animation-duration: 9.5s; animation-delay: 4.3s; }
@keyframes rk-glint {
  0%, 62% { transform: translateX(-160%); }
  88%, 100% { transform: translateX(160%); }
}
@media (prefers-reduced-motion: reduce) {
  .rk-ped-plate::after { animation: none; }
}
.rk-ped-rank {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  line-height: 1;
  letter-spacing: 2px;
  color: var(--ped-c);
  text-shadow: 0 0 10px var(--ped-soft);
}
.rk-ped-1 .rk-ped-rank { font-size: 32px; }
.rk-ped-face .player-icon {
  width: 88px; height: 88px;
  border-radius: 10px;
  display: flex; align-items: center; justify-content: center;
  /* Emoji-fallback faces: the glyph fills the plate like a photo would,
     and line-height 1 kills the inherited line box that floated the
     emoji high off centre (a 44px glyph in a 28px line box). */
  font-size: 56px;
  line-height: 1;
  overflow: hidden;
  background: #12240f;
  border: 1px solid var(--ped-soft);
}
.rk-ped-1 .rk-ped-face .player-icon { width: 124px; height: 124px; font-size: 78px; }
.rk-ped-face .player-icon img { width: 100%; height: 100%; object-fit: cover; }
.rk-ped-name {
  display: flex;
  align-items: baseline;
  /* centred as a UNIT: tag + name find the plate's axis together --
     worn fx force inline-block on the name and can leave the flex row
     hugging one side without this */
  justify-content: center;
  gap: 8px;
  max-width: 100%;
  /* daylight between the face and the name, plus a soft dark pool
     BEHIND the name: the podium wears full outfits, and a green fx
     name on a green frame otherwise reads as part of the picture */
  margin-top: 4px;
  padding: 3px 14px;
  background: radial-gradient(ellipse 100% 120% at 50% 50%, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.3) 60%, transparent 92%);
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  /* The name is what the pedestal is FOR -- it reads from across the
     room or the podium isn't doing its job (owner call: bigger). */
  font-size: 28px;
  line-height: 1.15;
  color: #eaffe4;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.35);
}
.rk-ped-1 .rk-ped-name { font-size: 36px; }
.rk-ped-tag { font-family: 'Courier New', monospace; font-size: 0.55em; color: rgba(134, 255, 102, 0.6); flex: none; }
.rk-ped-name b { font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Label CENTRED UNDER the number, not baseline-trailing beside it -- a
   small word hanging off a 64px glyph's baseline reads as misplaced. */
.rk-ped-stat { display: flex; flex-direction: column; align-items: center; gap: 3px; font-family: 'Courier New', monospace; }
.rk-ped-stat b {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  /* The score is the headline of the pedestal (owner call: bigger) --
     line-height 1 so the size buys glyph, not leading, inside the
     plate's fixed budget. */
  font-size: 48px;
  line-height: 1;
  color: var(--ped-c);
  text-shadow: 0 0 12px var(--ped-soft);
}
.rk-ped-1 .rk-ped-stat b { font-size: 64px; }
.rk-ped-stat span { font-size: 15px; letter-spacing: 3px; color: rgba(134, 255, 102, 0.55); }
.rk-ped-you {
  position: absolute;
  top: 12px; right: 14px;
  padding: 2px 8px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 2px;
  color: #061106;
  background: #39ff14;
}
/* an unclaimed throne: dimmer, dashed, waiting -- never collapsed */
.rk-ped-empty .rk-ped-plate {
  justify-content: center;
  border-style: dashed;
  border-color: rgba(134, 255, 102, 0.22);
  background: linear-gradient(170deg, rgba(57, 255, 20, 0.04) 0%, rgba(3, 5, 3, 0.9) 100%);
}
.rk-ped-empty { filter: none; }
.rk-ped-vacant {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 4px;
  color: rgba(134, 255, 102, 0.35);
}

/* One grid, declared once: the header row and every list row read the same
   columns, so nothing can drift out of line. minmax(0,1fr) on the name is
   what lets long names ellipsize instead of shoving the stat column. */
#rk-cols, .rk-row, #rk-you {
  display: grid;
  grid-template-columns: 64px 54px minmax(0, 1fr) 110px 170px;
  align-items: center;
  gap: 12px;
}
#rk-cols {
  padding: 8px 14px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.5);
  border-bottom: 1px solid rgba(57, 255, 20, 0.2);
}
/* The list scrolls inside the plate, never the page (the plate is capped
   at the viewport by #rk-frame's max-height). */
/* The list is the one elastic band in the plate: it takes whatever the
   fixed frame has left and scrolls, so row count never resizes the box. */
#rk-list { overflow-y: auto; min-height: 120px; flex: 1 1 auto; }
/* Both scrollbar engines, or Firefox paints its stock grey bar on the
   CRT: scrollbar-color for Gecko, the ::-webkit-* trio for Chromium --
   same pairing as .term-body / #cr-shelf. */
#rk-list {
  scrollbar-width: thin;
  scrollbar-color: rgba(57, 255, 20, 0.3) transparent;
}
#rk-list::-webkit-scrollbar { width: 8px; }
#rk-list::-webkit-scrollbar-track { background: transparent; }
#rk-list::-webkit-scrollbar-thumb { background: rgba(57, 255, 20, 0.3); }
#rk-list::-webkit-scrollbar-thumb:hover { background: rgba(57, 255, 20, 0.5); }
.rk-row, #rk-you {
  padding: 10px 14px;
  font-family: 'Courier New', monospace;
  font-size: 22px;
  color: #b8e6ac;
  border-bottom: 1px solid rgba(57, 255, 20, 0.08);
}
.rk-pos { color: rgba(134, 255, 102, 0.6); letter-spacing: 1px; }
/* rows glow under the cursor -- a background tint, not a filter, so 50
   rows cost nothing. A row with a playerId is a door (click = profile,
   right-click = menu), and the cursor says so. */
.rk-row:hover { background: rgba(57, 255, 20, 0.06); }
.rk-row.rk-clickable, #rk-you[data-player-id], .rk-ped[data-player-id] { cursor: pointer; }
.rk-face { display: flex; }
/* reuse of .player-icon: same square-in-roster read as the lobby (6px). */
.rk-face .player-icon {
  width: 44px; height: 44px;
  border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  font-size: 28px;
  line-height: 1;
  overflow: hidden;
  background: #12240f;
}
.rk-face .player-icon img { width: 100%; height: 100%; object-fit: cover; }
.rk-name { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.rk-tag { color: rgba(134, 255, 102, 0.55); font-size: 0.82em; flex: none; }
.rk-name b {
  font-weight: normal;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.rk-lvl { text-align: right; color: rgba(184, 230, 172, 0.75); font-size: 0.85em; letter-spacing: 1px; }
/* prestige star, tinted per certification tier (1 gold / 2 platinum /
   3 diamond) -- the same order PRESTIGE_TIERS declares */
.rk-cert { margin-right: 4px; }
.rk-cert-1 { color: #ffd23e; }
.rk-cert-2 { color: #dfe9f2; }
.rk-cert-3 { color: #7fe6ff; }
.rk-stat { text-align: right; color: #86ff66; letter-spacing: 1px; }
/* YOU: your row on the board, or the docked line under it -- same accent
   either way, so the eye finds itself the same way twice */
.rk-mine, #rk-you {
  background: rgba(57, 255, 20, 0.1);
  color: #eaffe4;
}
#rk-you { border-top: 1px solid rgba(57, 255, 20, 0.35); border-bottom: none; flex: none; }
.rk-empty {
  padding: 26px 14px;
  font-family: 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 1px;
  color: rgba(134, 255, 102, 0.5);
}
/* Handset widths: the level column is the one that earns its keep least,
   so it folds first -- same philosophy as the server browser dropping
   HOST/GENRE at 760px. */
@media (max-width: 760px) {
  #rk-frame { padding: 18px 16px 14px; }
  #rk-cols, .rk-row, #rk-you { grid-template-columns: 40px 36px minmax(0, 1fr) 96px; gap: 8px; }
  .rk-c-lvl, .rk-lvl { display: none; }
  .rk-row, #rk-you { font-size: 17px; }
  #rk-podium { height: 224px; gap: 10px; }
  .rk-ped-face .player-icon { width: 56px; height: 56px; font-size: 28px; }
  .rk-ped-1 .rk-ped-face .player-icon { width: 72px; height: 72px; font-size: 36px; }
  .rk-ped-name { font-size: 16px; }
  .rk-ped-1 .rk-ped-name { font-size: 18px; }
  .rk-ped-rank { font-size: 22px; }
  .rk-ped-1 .rk-ped-rank { font-size: 28px; }
  .rk-ped-stat b { font-size: 22px; }
  .rk-ped-1 .rk-ped-stat b { font-size: 27px; }
}

/* ---------- terminal background: live "machine activity" layer ----------
   A second layer over the circuit board, deliberately NOT more canvas
   work: plain positioned spans, animated with opacity/transform only, so
   the compositor handles it for free no matter how often it fires.
   Snapped to real solder-pad coordinates from the same board geometry
   the pulses ride, so it reads as actual traffic through actual nodes --
   memory addresses, DB reads, ping times -- flickering into existence
   and evaporating, upward, like the machine is genuinely doing something
   right where you're standing. */
#term-data-layer {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.term-data-label {
  position: absolute;
  transform: translate(-50%, -50%);
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 1px;
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.8);
  opacity: 0;
  white-space: nowrap;
  pointer-events: none;
}
/* A quick bright blip fires first, just left of the text -- "something
   just happened at this exact node" -- then the readout itself pops and
   drifts up as it fades, like it's evaporating off the board. */
.term-data-label::before {
  content: '';
  position: absolute;
  left: -14px;
  top: 50%;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: #39ff14;
  transform: translateY(-50%);
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.9);
  opacity: 0;
}
.term-data-label.term-data-pop {
  animation: term-data-pop 1.6s ease-out 1;
}
.term-data-label.term-data-pop::before {
  animation: term-data-blip 1.6s ease-out 1;
}
@keyframes term-data-pop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
  10%  { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
  16%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  70%  { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -60%) scale(0.95); }
}
@keyframes term-data-blip {
  0%   { opacity: 0; }
  8%   { opacity: 1; }
  30%  { opacity: 0.3; }
  100% { opacity: 0; }
}

/* ---------- NEW SERVER: hologram projection ----------
   The create-server settings are a hologram PROJECTED FROM the
   [ + NEW SERVER ] button: holoOpen() (main.js) measures the button and
   animates the panel growing out of its exact screen position -- a
   squashed bright sliver at the button that expands, flickers like
   unstable projected light, and settles centered -- while this overlay
   dims and blurs the whole server browser behind it. Translucent holo
   glass in a cyan-green cast (deliberately a step BLUER than the app's
   hot neon green, the way projected light reads against phosphor UI),
   internal scanlines with a slow sweep band, a gentle idle float.
   Closing retracts it back into the button. */
#holo-overlay {
  position: fixed;
  inset: 0;
  z-index: 340;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(4 * var(--dvh, 1vh)) calc(4 * var(--dvw, 1vw));
  background: rgba(1, 5, 3, 0.72);
  backdrop-filter: blur(2.5px);
  animation: holo-dim-in 0.3s ease-out;
}
@keyframes holo-dim-in {
  from { background: rgba(1, 5, 3, 0); backdrop-filter: blur(0px); }
  to { background: rgba(1, 5, 3, 0.72); backdrop-filter: blur(2.5px); }
}

/* The floating settings panel. */
#holo-panel {
  position: relative;
  z-index: 2;
  width: min(680px, calc(94 * var(--dvw, 1vw)));
  padding: 30px 38px 26px;
  background: linear-gradient(175deg, rgba(24, 70, 55, 0.32), rgba(8, 28, 22, 0.55));
  border: 1px solid rgba(140, 255, 210, 0.55);
  box-shadow:
    0 0 34px rgba(90, 255, 190, 0.22),
    inset 0 0 44px rgba(120, 255, 200, 0.07);
  backdrop-filter: blur(2px);
}
/* chromatic fringing on the glass edges -- projected-light artifacting */
#holo-panel::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow:
    inset 2px 0 6px rgba(255, 70, 90, 0.12),
    inset -2px 0 6px rgba(70, 160, 255, 0.14);
}
/* internal scanlines + one brighter band slowly sweeping down */
#holo-panel-scan {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(to bottom, transparent, rgba(160, 255, 220, 0.09) 48%, rgba(160, 255, 220, 0.09) 52%, transparent) no-repeat 0 -40% / 100% 45%,
    repeating-linear-gradient(to bottom, rgba(140, 255, 210, 0.06) 0 1px, transparent 1px 3px);
  animation: holo-scan-sweep 4.5s linear infinite;
}
@keyframes holo-scan-sweep {
  from { background-position: 0 -45%, 0 0; }
  to { background-position: 0 145%, 0 0; }
}
/* Idle character (the grow-from-button entry itself is JS-driven in
   holoOpen so it can aim at the button's real position; WAAPI overrides
   these while it plays, then hands back): a gentle float and the
   occasional projected-light flicker. Restart on every open comes free
   -- display:none -> flex re-runs them. */
#holo-panel {
  animation:
    holo-float 5.5s ease-in-out 0.6s infinite alternate,
    holo-flicker 4.2s steps(1) 1.2s infinite;
}
@keyframes holo-float {
  from { transform: translateY(0); }
  to { transform: translateY(-6px); }
}
/* tiny projected-light instability: brief dips, mostly steady */
@keyframes holo-flicker {
  0%, 100% { opacity: 1; }
  81% { opacity: 1; }
  82% { opacity: 0.86; }
  83% { opacity: 1; }
  91% { opacity: 0.93; }
  92% { opacity: 1; }
}
/* The button that opened the overlay IS the projector -- it stays lit
   and pulsing for as long as its hologram is up. */
.holo-projecting {
  color: #b8ffe4 !important;
  text-shadow: 0 0 12px rgba(140, 255, 210, 0.9), 0 0 30px rgba(90, 255, 190, 0.5) !important;
  animation: holo-emitter-pulse 1.6s ease-in-out infinite;
}
@keyframes holo-emitter-pulse {
  0%, 100% { filter: brightness(1); }
  50% { filter: brightness(1.5); }
}
/* ---- panel contents ---- */
#holo-head {
  position: relative;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  margin-bottom: 24px;
  padding-bottom: 14px;
  border-bottom: 1px solid rgba(140, 255, 210, 0.3);
}
#holo-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(34px, calc(3.4 * var(--dvw, 1vw)), 52px);
  letter-spacing: 4px;
  color: #b8ffe4;
  text-shadow: 0 0 12px rgba(140, 255, 210, 0.8), 0 0 34px rgba(90, 255, 190, 0.4);
}
#holo-kata {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 4px;
  color: #64b39a;
  white-space: nowrap;
}
.holo-field { display: block; margin: 0 0 20px; }
.holo-label {
  display: block;
  margin-bottom: 8px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 3px;
  color: #7fd4b6;
}
#holo-panel input[type="text"],
#holo-panel input[type="number"],
#holo-panel select {
  width: 100%;
  padding: 12px 14px;
  background: rgba(8, 34, 26, 0.5);
  border: 1px solid rgba(140, 255, 210, 0.28);
  border-bottom: 2px solid rgba(140, 255, 210, 0.75);
  color: #dcfff2;
  font-family: 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 1px;
  transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
#holo-panel input::placeholder { color: rgba(140, 255, 210, 0.35); letter-spacing: 2px; }
/* The browser's default white number-spinner buttons read as stickers
   slapped on the glass -- gone; the values are typed. */
#holo-panel input[type="number"]::-webkit-outer-spin-button,
#holo-panel input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
#holo-panel input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
#holo-panel input:focus,
#holo-panel select:focus {
  outline: none;
  background: rgba(12, 46, 36, 0.6);
  border-color: rgba(160, 255, 220, 0.6);
  border-bottom-color: #b8ffe4;
  box-shadow: 0 0 16px rgba(120, 255, 200, 0.3);
}
#holo-panel select option { background: #0a221c; color: #dcfff2; }
.holo-row { display: flex; gap: 20px; }
.holo-row .holo-field { flex: 1 1 0; }

/* ---- segmented option rows (reference-layout style: side-by-side
   choices, the active one filled solid) ---- */
.holo-segments { display: flex; flex-wrap: wrap; gap: 8px; }
.holo-segment { display: contents; }
.holo-segment input { display: none; }
.holo-segment span,
.holo-segment-btn {
  flex: 1 1 auto;
  min-width: 150px;
  text-align: center;
  padding: 13px 16px;
  background: rgba(8, 34, 26, 0.5);
  border: 1px solid rgba(140, 255, 210, 0.28);
  color: #a9e8d0;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.holo-segment span:hover,
.holo-segment-btn:hover {
  border-color: rgba(160, 255, 220, 0.6);
  color: #dcfff2;
}
.holo-segment input:checked + span,
.holo-segment-btn.active {
  background: rgba(130, 255, 205, 0.85);
  border-color: #b8ffe4;
  color: #06231a;
  font-weight: bold;
  box-shadow: 0 0 18px rgba(120, 255, 200, 0.45);
}

/* ---- GENRE ROULETTE: building the wheel ----
   The genre checklist is a wall of small chips rather than the wide
   side-by-side segments the radio rows use, so its tiles opt out of the
   150px min-width -- twenty genres at that width is a scroll, not a
   glance. Custom entries the host typed carry a [x] that removes them;
   the defaults have no x because unticking IS the remove. */
#pick-roulette-genres { margin-bottom: 20px; }
#pick-roulette-genres .holo-segment span,
#pick-roulette-genres .holo-segment-btn {
  flex: 0 0 auto;
  min-width: 0;
  padding: 9px 14px;
  font-size: 13px;
  letter-spacing: 1.5px;
}
.holo-genre-x {
  margin-left: 8px;
  opacity: 0.65;
  font-weight: bold;
}
.holo-genre-x:hover { opacity: 1; }
/* A text box and its button on one line, the button sized to its label. */
.holo-inline { display: flex; gap: 8px; align-items: stretch; }
.holo-inline input[type="text"] { flex: 1 1 auto; }
.holo-inline .holo-segment-btn { flex: 0 0 auto; min-width: 120px; }
.holo-note {
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1.5px;
  color: #7fd4b6;
  margin: -6px 0 20px;
  min-height: 18px;
}
.holo-note.is-warn { color: #ffb27f; }
/* The mode blurb reserves its line whether or not it is saying anything:
   it fills on hover and empties on leave, and a note that collapses when
   empty would bounce the three tiles above it on every pass of the
   pointer. Two lines deep, since the longest blurb wraps at panel width. */
#create-mode-blurb {
  min-height: 2.6em;
  margin: 10px 0 0;
  transition: color 0.12s ease-out;
}

/* ---- sliders with big readouts + tick rows ---- */
.holo-slider-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 10px;
}
.holo-slider-head .holo-label { margin-bottom: 0; }
.holo-readout {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  letter-spacing: 2px;
  color: #b8ffe4;
  text-shadow: 0 0 10px rgba(140, 255, 210, 0.7), 0 0 26px rgba(90, 255, 190, 0.35);
}
.holo-range {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 34px;
  margin: 0;
  background: transparent;
  cursor: pointer;
}
.holo-range::-webkit-slider-runnable-track {
  height: 28px;
  background: linear-gradient(to right,
    rgba(120, 255, 200, 0.55) 0 var(--fill, 50%),
    rgba(8, 34, 26, 0.5) var(--fill, 50%) 100%);
  border: 1px solid rgba(140, 255, 210, 0.3);
}
.holo-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 16px;
  height: 36px;
  margin-top: -5px;
  background: #b8ffe4;
  border: 1px solid rgba(6, 24, 18, 0.9);
  box-shadow: 0 0 14px rgba(140, 255, 210, 0.9);
  cursor: grab;
}
.holo-range::-moz-range-track {
  height: 28px;
  background: linear-gradient(to right,
    rgba(120, 255, 200, 0.55) 0 var(--fill, 50%),
    rgba(8, 34, 26, 0.5) var(--fill, 50%) 100%);
  border: 1px solid rgba(140, 255, 210, 0.3);
}
.holo-range::-moz-range-thumb {
  width: 16px;
  height: 36px;
  border-radius: 0;
  background: #b8ffe4;
  border: 1px solid rgba(6, 24, 18, 0.9);
  box-shadow: 0 0 14px rgba(140, 255, 210, 0.9);
}
.holo-ticks {
  display: flex;
  justify-content: space-between;
  margin-top: 7px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 1px;
  color: #64b39a;
}
.holo-label-dim { color: #4e806c; }

/* pick-a-beat rows adopt the holo skin (global .beat-option styles are
   tuned for the vote screen's dark panels, not projected glass) */
#holo-panel #pick-beat-list { max-height: calc(30 * var(--dvh, 1vh)); overflow-y: auto; margin-bottom: 16px; }
#holo-panel .beat-option {
  border: 1px solid rgba(140, 255, 210, 0.25);
  border-radius: 0;
  background: rgba(8, 34, 26, 0.4);
  color: #dcfff2;
  font-family: 'Courier New', monospace;
}
#holo-panel .beat-option.selected { border-color: #b8ffe4; background: rgba(16, 56, 44, 0.5); }

/* ---- ONE QUESTION AT A TIME ----
   The panel is a wizard now (owner call): same controls, same style, shown
   one group at a time. Nothing here restyles a control -- a step is a
   container, and the only new pixels are the nav row and the answer sheet. */
.holo-step { display: block; }
/* THE STEP ASKS OUT LOUD. One question per step, in the house display
   font at a size under the panel title -- the title says where you are,
   this says what is being asked of you. It replaces the little Courier
   field label on the six steps that are ONE question (the label would
   have said the same word twice); the room step keeps its labels, since
   they name four different things. */
.holo-step-q {
  margin: 0 0 18px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(20px, calc(1.6 * var(--dvw, 1vw)), 26px);
  font-weight: 400;
  letter-spacing: 3px;
  color: #b8ffe4;
  text-shadow: 0 0 10px rgba(140, 255, 210, 0.55), 0 0 26px rgba(90, 255, 190, 0.25);
}
/* ---- UNANSWERED IS DARK ----
   Every tile on a step is an equally unanswered option, so they all sit
   down at 0.55 and the room is dim until you commit to something. Hover
   and keyboard focus bring one to full, so the pointer always knows what
   it is about to answer with, and a CHOSEN tile stays lit -- which is what
   makes BACK legible: the step you return to still shows what you picked.

   TARGETED AT THE SPAN, NOT THE LABEL. `.holo-segment` is `display:
   contents` -- it generates no box at all, so opacity on it is inert and
   the first version of this rule dimmed precisely nothing (the owner saw a
   fully lit tile on a fresh panel and said so). The painted box is the
   span inside it; `.holo-segment-btn` and `.beat-option` are real boxes
   and take it directly. */
.holo-step .holo-segment span,
.holo-step .holo-segment-btn,
.holo-step .beat-option {
  opacity: 0.55;
  transition: opacity 0.14s ease-out;
}
.holo-step .holo-segment span:hover,
.holo-step .holo-segment input:focus-visible + span,
.holo-step:not(.is-fresh) .holo-segment input:checked + span,
.holo-step .holo-segment-btn:hover,
.holo-step .holo-segment-btn:focus-visible,
.holo-step:not(.is-fresh) .holo-segment-btn.active,
.holo-step .beat-option:hover,
.holo-step .beat-option:focus-within,
.holo-step .beat-option:has(input:checked) { opacity: 1; }
/* ...but a list you TICK is not a set of answers, it is one answer being
   assembled, so its rows stay lit and CONFIRM ends the step. */
#pick-host-words .holo-segment span,
#pick-roulette-genres .holo-segment span { opacity: 1; }

/* A STEP YOU HAVE NOT ANSWERED LIGHTS NOTHING, and dimming alone does not
   achieve that: the checked tile wears a SOLID FILL, which at 0.55 still
   reads as the answer. So while the step is fresh the checked tile is
   given the unchecked skin outright -- same fill, same border, same weight
   as its neighbours. The radio underneath is untouched, so what the panel
   SENDS is unchanged; this is only what it draws. */
.holo-step.is-fresh .holo-segment input:checked + span,
.holo-step.is-fresh .holo-segment-btn.active {
  background: rgba(8, 34, 26, 0.5);
  border-color: rgba(140, 255, 210, 0.28);
  color: #a9e8d0;
  font-weight: normal;
  box-shadow: none;
}

/* The confirm beat. The pressed tile flares and only THEN does the panel
   move (190ms in holoAdvance) -- a pane that changes on the same frame as
   the click reads as the screen moving under you instead of as your answer
   landing. Brightness, not scale: these tiles sit in a grid and a growing
   one shoves its neighbours. On the span again, for the same reason as
   above -- a filter on a display:contents label paints nothing. */
.holo-confirming span,
.holo-confirming.holo-segment-btn,
.holo-confirming.beat-option {
  opacity: 1 !important;
  animation: holo-confirm 0.19s ease-out;
}
@keyframes holo-confirm {
  0%   { filter: brightness(2.4); }
  100% { filter: brightness(1); }
}
@media (prefers-reduced-motion: reduce) {
  .holo-confirming span,
  .holo-confirming.holo-segment-btn,
  .holo-confirming.beat-option { animation: none; }
}

/* The incoming step arrives from the right, the way a card is dealt onto
   the glass. Short and small: this fires on every NEXT, and a long slide
   turns a five-step flow into five seconds of waiting. */
.holo-step-in { animation: holo-step-in 0.26s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes holo-step-in {
  from { opacity: 0; transform: translateX(18px); }
  to   { opacity: 1; transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
  .holo-step-in { animation: none; }
}
/* The step counter sits BETWEEN back and next and takes the slack, so the
   two buttons stay pinned to the ends of the row however wide the panel
   gets. Courier, like every other readout in here. */
.holo-step-count {
  flex: 1 1 auto;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 4px;
  color: rgba(127, 212, 182, 0.75);
}
/* BACK on the first step is DEAD, not gone: a control that vanishes moves
   everything beside it, and the counter would jump on every first NEXT. */
#btn-holo-back.is-off {
  opacity: 0.25;
  pointer-events: none;
}
/* DEPLOY is the whole row when it is there -- it is the last press, and it
   should read as one. Next and back sit either side of the counter. */
#btn-confirm-create:not(.hidden) {
  flex: 1 1 auto;
  text-align: center;
  padding: 15px 16px;
  font-size: 20px;
  letter-spacing: 3px;
  background: rgba(8, 34, 26, 0.45);
  border: 1px solid rgba(140, 255, 210, 0.45);
}
#btn-confirm-create:not(.hidden):hover {
  background: rgba(120, 255, 200, 0.14);
  border-color: #b8ffe4;
  box-shadow: 0 0 22px rgba(120, 255, 200, 0.35);
  transform: translate(-2px, -2px);
}
/* ---- the answer sheet ----
   Label left, value right, one line each. A definition list because that
   is what it is, and because the two columns then need no markup of their
   own to line up. */
#holo-review {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 9px 18px;
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1.5px;
}
#holo-review dt {
  color: rgba(127, 212, 182, 0.7);
  white-space: nowrap;
}
#holo-review dd {
  margin: 0;
  color: #b8ffe4;
  text-align: right;
  /* A typed server name can be thirty characters; it ellipsizes rather
     than pushing the label off the plate. overflow HIDDEN, not clip --
     see the note on #prod-sample-box: ellipsis is not obliged to apply
     to a clip container. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.holo-actions {
  display: flex;
  align-items: center;
  gap: 22px;
  margin-top: 6px;
  padding-top: 18px;
  border-top: 1px solid rgba(140, 255, 210, 0.3);
}
.holo-btn {
  background: none;
  border: none;
  padding: 8px 4px;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 2px;
  color: #7fd4b6;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), text-shadow 0.15s, color 0.15s;
}
.holo-btn:hover {
  color: #dcfff2;
  transform: translate(-2px, -2px) scale(1.05);
  text-shadow: 0 0 12px rgba(140, 255, 210, 0.8);
}
.holo-btn:active { transform: scale(0.94); transition-duration: 0.06s; }
.holo-btn-primary {
  font-size: 20px;
  color: #b8ffe4;
  text-shadow: 0 0 10px rgba(140, 255, 210, 0.6);
}
.holo-btn-primary:hover {
  text-shadow: 0 0 14px rgba(180, 255, 230, 1), 0 0 34px rgba(120, 255, 200, 0.6);
}
/* Reference-style full-width deploy bar at the panel's foot. */
.holo-btn-block {
  display: block;
  width: 100%;
  padding: 17px 16px;
  text-align: center;
  font-size: 23px;
  letter-spacing: 3px;
  background: rgba(8, 34, 26, 0.45);
  border: 1px solid rgba(140, 255, 210, 0.45);
}
.holo-btn-block:hover {
  background: rgba(120, 255, 200, 0.14);
  border-color: #b8ffe4;
  box-shadow: 0 0 22px rgba(120, 255, 200, 0.35);
  transform: translate(-2px, -2px);
}
.holo-btn-dim { font-size: 14px; color: #64b39a; }
/* The abort rides the header's right edge, tab-area style. */
#holo-head { align-items: center; }
#holo-head #holo-kata { margin-left: auto; }
/* Tall content scrolls INSIDE the glass; head/actions stay pinned. */
#holo-panel {
  max-height: calc(88 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
}
#holo-body {
  flex: 1 1 auto;
  /* 0 is the flex-shrink floor this has always had; the var raises it to
     the TALLEST step of the current mode (holoFitBody) so the centred
     panel does not resize around its own middle on every NEXT. Same rule
     the leaderboard plate follows: fixture, not balloon. */
  min-height: var(--holo-body-min, 0px);
  overflow-y: auto;
  padding-right: 6px;
}
#holo-body::-webkit-scrollbar { width: 7px; }
#holo-body::-webkit-scrollbar-track { background: transparent; }
#holo-body::-webkit-scrollbar-thumb { background: rgba(140, 255, 210, 0.3); }
#holo-body::-webkit-scrollbar-thumb:hover { background: rgba(140, 255, 210, 0.55); }
#createlobby-status {
  min-height: 22px;
  margin: 12px 0 0;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 1px;
  color: #ffcc66;
}

/* ============================================================
   BEAT SHOWCASE -- the listen phase as a full-bleed stage.
   Track title + clickable artist/gang chips over a live audio
   visualizer, vote rig (massive number + massive arrows + LOCK IN)
   on the right. FILTER motion is OFF here (no-glitch-in-game rule);
   the visualizer canvas is the one sanctioned per-frame draw, and it
   only runs while this screen is up.
   ============================================================ */
body:has(#screen-listen.active) { max-width: none; padding: 0; }
#screen-listen.active {
  /* HEIGHT, not just min-height. With only a minimum, the screen grew to fit
     its content -- so a long chat made it 5700px tall, the stage's 1fr row
     grew with it, and the chat log was handed 5200px of height. Its
     `overflow-y: auto` then had nothing to scroll, because it was never
     smaller than its contents. The page scrolled instead of the chat.
     Pinning the screen to the viewport is what gives the whole flex chain
     something finite to divide up, which is what finally makes the log the
     thing that scrolls. */
  height: calc(100 * var(--dvh, 1vh));
  max-height: calc(100 * var(--dvh, 1vh));
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 36px;
  background:
    repeating-linear-gradient(0deg, transparent 0 52px, rgba(60, 255, 170, 0.035) 52px 53px),
    repeating-linear-gradient(90deg, transparent 0 52px, rgba(60, 255, 170, 0.035) 52px 53px),
    radial-gradient(ellipse 120% 100% at 50% 0%, #05140c 0%, #020a06 55%, #010503 100%);
}

/* ---- top strip ---- */
/* One grid: [ TIME REMAINING | the track | (empty) ]. The third column
   is an intentional spacer that mirrors the first, so the title stays
   optically centred on screen instead of being shoved right by the
   timer. Nothing is placed in it. */
#bs-top {
  display: grid;
  grid-template-columns: minmax(300px, 23%) 1fr minmax(300px, 23%);
  align-items: start;
  gap: 30px;
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
}
#bs-left {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-left: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px); /* nudged in off the screen edge */
  min-width: 0;
}
#bs-mode {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  color: #86ffc2;
  text-shadow: 0 0 14px rgba(80, 255, 180, 0.55);
  white-space: nowrap;
}
#bs-mode-kata { font-family: 'Courier New', monospace; font-size: 14px; color: rgba(140, 255, 205, 0.55); letter-spacing: 5px; }
/* TIME REMAINING -- the big number on the left. Sized to fill its column
   as far as it can go; the spacer column opposite guarantees it can never
   crowd the title no matter how wide the text gets. */
#bs-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(64px, calc(8.4 * var(--dvw, 1vw)), 132px);
  font-weight: bold;
  line-height: 1;
  letter-spacing: 1px;
  white-space: nowrap;
  color: #eaffe3;
  text-shadow: 0 0 26px rgba(120, 255, 180, 0.7), 0 0 80px rgba(60, 255, 170, 0.3);
}
/* last 10s of the slot runs hot */
#bs-timer.bs-urgent {
  color: #ffd98c;
  text-shadow: 0 0 22px rgba(255, 190, 80, 0.8), 0 0 70px rgba(255, 170, 40, 0.35);
}
/* final 5s: RED NEON, and it shakes. Rigid steps, not a smooth wobble --
   same language as the FILTER's tick. */
#bs-timer.bs-critical {
  color: #ff4d4d;
  text-shadow:
    0 0 8px #ff2d2d,
    0 0 26px rgba(255, 45, 45, 0.9),
    0 0 60px rgba(255, 20, 20, 0.55),
    0 0 110px rgba(255, 0, 0, 0.3);
  /* Two things at once: the rigid shake, and the light cutting out like a
     failing neon tube. steps(1) on the flicker so it's a hard ON/OFF, not
     a fade -- and an irregular rhythm so it never reads as a loop. */
  animation:
    bs-timer-panic 0.34s steps(2, end) infinite,
    bs-timer-flicker 1.7s steps(1, end) infinite;
}
@keyframes bs-timer-panic {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-3px, 1px); }
  40%  { transform: translate(3px, -1px); }
  60%  { transform: translate(-2px, -1px); }
  80%  { transform: translate(2px, 1px); }
  100% { transform: translate(0, 0); }
}
@keyframes bs-timer-flicker {
  0%, 6%    { opacity: 1; }
  7%, 8%    { opacity: 0.12; }   /* blink out */
  9%, 20%   { opacity: 1; }
  21%, 22%  { opacity: 0.3; }
  23%, 44%  { opacity: 1; }
  45%, 47%  { opacity: 0.08; }   /* longer dropout */
  48%, 62%  { opacity: 1; }
  63%, 64%  { opacity: 0.35; }
  65%, 82%  { opacity: 1; }
  83%, 85%  { opacity: 0.1; }
  86%, 100% { opacity: 1; }
}
#bs-progress { font-size: 17px; letter-spacing: 4px; color: rgba(159, 255, 207, 0.7); }

/* ---- title + byline (the middle column) ----
   min-width:0 is load-bearing: a grid item defaults to min-content width,
   so a long nowrap title pushed the column past the viewport instead of
   ellipsising. Without this a wordy track name breaks the whole header. */
#bs-head { text-align: center; min-width: 0; }
#bs-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(54px, calc(7.6 * var(--dvw, 1vw)), 116px);
  /* 1.15, was 1.05: this display face's descenders (the long
     underscore-tails) physically left the line box and sat on the byline
     plate below. The extra leading keeps them inside the title's own row. */
  line-height: 1.15;
  letter-spacing: 5px;
  color: #eaffe3;
  text-shadow: 0 0 18px rgba(80, 255, 180, 0.6), 0 0 70px rgba(60, 255, 170, 0.25);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#bs-byline {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
  margin-top: 18px;
}
/* Only ever visible when the track is NOT playing -- either the browser
   blocked it (a reload during the showcase throws away the interaction that
   permits audio) or the file wouldn't load. Amber rather than the room's
   green, because it is asking for something, and it sits under the byline so
   it cannot cover the title or the vote rig. */
#bs-play-notice {
  font-family: 'Courier New', monospace;
  font-size: clamp(13px, calc(1.3 * var(--dvw, 1vw)), 17px);
  letter-spacing: 4px;
  text-align: center;
  margin: 14px 0 0;
  padding: 8px 18px;
  color: #ffd08a;
  border: 1px solid rgba(255, 190, 110, 0.45);
  background: rgba(60, 40, 10, 0.35);
  animation: bs-notice-pulse 1.8s ease-in-out infinite;
}
#bs-play-notice[data-kind='error'] {
  color: #ffb0a4;
  border-color: rgba(255, 140, 120, 0.45);
  background: rgba(60, 20, 16, 0.35);
  animation: none; /* nothing to act on, so nothing to draw the eye */
}
@keyframes bs-notice-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}
@media (prefers-reduced-motion: reduce) {
  #bs-play-notice { animation: none; }
}
#bs-by { font-family: 'Courier New', monospace; font-size: 22px; letter-spacing: 7px; color: rgba(140, 255, 205, 0.6); }
.bs-chip {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(30px, calc(3.1 * var(--dvw, 1vw)), 44px);
  letter-spacing: 3px;
  padding: 10px 26px 8px;
  color: #9fffcf;
  background: rgba(10, 40, 26, 0.6);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(130, 255, 205, 0.45);
  clip-path: polygon(10px 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.18s ease-out;
  /* The hover pop scales a CLIPPED, 1px-bordered box, and a fractional
     scale re-rasterizes the clip edge exactly on top of the hairline --
     the border lines visibly vanished while hovered. Promoted, the scale
     is compositor work over a cached texture and the lines ride along
     (the frames' animate-transform-never-the-clip rule, HUD edition).
     will-change: TRANSFORM, not filter -- the Gecko poison is filter. */
  will-change: transform;
}
.bs-chip:hover, .bs-chip:focus-visible {
  transform: translate(-3px, -3px) scale(1.07);
  background: rgba(16, 62, 40, 0.85);
  color: #eaffe3;
  text-shadow:
    1px 1px 0 rgba(28, 130, 80, 0.9), 2px 2px 0 rgba(22, 104, 64, 0.75),
    3px 3px 0 rgba(16, 78, 48, 0.6), 4px 4px 0 rgba(11, 56, 34, 0.45),
    0 0 10px rgba(80, 255, 180, 0.8), 0 0 26px rgba(60, 255, 170, 0.5);
}
.bs-chip-gang { color: #ffd257; border-color: rgba(255, 210, 87, 0.5); background: rgba(48, 36, 8, 0.55); }
.bs-chip-gang:hover, .bs-chip-gang:focus-visible {
  background: rgba(74, 56, 12, 0.85);
  color: #ffe9a8;
  text-shadow:
    1px 1px 0 rgba(150, 110, 20, 0.9), 2px 2px 0 rgba(120, 88, 16, 0.75),
    3px 3px 0 rgba(90, 66, 12, 0.6), 4px 4px 0 rgba(64, 47, 9, 0.45),
    0 0 10px rgba(255, 210, 87, 0.8), 0 0 26px rgba(255, 210, 87, 0.45);
}

/* ---- stage: visualizer + vote rig ---- */
#bs-stage {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: stretch;
  gap: clamp(22px, calc(3 * var(--dvw, 1vw)), 48px);
}
#bs-viz-wrap {
  flex: 1 1 auto;
  min-width: 0;
  position: relative;
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(130, 255, 205, 0.3);
  clip-path: polygon(0 14px, 14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%);
  background: linear-gradient(180deg, rgba(3, 16, 10, 0.9), rgba(1, 8, 5, 0.95));
}
#bs-viz { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* the rig: a vertical slot-machine -- arrow / NUMBER / arrow / LOCK IN */
#bs-rig {
  flex: 0 0 clamp(190px, calc(17 * var(--dvw, 1vw)), 250px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
/* ---- the vote fader ----
   One analog channel-strip fader, the kind you'd find on a desk console:
   brushed plate, engraved scale, a slit with a ribbed cap riding it. The
   throw runs 1 (bottom) to 10 (top); --fader-pos is 0 at the top of the
   throw and 1 at the bottom, set by bsSetNum() so the cap, the LED fill
   and the number always agree. Heat colouring rides the same --heat-*
   variables as the number above it -- the whole strip warms as one object. */
#bs-fader {
  --bsf-cap-h: 34px;
  position: relative;
  width: clamp(120px, calc(11.5 * var(--dvw, 1vw)), 156px);
  height: clamp(240px, calc(32 * var(--dvh, 1vh)), 380px);
  display: flex;
  flex-direction: column;
  padding: 9px 9px 12px;
  background:
    repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.016) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, rgba(16, 34, 24, 0.94), rgba(6, 14, 10, 0.96));
  border: 1px solid rgba(130, 255, 205, 0.35);
  clip-path: polygon(0 14px, 14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%);
  box-shadow: inset 0 0 34px rgba(0, 0, 0, 0.55);
  cursor: grab;
  user-select: none;
  touch-action: none;
  outline: none;
}
#bs-fader.bsf-grab { cursor: grabbing; }
#bs-fader:focus-visible { border-color: rgba(134, 255, 194, 0.9); }
#bs-fader.locked { opacity: 0.5; cursor: default; pointer-events: none; }

.bsf-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 1.3px;
  color: rgba(159, 255, 207, 0.7);
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(130, 255, 205, 0.16);
  margin-bottom: 8px;
}
.bsf-head-label em { font-style: normal; opacity: 0.55; }
.bsf-leds { display: flex; gap: 5px; }
.bsf-led-dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: rgba(120, 140, 130, 0.25);
  border: 1px solid rgba(0, 0, 0, 0.6);
}
/* SIGNAL breathes whenever the fader is live; PEAK slams red only on a 10.
   Straight off the reference desk: the two lamps above every channel. */
.bsf-led-signal { animation: bsf-signal 2.2s ease-in-out infinite; }
#bs-fader.locked .bsf-led-signal { animation: none; }
@keyframes bsf-signal {
  0%, 100% { background: rgba(60, 120, 80, 0.4); box-shadow: none; }
  50% { background: #58ff9e; box-shadow: 0 0 8px rgba(88, 255, 158, 0.8); }
}
#bs-fader.bsf-peak .bsf-led-peak {
  background: #ff5240;
  box-shadow: 0 0 10px rgba(255, 82, 64, 0.9), 0 0 22px rgba(255, 82, 64, 0.5);
}

.bsf-body {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: 0 6px;
  min-height: 0;
}
/* Scale columns get half a cap of padding so each tick's centre lines up
   with where the CAP CENTRE actually travels (the cap's throw is inset by
   its own height; the ticks have to shrink to the same span or 10 and 1
   would sit past the cap's reachable ends). */
.bsf-scale {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: calc(var(--bsf-cap-h) / 2 - 5px) 0;
}
.bsf-tick {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  height: 10px;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 0.5px;
  color: rgba(159, 255, 207, 0.55);
  font-variant-numeric: tabular-nums;
}
.bsf-tick::after {
  content: '';
  width: 7px;
  height: 1px;
  background: rgba(130, 255, 205, 0.4);
}
.bsf-scale-r .bsf-tick { justify-content: flex-start; }
.bsf-scale-r .bsf-tick::after { display: none; }
.bsf-scale-r .bsf-tick::before {
  content: '';
  width: 4px;
  height: 1px;
  background: rgba(130, 255, 205, 0.22);
}
/* 10 and 1 are the destinations, not just steps -- mark them. */
.bsf-tick[data-hot] { color: rgba(255, 170, 90, 0.85); }
.bsf-tick[data-cold] { color: rgba(140, 200, 255, 0.8); }

.bsf-track {
  position: relative;
  width: 24px;
}
/* The slit: a machined groove, darker than the plate it's cut into. */
.bsf-slit {
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 6px;
  transform: translateX(-50%);
  background: linear-gradient(90deg, #000 0%, #0a120d 45%, #000 100%);
  border: 1px solid rgba(0, 0, 0, 0.9);
  box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(130, 255, 205, 0.08);
}
/* LED strip filling the slit from the bottom up to the cap, in the current
   heat colour. This is the "how much" read at a glance. */
.bsf-glow {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 3px;
  transform: translateX(-50%);
  height: calc((1 - var(--fader-pos, 0.5)) * (100% - var(--bsf-cap-h)) + var(--bsf-cap-h) / 2);
  background: var(--heat-col, #86ffc2);
  box-shadow: 0 0 10px hsl(var(--heat-hue, 150deg) 100% 60% / 0.7), 0 0 26px hsl(var(--heat-hue, 150deg) 100% 55% / 0.35);
  transition: height 0.1s ease-out, background 0.2s;
}
/* The cap. Ribbed grip, hairline indicator in the heat colour across the
   middle -- the one line on the whole strip that says EXACTLY where you
   are, same as the reference hardware. */
.bsf-cap {
  position: absolute;
  left: 50%;
  top: calc(var(--fader-pos, 0.5) * (100% - var(--bsf-cap-h)));
  width: 38px;
  height: var(--bsf-cap-h);
  transform: translateX(-50%);
  background:
    repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0 1px, transparent 1px 5px),
    linear-gradient(180deg, #2c3a32 0%, #131c17 48%, #232f28 52%, #0b110d 100%);
  border: 1px solid rgba(150, 255, 210, 0.3);
  clip-path: polygon(0 4px, 4px 0, calc(100% - 4px) 0, 100% 4px, 100% calc(100% - 4px), calc(100% - 4px) 100%, 4px 100%, 0 calc(100% - 4px));
  box-shadow: 0 6px 14px rgba(0, 0, 0, 0.7);
  transition: top 0.1s ease-out;
}
.bsf-cap::after {
  content: '';
  position: absolute;
  left: 2px;
  right: 2px;
  top: 50%;
  height: 3px;
  transform: translateY(-50%);
  background: var(--heat-col, #86ffc2);
  box-shadow: 0 0 8px hsl(var(--heat-hue, 150deg) 100% 60% / 0.9);
  transition: background 0.2s;
}
/* House extrusion pop on hover, pressed flat while actually dragging. */
#bs-fader:hover:not(.locked) .bsf-cap {
  transform: translateX(-50%) translate(-2px, -2px);
  border-color: rgba(134, 255, 194, 0.85);
  filter: drop-shadow(2px 2px 0 rgba(16, 78, 48, 0.9)) drop-shadow(0 0 10px rgba(80, 255, 180, 0.4));
}
#bs-fader.bsf-grab .bsf-cap {
  transform: translateX(-50%);
  filter: none;
  transition: top 0.06s ease-out;
}

/* ---- 1 snows ----
   The cold mirror of the fire: drifting flakes around the number while the
   vote sits at rock bottom. Same wrapper as the flame so they play in the
   same place; bs-min comes and goes from bsSetNum(). */
#bs-snow {
  position: absolute;
  left: -46px;
  right: -46px;
  top: -34px;
  height: 170px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.45s ease;
}
#bs-rig.bs-min #bs-snow { opacity: 1; }
#bs-snow i {
  position: absolute;
  top: -6px;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: #e8f6ff;
  box-shadow: 0 0 6px rgba(170, 225, 255, 0.9), 0 0 14px rgba(120, 190, 255, 0.4);
  opacity: 0;
  animation: bsf-snowfall 3s linear infinite;
}
@keyframes bsf-snowfall {
  0% { transform: translate(0, 0) scale(1); opacity: 0; }
  12% { opacity: 0.9; }
  80% { opacity: 0.7; }
  100% { transform: translate(var(--sway, 12px), 165px) scale(0.6); opacity: 0; }
}
#bs-snow i:nth-child(1)  { left: 4%;  --sway: 14px;  animation-duration: 2.6s; animation-delay: -0.3s; }
#bs-snow i:nth-child(2)  { left: 12%; --sway: -10px; animation-duration: 3.4s; animation-delay: -1.8s; width: 3px; height: 3px; }
#bs-snow i:nth-child(3)  { left: 21%; --sway: 18px;  animation-duration: 2.9s; animation-delay: -0.9s; }
#bs-snow i:nth-child(4)  { left: 30%; --sway: -14px; animation-duration: 3.8s; animation-delay: -2.6s; width: 5px; height: 5px; }
#bs-snow i:nth-child(5)  { left: 38%; --sway: 8px;   animation-duration: 2.4s; animation-delay: -1.2s; width: 3px; height: 3px; }
#bs-snow i:nth-child(6)  { left: 47%; --sway: -18px; animation-duration: 3.1s; animation-delay: -0.5s; }
#bs-snow i:nth-child(7)  { left: 55%; --sway: 12px;  animation-duration: 3.6s; animation-delay: -2.1s; width: 3px; height: 3px; }
#bs-snow i:nth-child(8)  { left: 63%; --sway: -8px;  animation-duration: 2.7s; animation-delay: -1.5s; }
#bs-snow i:nth-child(9)  { left: 71%; --sway: 16px;  animation-duration: 3.3s; animation-delay: -0.1s; width: 5px; height: 5px; }
#bs-snow i:nth-child(10) { left: 79%; --sway: -12px; animation-duration: 2.5s; animation-delay: -2.4s; }
#bs-snow i:nth-child(11) { left: 86%; --sway: 10px;  animation-duration: 3.7s; animation-delay: -1.0s; width: 3px; height: 3px; }
#bs-snow i:nth-child(12) { left: 93%; --sway: -16px; animation-duration: 2.8s; animation-delay: -0.7s; }
#bs-snow i:nth-child(13) { left: 26%; --sway: 20px;  animation-duration: 3.9s; animation-delay: -3.1s; width: 3px; height: 3px; }
#bs-snow i:nth-child(14) { left: 60%; --sway: -20px; animation-duration: 3.0s; animation-delay: -1.9s; }
@media (prefers-reduced-motion: reduce) {
  #bs-snow i { animation: none; }
  .bsf-led-signal { animation: none; }
}
/* ---- the heat scale ----
   1 is cold and dead, 10 is on fire, and every step between moves
   continuously. --vote-heat runs 0..1 and is set once per vote in JS;
   everything below is written in terms of it, so the hue, the brightness
   and the glow all travel together instead of being ten hand-picked
   colours that jump.
   The hue does the work: 190deg (ice blue) down to 12deg (ember red) as
   heat rises. Saturation and lightness climb with it, so cold isn't just
   blue -- it's dim and lifeless, which is the half people forget. */
#bs-rig {
  --vote-heat: 0.444; /* matches the default vote of 5 */
  /* These three are written by bsSetNum() from BS_HEAT_STOPS, a three-anchor
     ramp -- the straight calc() line that used to live here put vote 5 on
     hue 111 (a pure, yellowish green) which read as a different colour to
     everything else on the screen. The values below are the vote-5 anchor
     exactly, so the FIRST paint (before any vote lands) is already the UI
     green rather than flashing the old colour. */
  --heat-hue: 149.8deg;
  --heat-sat: 100%;
  --heat-lum: 76.3%;
  --heat-col: hsl(var(--heat-hue) var(--heat-sat) var(--heat-lum));
  --heat-glow: calc(14px + 40px * var(--vote-heat));
  --heat-bloom: calc(30px + 90px * var(--vote-heat));
}
/* ---- 10 catches fire ----
   Black blobs rising through a solid orange plate; blur fuses them and a
   huge contrast crushes the soft edges back to hard ones, which is what
   turns blurred circles into flame tongues.
   mix-blend-mode: screen is doing something load-bearing here -- the
   technique relies on its own black masking being invisible, which only
   holds on a pure black page. Screen makes black contribute nothing, so
   the flame sits on THIS screen's dark-green stage without dragging a
   black box along with it. */
/* ==================== RESULTS ====================
   The podium, counting up from third. Deliberately oversized: this is the
   payoff screen, and at the old scale it read like a settings dialog. */
body:has(#screen-results.active) { max-width: none; padding: 0; }
#screen-results.active {
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  padding: 0;
  background: radial-gradient(ellipse at 50% 30%, rgba(20, 50, 26, 0.5), rgba(2, 6, 3, 0.98) 68%);
}
#rs-stage {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(28px, calc(3.4 * var(--dvh, 1vh)), 52px);
  /* calc(2 * var(--dvw, 1vw)), not calc(3 * var(--dvw, 1vw)). The podium's min-content is ~1506px and will not shrink,
     and `vw` is measured against the VIEWPORT -- which includes the
     scrollbar -- while the page only has the document width to play with.
     At ~1600px those 16px were the difference, and the results screen grew a
     horizontal scrollbar. Narrowing the padding buys the room back without
     reflowing the podium itself. */
  padding: calc(4 * var(--dvh, 1vh)) calc(2 * var(--dvw, 1vw));
}

#rs-reveal.hidden, #rs-rest-wrap.hidden, #rs-actions.hidden { display: none; }

/* ---- the podium ---- */
#rs-reveal { display: flex; flex-direction: column; align-items: center; gap: clamp(26px, calc(3.2 * var(--dvh, 1vh)), 48px); width: 100%; }
#rs-podium {
  display: grid;
  /* 3rd | 1st | 2nd -- the winner is centre and tallest, which is what
     makes it a podium rather than a sorted list. 1st gets a wider column
     as well as a taller plate; matching widths made it read as three
     equal cards with one recoloured. */
  grid-template-columns: 1fr 1.42fr 1fr;
  align-items: end;
  gap: clamp(18px, calc(1.8 * var(--dvw, 1vw)), 34px);
  width: min(1560px, calc(95 * var(--dvw, 1vw)));
}
.rs-slot {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 30px 22px 26px;
  text-align: center;
  background: rgba(8, 22, 12, 0.75);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.28);
  clip-path: polygon(0 20px, 20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%);
  /* Each slot is empty and inert until its moment; no pre-flash of the
     answer before it lands. */
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.45s ease-out, transform 0.55s cubic-bezier(0.16, 1, 0.3, 1);
}
.rs-slot.rs-in { opacity: 1; transform: none; }
.rs-slot-1 {
  padding: 46px 26px 40px;
  border-color: rgba(255, 215, 94, 0.65);
  background: rgba(30, 24, 6, 0.72);
  box-shadow: 0 0 60px rgba(255, 200, 70, 0.12), inset 0 0 70px rgba(255, 200, 70, 0.06);
}
.rs-place {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(24px, calc(1.9 * var(--dvw, 1vw)), 34px);
  letter-spacing: 5px;
  color: rgba(134, 255, 102, 0.7);
}
.rs-slot-1 .rs-place { font-size: clamp(34px, calc(2.7 * var(--dvw, 1vw)), 50px); color: #ffd75e; text-shadow: 0 0 20px rgba(255, 215, 94, 0.6); }
.rs-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(38px, calc(3.6 * var(--dvw, 1vw)), 64px);
  letter-spacing: 1px;
  color: #eafff0;
  max-width: 100%;
  /* `clip`, not `hidden` -- text-overflow:ellipsis only needs overflow to
     be non-visible, and clip is the one value in that family that takes an
     overflow-clip-margin. The hover glow reaches 60px (90px for the
     winner); hidden would guillotine it in a flat rectangle right at the
     text edge the moment it lit up -- the exact bug already fixed once on
     #bs-title. Same fix, same reason, applied here. */
  overflow: clip;
  overflow-clip-margin: 64px;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* Padding cancelled by an equal negative margin: purely a layout no-op
     now that overflow-clip-margin does the real work, kept so the box's
     hit-area/hover target doesn't shrink. */
  padding: 0.18em 0.34em;
  margin: -0.18em -0.34em;
  /* SAME three shadow layers as the hover state, just dark. A shadow list
     only interpolates against a list of equal length -- going from "none"
     to three layers can't animate at all, so the glow used to snap on.
     This is what made the hover feel broken rather than slow. */
  text-shadow:
    0 0 10px rgba(134, 255, 102, 0),
    0 0 26px rgba(134, 255, 102, 0),
    0 0 60px rgba(134, 255, 102, 0);
}
.rs-slot-1 .rs-name {
  font-size: clamp(42px, calc(4.3 * var(--dvw, 1vw)), 78px);
  color: #fff6d8;
  text-shadow:
    0 0 26px rgba(255, 215, 94, 0.5),
    0 0 44px rgba(255, 200, 70, 0),
    0 0 90px rgba(255, 200, 70, 0);
}
/* Names open the player's profile, so they have to LOOK like they do --
   the glow building is the whole affordance.
   No scale(): re-rasterising 78px display type every frame is what made
   this shimmer. It lifts instead, which the compositor handles cleanly,
   and the light ignites over a third of a second rather than snapping. */
.rs-name.rs-clickable, .rs-rname.rs-clickable {
  cursor: pointer;
  transition:
    color 0.34s ease-out,
    text-shadow 0.34s ease-out,
    transform 0.34s cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}
.rs-name.rs-clickable:hover {
  color: #ffffff;
  transform: translateY(-3px);
  text-shadow:
    0 0 10px rgba(134, 255, 102, 0.85),
    0 0 26px rgba(134, 255, 102, 0.5),
    0 0 60px rgba(134, 255, 102, 0.28);
}
.rs-slot-1 .rs-name.rs-clickable:hover {
  text-shadow:
    0 0 16px rgba(255, 236, 170, 0.9),
    0 0 44px rgba(255, 200, 70, 0.55),
    0 0 90px rgba(255, 200, 70, 0.3);
}
/* Same matched-layer rule as the podium names -- transparent layers in the
   resting state so the glow has something to interpolate FROM. */
.rs-rname.rs-clickable {
  text-shadow: 0 0 12px rgba(134, 255, 102, 0), 0 0 30px rgba(134, 255, 102, 0);
}
.rs-rname.rs-clickable:hover {
  color: #ffffff;
  transform: translateY(-2px);
  text-shadow: 0 0 12px rgba(134, 255, 102, 0.8), 0 0 30px rgba(134, 255, 102, 0.4);
}
.rs-track {
  font-family: 'Courier New', monospace;
  font-size: clamp(15px, calc(1.05 * var(--dvw, 1vw)), 20px);
  letter-spacing: 1px;
  color: rgba(150, 210, 145, 0.85);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rs-slot-1 .rs-track { font-size: clamp(18px, calc(1.35 * var(--dvw, 1vw)), 26px); color: rgba(255, 235, 175, 0.9); }
.rs-score { margin-top: 10px; font-family: 'SD CyberPunk City', 'Courier New', monospace; color: #86ff66; }
.rs-score b { font-size: clamp(46px, calc(4.4 * var(--dvw, 1vw)), 76px); font-weight: normal; }
.rs-score span { font-size: clamp(17px, calc(1.2 * var(--dvw, 1vw)), 24px); opacity: 0.6; margin-left: 3px; }
.rs-slot-1 .rs-score { color: #ffd75e; }
.rs-slot-1 .rs-score b { font-size: clamp(64px, calc(6.2 * var(--dvw, 1vw)), 108px); text-shadow: 0 0 30px rgba(255, 215, 94, 0.55); }
.rs-votes { font-family: 'Courier New', monospace; font-size: clamp(12px, calc(0.85 * var(--dvw, 1vw)), 16px); letter-spacing: 2px; color: rgba(140, 190, 135, 0.7); }

/* ---- the per-slot snippet visualizer ----
   Each placer's own track plays as they land, and this is the picture of
   it. Fades in with the audio rather than snapping on, so the sound and
   the bars arrive as one event. */
.rs-viz {
  width: 100%;
  height: 54px;
  margin-top: 12px;
  opacity: 0;
  transition: opacity 0.8s ease-out;
}
.rs-slot-1 .rs-viz { height: 78px; }
.rs-viz.rs-viz-on { opacity: 1; }

/* ---- the rest of the field ---- */
/* Fades in as a block once the podium is done, instead of appearing
   abruptly under it. */
/* ---------- THE SAMPLE CREDIT ----------
   Sample Flip is the one mode built on somebody else's record. That person
   has no account here and never saw the round -- the only thing the game can
   give them is this, said at size, in front of everyone who just spent
   twenty minutes inside their work.
   It had markup and drawing code and NO stylesheet at all, so it rendered as
   raw HTML: labels welded to their values ("124BPM C MINORKEY"), an empty
   avatar box, everything piled up on the left. That is what this is fixing.
   Amber throughout, matching the waveform ink rsDrawFlip already uses --
   this plate belongs to the crate, not to the green results chassis. */
#rs-flip {
  --flip: #ffc14d;
  --flip-soft: rgba(255, 193, 77, 0.5);
  --flip-dim: rgba(255, 193, 77, 0.16);
  position: relative;
  /* Matches #rs-podium exactly. Not wider, and not a different unit: the
     podium's min-content does not shrink, and calc(2 * var(--dvw, 1vw)) of stage padding is
     already the entire margin this screen has against a horizontal
     scrollbar (see #rs-stage). */
  width: min(1560px, calc(95 * var(--dvw, 1vw)));
  /* NO margin. #rs-stage is a flex column with its own gap, so a margin
     here does not replace that gap, it adds to it -- 52px of intended
     spacing became 78px, and on a laptop that is the difference between
     the screen fitting and the page growing a scrollbar. */
  padding: 2px; /* the gradient edge below shows through this */
  background: linear-gradient(150deg, var(--flip-soft), rgba(255, 193, 77, 0.06) 55%, transparent);
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
  animation: rs-flip-land 0.9s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* The real ground, inset over the gradient -- same faked-border trick the
   lobby panel uses, so the plate reads as one cut slab rather than a box
   with a border drawn on it. */
#rs-flip::before {
  content: '';
  position: absolute;
  inset: 2px;
  background: linear-gradient(150deg, rgba(26, 20, 8, 0.96), rgba(10, 12, 10, 0.98));
  clip-path: polygon(0 0, calc(100% - 21px) 0, 100% 21px, 100% 100%, 21px 100%, 0 calc(100% - 21px));
  pointer-events: none;
}
#rs-flip > * { position: relative; z-index: 1; }

#rs-flip-tag {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(15px, calc(1.15 * var(--dvw, 1vw)), 21px);
  letter-spacing: 7px;
  text-align: center;
  color: var(--flip);
  opacity: 0.8;
  padding: clamp(12px, calc(1.8 * var(--dvh, 1vh)), 20px) 20px 0;
  text-shadow: 0 0 14px var(--flip-dim);
}
#rs-flip-tag .rs-kata {
  font-size: 0.62em;
  letter-spacing: 3px;
  opacity: 0.55;
}

/* Waveform behind, credit in front. The shape of the record is the
   atmosphere here -- it should not compete with the name. */
/* Vertical sizing is vh-bound, horizontal is vw-bound. Everything here used
   to key off width alone, so on a short wide screen (a laptop, which is most
   of them) the panel stayed at full height and pushed the standings off the
   bottom of the page. */
#rs-flip-body {
  position: relative;
  display: grid;
  justify-items: center;
  align-items: center;
  padding: clamp(14px, calc(2.4 * var(--dvh, 1vh)), 34px) clamp(24px, calc(3 * var(--dvw, 1vw)), 52px) clamp(16px, calc(2.6 * var(--dvh, 1vh)), 36px);
}
#rs-flip-wave {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0.4;
  pointer-events: none;
  /* Fades out at both ends so it reads as ambience rather than a chart
     with hard edges sitting behind the type. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
#rs-flip-id {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  width: 100%;
}

#rs-flip-name {
  font-family: 'Courier New', monospace;
  font-size: clamp(13px, calc(1.05 * var(--dvw, 1vw)), 18px);
  letter-spacing: 5px;
  text-transform: uppercase;
  color: rgba(255, 224, 170, 0.62);
  margin-bottom: 14px;
}

/* The handle is the point of the whole panel, so it is the biggest thing
   on it -- bigger than the sample's own title. */
/* The name gets its own plate. Floating straight on the waveform, the
   handle was competing with the loudest part of its own record for
   legibility -- this darkens the ground under it and gives the credit an
   object to sit on, so it reads as a nameplate rather than a caption. */
#rs-flip-cred {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, calc(1.3 * var(--dvw, 1vw)), 24px);
  min-width: 0;
  max-width: 100%;
  padding: clamp(8px, calc(1.4 * var(--dvh, 1vh)), 18px) clamp(24px, calc(2.6 * var(--dvw, 1vw)), 46px);
  background:
    linear-gradient(180deg, rgba(255, 193, 77, 0.09), rgba(255, 193, 77, 0.03)),
    rgba(12, 9, 3, 0.82);
  border-top: 1px solid rgba(255, 193, 77, 0.35);
  border-bottom: 1px solid rgba(255, 193, 77, 0.35);
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
  box-shadow: 0 0 40px rgba(255, 193, 77, 0.1);
}
#rs-flip-av {
  flex: none;
  display: grid;
  place-items: center;
  /* min() of a width and a height term, so it gives way on whichever axis
     is actually short rather than only on narrow screens. */
  width: clamp(38px, min(calc(4 * var(--dvw, 1vw)), calc(7 * var(--dvh, 1vh))), 66px);
  aspect-ratio: 1;
  border-radius: 50%;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, calc(1.9 * var(--dvw, 1vw)), 32px);
  line-height: 1;
  color: #1a1408;
  background: radial-gradient(circle at 35% 30%, #ffd98a, var(--flip) 70%);
  box-shadow: 0 0 0 2px rgba(255, 193, 77, 0.25), 0 0 24px var(--flip-dim);
}
#rs-flip-handle {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  /* Same idea as the avatar: bounded by height as well as width, so a wide
     but short window does not get 62px type it has no room for. */
  font-size: clamp(26px, min(calc(3.6 * var(--dvw, 1vw)), calc(6.4 * var(--dvh, 1vh))), 62px);
  font-weight: normal;
  letter-spacing: 2px;
  line-height: 1.05;
  color: var(--flip);
  text-shadow: 0 0 16px var(--flip-soft), 0 0 44px var(--flip-dim);
  overflow-wrap: anywhere;
}

/* No BPM/key cells here any more. The crate screen already showed the
   numbers to everyone who dug through it, and on this panel they competed
   with the only thing it is for. */

/* Nobody to credit. Says so plainly and drops the gold rather than
   dressing up a blank -- inventing a name for someone's work would be
   worse than admitting we don't have it (see sampleLibrary.js). */
#rs-flip.rs-flip-uncred {
  --flip: #c9d6cd;
  --flip-soft: rgba(220, 235, 225, 0.35);
  --flip-dim: rgba(220, 235, 225, 0.12);
}
#rs-flip.rs-flip-uncred #rs-flip-av {
  background: rgba(220, 235, 225, 0.16);
  color: rgba(220, 235, 225, 0.75);
  box-shadow: none;
}
#rs-flip.rs-flip-uncred #rs-flip-handle { text-shadow: none; opacity: 0.75; }
/* The plate stays -- the panel should hold its shape whether or not there
   is a name for it -- but it loses the gold and the glow. */
#rs-flip.rs-flip-uncred #rs-flip-cred {
  background:
    linear-gradient(180deg, rgba(220, 235, 225, 0.05), rgba(220, 235, 225, 0.02)),
    rgba(10, 12, 10, 0.8);
  border-top-color: rgba(220, 235, 225, 0.18);
  border-bottom-color: rgba(220, 235, 225, 0.18);
  box-shadow: none;
}

/* Lands slightly later than the standings so it reads as its own beat in
   the sequence, not part of the scoreboard. */
@keyframes rs-flip-land {
  from { opacity: 0; transform: translateY(22px) scale(0.985); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  #rs-flip { animation: none; }
}
@media (max-width: 760px) {
  #rs-flip-body {
    grid-template-columns: minmax(0, 1fr);
    justify-items: center;
    text-align: center;
  }
  #rs-flip-cred { justify-content: center; }
}

#rs-rest-wrap {
  width: min(1560px, calc(95 * var(--dvw, 1vw)));
  animation: rs-fade-up 0.85s cubic-bezier(0.16, 1, 0.3, 1) both;
}
#rs-actions { display: flex; justify-content: center; animation: rs-fade-up 0.85s cubic-bezier(0.16, 1, 0.3, 1) 0.18s both; }
@keyframes rs-fade-up {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}
#rs-rest-head {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, calc(1.6 * var(--dvw, 1vw)), 30px);
  letter-spacing: 5px;
  color: rgba(134, 255, 102, 0.75);
  padding-bottom: 10px;
  border-bottom: 1px solid rgba(134, 255, 102, 0.2);
}
#rs-rest-head .rs-kata { font-size: clamp(14px, calc(1 * var(--dvw, 1vw)), 18px); color: rgba(134, 255, 102, 0.4); letter-spacing: 3px; }
#rs-rest { list-style: none; margin: 0; padding: 0; }
#rs-rest li {
  display: grid;
  grid-template-columns: 60px minmax(0, 1fr) minmax(0, 1.4fr) 90px;
  align-items: center;
  gap: 18px;
  padding: 15px 10px;
  font-family: 'Courier New', monospace;
  font-size: clamp(18px, calc(1.25 * var(--dvw, 1vw)), 24px);
  color: #cdeecb;
  border-bottom: 1px solid rgba(134, 255, 102, 0.08);
  /* staggered per row, index set inline -- the list assembles rather than
     dropping in all at once */
  animation: rs-fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: calc(0.24s + var(--rs-i, 0) * 0.08s);
}
.rs-rank { color: rgba(134, 255, 102, 0.55); letter-spacing: 2px; }
/* Same clip-not-hidden fix as .rs-name above: this one had NO compensation
   at all, so its hover glow (up to 58px, see .rs-rname.rs-clickable:hover)
   was clipped flat at the text edge every time. */
.rs-rname { overflow: clip; overflow-clip-margin: 60px; text-overflow: ellipsis; white-space: nowrap; color: #eafff0; }
.rs-rtrack { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: rgba(150, 210, 145, 0.7); font-size: clamp(15px, calc(1 * var(--dvw, 1vw)), 19px); }
.rs-rscore { text-align: right; color: #86ff66; }
#rs-rest li.rs-noshow { opacity: 0.4; }

.rs-btn {
  padding: 20px 60px 16px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(28px, calc(2.1 * var(--dvw, 1vw)), 38px);
  letter-spacing: 5px;
  color: #041008;
  background: linear-gradient(180deg, #7dffb9, #39d97f);
  border: none;
  clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.16s;
}
.rs-btn:hover { transform: translate(-3px, -3px) scale(1.05); filter: brightness(1.12); }
.rs-btn:active { transform: scale(0.97); }

#bs-rig { position: relative; }
/* The flame is measured against THIS box, not the rig. Anchored to the
   rig's centre it sat down by the lower arrow, because the rig is a tall
   column of four controls and the number is only one of them. */
#bs-num-wrap {
  position: relative;
  width: 100%;
  display: flex;
  justify-content: center;
}
#bs-fire {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 260px;
  height: 260px;
  /* narrowed horizontally -- a circular flame reads as a fireball, a
     squeezed one reads as a flame */
  /* lifted off dead-centre so the flame's dense base sits at the digits'
     baseline and the tongues lick up across them */
  transform: translate(-50%, calc(-50% - 46px)) scale(0.62, 1);
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0;
  transition: opacity 0.4s ease-out;
  z-index: 0;
}
/* Only at a perfect 10, and only while it's still yours to change --
   once locked the rig goes gold and the fire would fight it. */
#bs-rig.bs-max #bs-fire { opacity: 1; }
#bs-fire-core {
  position: relative;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  background: #ff9900;
  filter: blur(13px) contrast(26);
  /* The thick black border is the mask that shapes the plate into a
     flame; the open bottom is where it burns from. */
  border: 130px solid #000;
  border-bottom-color: transparent;
  border-radius: 40%;
}
#bs-fire-core i {
  position: absolute;
  top: 260px;
  left: -30px;
  width: 60px;
  background: #000;
  border-radius: 100%;
  animation: bs-burn-rise 950ms linear infinite;
}
/* Baked-out values instead of Sass random(): every blob gets its own
   height, lane and speed, and a negative delay so they start mid-flight
   rather than all launching together on the first frame. */
#bs-fire-core i:nth-child(1)  { height: 34px; margin-left: -104px; animation-duration:  820ms; animation-delay: -1700ms; }
#bs-fire-core i:nth-child(2)  { height: 12px; margin-left:  -71px; animation-duration: 1150ms; animation-delay:  -400ms; }
#bs-fire-core i:nth-child(3)  { height: 45px; margin-left:  -38px; animation-duration:  640ms; animation-delay: -1100ms; }
#bs-fire-core i:nth-child(4)  { height: 22px; margin-left:   -8px; animation-duration: 1280ms; animation-delay: -2400ms; }
#bs-fire-core i:nth-child(5)  { height: 38px; margin-left:   26px; animation-duration:  730ms; animation-delay:  -900ms; }
#bs-fire-core i:nth-child(6)  { height: 16px; margin-left:   58px; animation-duration: 1040ms; animation-delay: -2000ms; }
#bs-fire-core i:nth-child(7)  { height: 29px; margin-left:   92px; animation-duration:  900ms; animation-delay:  -600ms; }
#bs-fire-core i:nth-child(8)  { height: 41px; margin-left:  -88px; animation-duration: 1190ms; animation-delay: -1400ms; }
#bs-fire-core i:nth-child(9)  { height: 19px; margin-left:  -52px; animation-duration:  690ms; animation-delay:  -300ms; }
#bs-fire-core i:nth-child(10) { height: 33px; margin-left:  -22px; animation-duration: 1090ms; animation-delay: -2200ms; }
#bs-fire-core i:nth-child(11) { height: 25px; margin-left:   12px; animation-duration:  780ms; animation-delay: -1000ms; }
#bs-fire-core i:nth-child(12) { height: 47px; margin-left:   44px; animation-duration:  990ms; animation-delay: -1900ms; }
#bs-fire-core i:nth-child(13) { height: 14px; margin-left:   78px; animation-duration: 1230ms; animation-delay:  -700ms; }
#bs-fire-core i:nth-child(14) { height: 36px; margin-left: -116px; animation-duration:  860ms; animation-delay: -1500ms; }
#bs-fire-core i:nth-child(15) { height: 21px; margin-left:  -64px; animation-duration: 1140ms; animation-delay:  -200ms; }
#bs-fire-core i:nth-child(16) { height: 43px; margin-left:    2px; animation-duration:  670ms; animation-delay: -1200ms; }
#bs-fire-core i:nth-child(17) { height: 27px; margin-left:   66px; animation-duration: 1060ms; animation-delay: -2100ms; }
#bs-fire-core i:nth-child(18) { height: 31px; margin-left:  108px; animation-duration:  840ms; animation-delay:  -800ms; }
@keyframes bs-burn-rise {
  to { transform: translateY(-360px); }
}
/* A user who has asked for less motion gets the glow without the
   animation -- the fire is decoration, not information. */
@media (prefers-reduced-motion: reduce) {
  #bs-fire-core i { animation: none; }
}

#bs-num {
  /* above the flame */
  position: relative;
  z-index: 1;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(110px, calc(12 * var(--dvw, 1vw)), 170px);
  line-height: 1;
  padding: 6px 0 2px;
  min-width: 72%;
  text-align: center;
  color: var(--heat-col);
  text-shadow:
    0 0 var(--heat-glow) hsl(var(--heat-hue) 100% 60% / calc(0.35 + 0.5 * var(--vote-heat))),
    0 0 var(--heat-bloom) hsl(var(--heat-hue) 100% 55% / calc(0.15 + 0.45 * var(--vote-heat)));
  /* The colour eases between votes rather than snapping, which is what
     makes it feel like temperature changing instead of a value swapping. */
  transition: color 0.28s ease-out, text-shadow 0.28s ease-out;
  user-select: none;
}
/* Top of the scale: the number stops sitting still and burns. Two
   offset flickers at different speeds so it never pulses on a beat you
   can predict -- fire doesn't. */
#bs-num.bs-ablaze {
  animation: bs-burn 1.5s ease-in-out infinite, bs-burn-jitter 0.19s steps(2, end) infinite;
}
@keyframes bs-burn {
  0%, 100% { filter: brightness(1); }
  38% { filter: brightness(1.28); }
  64% { filter: brightness(1.1); }
}
@keyframes bs-burn-jitter {
  0%, 100% { text-shadow:
    0 0 var(--heat-glow) hsl(var(--heat-hue) 100% 62% / 0.9),
    0 0 var(--heat-bloom) hsl(28deg 100% 52% / 0.55); }
  50% { text-shadow:
    0 0 calc(var(--heat-glow) * 1.35) hsl(calc(var(--heat-hue) + 12deg) 100% 66% / 0.95),
    0 0 calc(var(--heat-bloom) * 1.2) hsl(18deg 100% 50% / 0.6); }
}
/* Bottom of the scale: dead still, and drained of light rather than
   merely blue. */
#bs-num.bs-frozen {
  filter: saturate(0.55) brightness(0.82);
}
/* The fader plate carries the same temperature as the number, so the rig
   reads as one object at one heat rather than a coloured number sitting on
   an unrelated green console strip. */
#bs-fader:not(.locked) {
  border-color: hsl(var(--heat-hue) var(--heat-sat) 60% / calc(0.3 + 0.45 * var(--vote-heat)));
  transition: border-color 0.28s ease-out, opacity 0.2s;
}
#bs-num.pop { animation: bs-num-pop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes bs-num-pop {
  0% { transform: scale(1.22); }
  100% { transform: scale(1); }
}
#bs-lock {
  width: 66%;
  margin-top: 14px;
  padding: 13px 0 11px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  letter-spacing: 3px;
  color: #041008;
  background: linear-gradient(180deg, #7dffb9, #39d97f);
  border: none;
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.18s ease-out;
}
#bs-lock:hover:not(:disabled), #bs-lock:focus-visible:not(:disabled) {
  transform: translate(-3px, -3px) scale(1.05);
  filter: brightness(1.15);
  box-shadow: 4px 4px 0 rgba(20, 90, 55, 0.7), 0 0 26px rgba(80, 255, 180, 0.55);
}
#bs-lock:active:not(:disabled) { transform: scale(0.97); }
/* Locked: the rig goes hot gold and holds -- ignite then cool. */
#bs-lock.locked {
  background: linear-gradient(180deg, #ffe9a8, #ffc93d);
  cursor: default;
  animation: bs-lock-ignite 0.5s ease-out;
}
@keyframes bs-lock-ignite {
  0% { box-shadow: 0 0 0 0 rgba(255, 220, 120, 0.9), 0 0 60px rgba(255, 210, 87, 0.9); filter: brightness(2); }
  100% { box-shadow: 0 0 0 26px rgba(255, 220, 120, 0), 0 0 0 rgba(255, 210, 87, 0); filter: brightness(1); }
}
#bs-num.locked { color: #ffe9a8; text-shadow: 0 0 22px rgba(255, 210, 87, 0.8), 0 0 80px rgba(255, 210, 87, 0.35); }
#bs-rig-status {
  margin-top: 12px;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 2px;
  color: rgba(160, 255, 210, 0.75);
  min-height: 20px;
  text-align: center;
}

/* Secondary to LOCK IN -- smaller, dimmer, no fill -- so rating the track
   stays the room's default action and skipping reads as the fallback. */
#bs-skip {
  width: 66%;
  margin-top: 10px;
  padding: 8px 0 7px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  color: rgba(160, 255, 210, 0.75);
  background: transparent;
  border: 1px solid rgba(120, 255, 190, 0.35);
  clip-path: polygon(8px 0, 100% 0, calc(100% - 8px) 100%, 0 100%);
  cursor: pointer;
  transition: border-color 0.15s ease-out, color 0.15s ease-out;
}
#bs-skip:hover:not(:disabled), #bs-skip:focus-visible:not(:disabled) {
  border-color: rgba(160, 255, 210, 0.7);
  color: #d8ffe8;
}
#bs-skip:active:not(:disabled) { transform: scale(0.97); }
#bs-skip.voted {
  color: rgba(255, 210, 87, 0.85);
  border-color: rgba(255, 210, 87, 0.5);
  cursor: default;
}
#bs-skip-tally {
  margin-left: 8px;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

/* your own track: rig swaps for a chill plate */
#bs-mine {
  flex: 0 0 clamp(220px, calc(20 * var(--dvw, 1vw)), 300px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  text-align: center;
}
#bs-mine.hidden { display: none; }
#bs-mine-big {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 42px;
  letter-spacing: 4px;
  color: #ffd257;
  text-shadow: 0 0 18px rgba(255, 210, 87, 0.6);
}
#bs-mine-sub { font-family: 'Courier New', monospace; font-size: 16px; letter-spacing: 3px; color: rgba(255, 226, 150, 0.6); }

#bs-status {
  margin-top: 18px;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 2px;
  color: rgba(160, 255, 210, 0.8);
  min-height: 22px;
  text-align: center;
}

/* The showcase's responsive rules now live at the very END of this file,
   below the appended ARENA block. They have to: a media query wins nothing
   from being a media query -- at equal specificity the LAST rule in the file
   wins, and the arena's base rules are appended after this point. Left here
   as a signpost so nobody moves them back. */

/* ============================================================
   DROP OFF ZONE -- the production phase as an industrial cargo dock.
   Different feel from the green screens on purpose: cold BLUE monitor
   glow, hazard striping, stencil energy. One giant drop pad, almost no
   words. FILTER motion is OFF here (no-glitch-in-game rule).
   ============================================================ */
body:has(#screen-production.active) { max-width: none; padding: 0; }
#screen-production.active {
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 34px;
  /* FLAT BASE ONLY. Every textured layer -- both grids and the vignette
     -- lives on ::before below so it can be FADED IN as the pane settles.
     While they sat here on the element's own background they arrived
     fully formed the instant the pane was visible, which is what read as
     "the filter just appears". A background can't be faded per-layer;
     a pseudo-element can. */
  background: #071b2f;
}
/* The dock's texture: grids + vignette, as ONE fadeable layer. A real
   element rather than a ::before -- pseudo-element animation is not
   reliably supported, and when it silently no-ops the texture snaps on
   at full strength instead of fading (exactly the pop this was meant to
   solve). It fades via the same staggered CSS animation as the rest of
   the furniture; see .cart-content-in > #dz-texture below. */
#dz-texture {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    /* the transition's green MACHINE grid, carried through */
    repeating-linear-gradient(0deg, transparent 0 46px, rgba(60, 255, 170, 0.055) 46px 47px),
    repeating-linear-gradient(90deg, transparent 0 46px, rgba(60, 255, 170, 0.055) 46px 47px),
    /* the dock's own blue monitor grid */
    repeating-linear-gradient(0deg, transparent 0 64px, rgba(90, 200, 255, 0.04) 64px 65px),
    repeating-linear-gradient(90deg, transparent 0 64px, rgba(90, 200, 255, 0.04) 64px 65px),
    /* and the vignette */
    radial-gradient(ellipse 130% 90% at 50% 12%, #0a2540 0%, #051426 48%, #02080f 100%);
}
/* Content rides above the texture layer. */
#screen-production.active > * { position: relative; z-index: 1; }
#screen-production.active > #dz-texture { position: absolute; z-index: 0; }

/* ---- top strip: title / counter / timer ---- */
#dz-top {
  display: flex;
  align-items: baseline;
  gap: 30px;
}
#dz-mode {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  letter-spacing: 3px;
  color: #8fd8ff;
  text-shadow: 0 0 14px rgba(90, 200, 255, 0.55);
}
#dz-kata { font-family: 'Courier New', monospace; font-size: 16px; letter-spacing: 6px; color: rgba(140, 215, 255, 0.5); }
/* ---- the clock, inside the pad above the headline ---- */
#dz-clock {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 22px;
  margin-bottom: 6px;
}
#prod-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(58px, calc(7.5 * var(--dvw, 1vw)), 104px);
  font-weight: bold;
  letter-spacing: 3px;
  line-height: 1;
  color: #eaf7ff;
  text-shadow: 0 0 26px rgba(120, 210, 255, 0.7), 0 0 80px rgba(90, 200, 255, 0.3);
}
/* Last 30s: the clock runs hot amber and pulses. */
#dz-pad.dz-urgent #prod-timer {
  color: #ffd98c;
  text-shadow: 0 0 26px rgba(255, 190, 80, 0.8), 0 0 80px rgba(255, 170, 40, 0.35);
  animation: dz-timer-pulse 1s ease-in-out infinite;
}
@keyframes dz-timer-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.045); }
}
#dz-count {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(30px, calc(3.3 * var(--dvw, 1vw)), 48px);
  letter-spacing: 2px;
  color: #8fd8ff;
  text-shadow: 0 0 12px rgba(90, 200, 255, 0.5);
  /* the shake below moves it; give it its own layer so the jitter can't
     force the flex row to re-layout on every frame */
  will-change: transform;
}
/* A track landed. The counter takes the hit and settles -- it does not
   simply blink to the new number. The impact is hard, the recovery
   decays, and the glow ignites and cools over the same span. */
#dz-count.dz-count-hit {
  animation: dz-count-hit 0.62s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes dz-count-hit {
  0%   { transform: translate(0, 0) scale(1);        color: #8fd8ff; text-shadow: 0 0 12px rgba(90, 200, 255, 0.5); }
  6%   { transform: translate(-7px, 2px) scale(1.22); color: #ffffff; text-shadow: 0 0 26px rgba(160, 230, 255, 0.95), 0 0 60px rgba(90, 200, 255, 0.6); }
  14%  { transform: translate(6px, -3px) scale(1.18); }
  24%  { transform: translate(-5px, -2px) scale(1.13); }
  36%  { transform: translate(4px, 2px) scale(1.09); }
  50%  { transform: translate(-3px, 1px) scale(1.06); color: #cdefff; }
  64%  { transform: translate(2px, -1px) scale(1.04); }
  78%  { transform: translate(-1px, 0) scale(1.02); }
  100% { transform: translate(0, 0) scale(1);        color: #8fd8ff; text-shadow: 0 0 12px rgba(90, 200, 255, 0.5); }
}

/* ---- THE DOCK'S BEAT DECK: artist-mode strip (beat + words + player) ----
   One plate, CUT IN HALF (owner call). Left half is the SONG: beat name,
   its producer (clickable credit, same wire as the crate's), BPM/KEY, and
   the deck -- play, the beat's REAL waveform as the scrub rail, time,
   DOWNLOAD as a plate. Right half is the WORDS in big lettering: they're
   the assignment, not a footnote. Replaces the naked <audio controls>
   element and its bare text link, the one native widget left on the
   screen. */
#prod-artist-box {
  display: flex;
  align-items: stretch;
  flex-wrap: wrap;
  gap: 14px 24px;
  padding: 14px 22px;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 2px;
  color: #cdeeff;
  background: rgba(10, 40, 68, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 210, 255, 0.3);
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
}
#prod-artist-box.hidden { display: none; }
/* The song's half. Wide floor so on a narrow window the halves stack
   instead of crushing the waveform to a sliver. */
#pab-beat {
  flex: 1 1 480px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 10px;
}
/* The words' half. The seam between the halves lives on this side; when a
   narrow window stacks the halves it reads as a left accent bar on the
   words block, same mark as the creator card's works list. */
#pab-words {
  flex: 1 1 300px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 6px;
  border-left: 1px solid rgba(120, 210, 255, 0.25);
  padding-left: 24px;
}
.pab-brief {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 14px;
  min-width: 0;
}
/* The title, at title size (owner: "make the person name bigger and the
   title bigger") -- it's what you're writing to, not a caption. */
#prod-beat {
  overflow-wrap: anywhere;
  font-size: clamp(20px, calc(1.5 * var(--dvw, 1vw)), 26px);
  font-weight: bold;
  letter-spacing: 1px;
  color: #e8f7ff;
}
/* BPM / KEY, the crate's meta cells in the dock's cyan. */
.pab-meta {
  font-size: 15px;
  letter-spacing: 1px;
  color: rgba(205, 238, 255, 0.85);
  white-space: nowrap;
}
.pab-meta b { font-weight: bold; }
.pab-meta i {
  font-style: normal;
  font-size: 11px;
  letter-spacing: 2px;
  color: rgba(120, 210, 255, 0.55);
  margin-left: 5px;
}
/* THE ASSIGNMENT. Big enough to write to from across the desk. */
#prod-words {
  font-size: clamp(22px, calc(1.8 * var(--dvw, 1vw)), 34px);
  font-weight: bold;
  letter-spacing: 3px;
  line-height: 1.25;
  text-transform: uppercase;
  color: #e6f6ff;
  overflow-wrap: anywhere;
  text-shadow: 0 0 14px rgba(120, 210, 255, 0.35);
}
/* The credit. Underline + hover glow ride in on .credit-link when there is
   a creator card behind it; UNCREDITED stays plain. */
#prod-beat-cred {
  font-size: 17px;
  letter-spacing: 2px;
  color: rgba(150, 210, 255, 0.85);
}
#prod-beat-cred::before {
  content: 'PROD. ';
  color: rgba(120, 210, 255, 0.5);
}
.dz-strip-label {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 19px;
  color: #6ad1ff;
  letter-spacing: 3px;
}
/* The deck row. Same anatomy as the booth's card transport, in the dock's
   cyan instead of the booth's blue. */
#prod-beat-player {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
#pb-play {
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  font-size: 14px;
  line-height: 1;
  color: #8fd8ff;
  background: rgba(120, 210, 255, 0.08);
  border: 1px solid rgba(120, 210, 255, 0.35);
  cursor: pointer;
  transition: background 0.16s ease-out, border-color 0.16s ease-out;
}
#pb-play:hover { background: rgba(120, 210, 255, 0.2); border-color: #8fd8ff; }
#pb-scrub {
  position: relative;
  flex: 1;
  min-width: 0;
  /* border-box: 24px of rail + 18px finger padding -- the old booth rack's
     scrub geometry, kept when that rack retired into the crate's wheel. */
  height: 42px;
  padding-block: 9px;
  background-image: linear-gradient(rgba(120, 210, 255, 0.09) 0 0);
  background-clip: content-box;
  cursor: pointer;
  touch-action: none;
}
#pb-wave {
  position: absolute;
  left: 0; right: 0;
  top: 9px; bottom: 9px;
  width: 100%;
  height: calc(100% - 18px);
  pointer-events: none;
}
#pb-fill {
  position: absolute;
  left: 0;
  top: 9px;
  bottom: 9px;
  width: 0;
  background: rgba(120, 210, 255, 0.28);
}
#pb-line {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 0;
  width: 3px;
  margin-left: -1.5px;
  background: #8fd8ff;
  box-shadow: 0 0 8px rgba(120, 210, 255, 0.9);
}
#pb-time {
  flex: none;
  font-size: 14px;
  letter-spacing: 1px;
  color: rgba(205, 238, 255, 0.6);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* You can't flip what you haven't got: the file is a required tool in this
   mode, so DOWNLOAD is a plate, not a text link. */
#pb-dl {
  flex: none;
  padding: 11px 18px;
  font-size: 13px;
  letter-spacing: 3px;
  color: #8fd8ff;
  text-decoration: none;
  border: 1px solid rgba(120, 210, 255, 0.35);
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
  transition: background 0.16s ease-out, border-color 0.16s ease-out, color 0.16s ease-out;
}
#pb-dl:hover, #pb-dl:focus-visible {
  color: #062033;
  background: #8fd8ff;
  border-color: #8fd8ff;
  outline: none;
}

/* ---- THE PAD ---- */
#dz-pad {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  cursor: pointer;
  background:
    /* corner brackets, drawn as gradient Ls in each corner */
    linear-gradient(#6ad1ff, #6ad1ff) left 26px top 26px / 46px 3px,
    linear-gradient(#6ad1ff, #6ad1ff) left 26px top 26px / 3px 46px,
    linear-gradient(#6ad1ff, #6ad1ff) right 26px top 26px / 46px 3px,
    linear-gradient(#6ad1ff, #6ad1ff) right 26px top 26px / 3px 46px,
    linear-gradient(#6ad1ff, #6ad1ff) left 26px bottom 26px / 46px 3px,
    linear-gradient(#6ad1ff, #6ad1ff) left 26px bottom 26px / 3px 46px,
    linear-gradient(#6ad1ff, #6ad1ff) right 26px bottom 26px / 46px 3px,
    linear-gradient(#6ad1ff, #6ad1ff) right 26px bottom 26px / 3px 46px,
    radial-gradient(ellipse 70% 60% at 50% 50%, rgba(40, 120, 190, 0.18), rgba(8, 30, 52, 0.55));
  background-repeat: no-repeat;
  border: 1px solid rgba(120, 210, 255, 0.35);
  clip-path: polygon(0 18px, 18px 0, calc(100% - 18px) 0, 100% 18px, 100% calc(100% - 18px), calc(100% - 18px) 100%, 18px 100%, 0 calc(100% - 18px));
  transition: border-color 0.2s ease-out, filter 0.2s ease-out;
}
/* hazard stripes along the pad's top and bottom edges */
#dz-pad::before, #dz-pad::after {
  content: '';
  position: absolute;
  left: 90px;
  right: 90px;
  height: 9px;
  background: repeating-linear-gradient(-45deg, rgba(255, 205, 60, 0.75) 0 14px, rgba(8, 14, 20, 0.9) 14px 28px);
  opacity: 0.55;
  pointer-events: none;
}
#dz-pad::before { top: 0; }
#dz-pad::after { bottom: 0; }

/* ---- WELCOMING: hover / drag-over ----
   The dock opens up for the file: corner brackets pull outward (the
   bay widening), the intake glow swells from the middle, the whole
   plate lifts a touch. .dz-hot (a file is actually over it) is the
   same gesture pushed further. */
#dz-pad:hover, #dz-pad:focus-visible, #dz-pad.dz-hot {
  border-color: rgba(150, 225, 255, 0.85);
  filter: drop-shadow(0 0 26px rgba(90, 200, 255, 0.4));
  background-position:
    left 14px top 14px, left 14px top 14px,
    right 14px top 14px, right 14px top 14px,
    left 14px bottom 14px, left 14px bottom 14px,
    right 14px bottom 14px, right 14px bottom 14px,
    center;
  background-size:
    62px 3px, 3px 62px, 62px 3px, 3px 62px,
    62px 3px, 3px 62px, 62px 3px, 3px 62px,
    auto;
}
#dz-pad:hover #dz-pad-glyph, #dz-pad.dz-hot #dz-pad-glyph {
  animation-duration: 1.1s; /* the beckon quickens */
  color: #bfeaff;
  text-shadow: 0 0 30px rgba(140, 220, 255, 0.95);
}
#dz-pad:hover .dz-intake, #dz-pad.dz-hot .dz-intake { opacity: 1; }
#dz-pad:hover #dz-pad-sub, #dz-pad.dz-hot #dz-pad-sub { color: rgba(190, 235, 255, 0.9); }
#dz-pad.dz-hot {
  filter: drop-shadow(0 0 40px rgba(120, 215, 255, 0.65)) brightness(1.12);
  border-color: rgba(190, 240, 255, 1);
}
#dz-pad.dz-hot .dz-intake { animation-duration: 1s; }

/* ---- LOADED: the bay stops advertising ----
   The welcome gesture exists to say "put something here". Once a track IS
   here it has nothing left to ask for, and an empty bay's glow bleeding
   around a staged card just looks like the UI is still waiting. Killed
   wholesale: the swell, the bracket spread, the intake pool and the
   glyph's beckon.
   Keyed off #dz-file.dz-file-loaded, which is set the moment a file is
   staged and lives INSIDE the pad -- so :has() reads it without any
   extra class plumbing in JS. */
#dz-pad:has(#dz-file.dz-file-loaded),
#dz-pad:has(#dz-file.dz-file-loaded):hover,
#dz-pad:has(#dz-file.dz-file-loaded):focus-visible {
  filter: none;
  border-color: rgba(120, 210, 255, 0.3);
  background-position:
    left 26px top 26px, left 26px top 26px,
    right 26px top 26px, right 26px top 26px,
    left 26px bottom 26px, left 26px bottom 26px,
    right 26px bottom 26px, right 26px bottom 26px,
    center;
  background-size:
    46px 3px, 3px 46px, 46px 3px, 3px 46px,
    46px 3px, 3px 46px, 46px 3px, 3px 46px,
    auto;
}
#dz-pad:has(#dz-file.dz-file-loaded) .dz-intake { opacity: 0; animation: none; }
#dz-pad:has(#dz-file.dz-file-loaded) #dz-pad-glyph {
  animation: none;
  color: #6ad1ff;
  text-shadow: 0 0 22px rgba(90, 200, 255, 0.8);
}
/* the intake glow: a soft breathing pool in the middle of the bay */
.dz-intake {
  position: absolute;
  inset: 12%;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(ellipse 60% 55% at 50% 55%, rgba(90, 200, 255, 0.22), transparent 70%);
  animation: dz-intake-breathe 2.6s ease-in-out infinite;
  transition: opacity 0.28s ease-out;
}
@keyframes dz-intake-breathe {
  0%, 100% { transform: scale(0.94); }
  50% { transform: scale(1.06); }
}
#dz-pad-glyph {
  font-size: 64px;
  line-height: 1;
  color: #6ad1ff;
  text-shadow: 0 0 22px rgba(90, 200, 255, 0.8);
  animation: dz-glyph-bob 2.2s ease-in-out infinite;
}
@keyframes dz-glyph-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(12px); }
}
#dz-pad-main {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(46px, calc(5.4 * var(--dvw, 1vw)), 74px);
  letter-spacing: 6px;
  color: #eaf7ff;
  text-shadow: 0 0 20px rgba(90, 200, 255, 0.65), 0 0 70px rgba(90, 200, 255, 0.25);
}
#dz-pad-sub {
  font-family: 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 4px;
  color: rgba(150, 220, 255, 0.6);
}
#dz-file {
  position: relative; /* anchors the eject button, which overhangs the card */
  min-height: 24px;
  max-width: min(680px, 78%);
  width: 100%;
}
#dz-file:empty { min-height: 24px; }

/* ---- CARGO CARD: the staged track, made physical ----
   Its real waveform, drawn from the file, plus name and size. A filename
   in plain text reads like a receipt; this reads like the machine is
   holding your track. */
.dz-cargo {
  position: relative;
  padding: 12px 16px 10px;
  background: rgba(6, 26, 46, 0.85);
  border: 1px solid rgba(120, 210, 255, 0.45);
  clip-path: polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px);
  box-shadow: 0 0 26px rgba(90, 200, 255, 0.25);
  animation: dz-cargo-in 0.42s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes dz-cargo-in {
  0%   { opacity: 0; transform: translateY(14px) scaleY(0.7); filter: brightness(2.4); }
  100% { opacity: 1; transform: none; filter: brightness(1); }
}
/* ---- the waveform doubles as the CROP TRACK ---- */
.dz-wave-stage {
  position: relative;
  width: 100%;
  height: 64px;
  touch-action: none; /* dragging a handle must not scroll the page */
}
.dz-wave {
  display: block;
  width: 100%;
  height: 64px;
}
/* everything OUTSIDE the selection is dimmed -- it won't be heard */
.dz-mask {
  position: absolute;
  top: 0;
  bottom: 0;
  background: rgba(3, 14, 26, 0.78);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.25s ease-out;
}
.dz-mask-l { left: 0; width: 0; }
.dz-mask-r { right: 0; left: 100%; }
.dz-croppable .dz-mask { opacity: 1; }
/* the kept region: a bright frame around what plays */
.dz-sel {
  position: absolute;
  top: 0;
  bottom: 0;
  pointer-events: none;
  border-top: 2px solid rgba(150, 225, 255, 0.85);
  border-bottom: 2px solid rgba(150, 225, 255, 0.85);
  background: rgba(90, 200, 255, 0.07);
  opacity: 0;
  transition: opacity 0.25s ease-out;
}
/* Grabbable once there's a real crop: drag it to slide the whole window
   along the track without resizing it. */
.dz-croppable .dz-sel { opacity: 1; pointer-events: auto; cursor: grab; }
.dz-croppable .dz-sel:hover { background: rgba(90, 200, 255, 0.13); }
.dz-sel-drag { cursor: grabbing; background: rgba(120, 215, 255, 0.18) !important; }
/* Edge markers: a single hairline of light with a small angular cap at
   each end -- an instrument's cursor, not a chunky grab bar. The wide
   hit area stays (easy to grab); only the drawn part is thin. */
.dz-handle {
  position: absolute;
  top: -9px;
  bottom: -9px;
  width: 18px;
  margin-left: -9px;
  cursor: ew-resize;
  opacity: 0;
  transition: opacity 0.25s ease-out;
}
.dz-croppable .dz-handle { opacity: 1; }
/* the hairline */
.dz-handle::before {
  content: '';
  position: absolute;
  left: 8px;
  top: 0;
  bottom: 0;
  width: 2px;
  background: #cdefff;
  box-shadow: 0 0 8px rgba(150, 225, 255, 0.9), 0 0 20px rgba(90, 200, 255, 0.5);
  transition: box-shadow 0.16s ease-out;
}
/* angular caps, top and bottom, pointing INTO the kept region */
.dz-handle::after {
  content: '';
  position: absolute;
  left: 3px;
  top: 0;
  width: 12px;
  height: 9px;
  background: #cdefff;
  box-shadow: 0 0 10px rgba(120, 215, 255, 0.7);
  clip-path: polygon(0 0, 100% 0, 50% 100%);
}
.dz-handle-l::after { clip-path: polygon(0 0, 100% 0, 0 100%); }
.dz-handle-r::after { clip-path: polygon(0 0, 100% 0, 100% 100%); }
/* mirrored cap along the bottom edge */
.dz-handle-l { --cap-flip: polygon(0 100%, 100% 100%, 0 0); }
.dz-handle-r { --cap-flip: polygon(0 100%, 100% 100%, 100% 0); }
.dz-handle-l .dz-cap-b { clip-path: polygon(0 100%, 100% 100%, 0 0); }
.dz-handle-r .dz-cap-b { clip-path: polygon(0 100%, 100% 100%, 100% 0); }
.dz-cap-b {
  position: absolute;
  left: 3px;
  bottom: 0;
  width: 12px;
  height: 9px;
  background: #cdefff;
  box-shadow: 0 0 10px rgba(120, 215, 255, 0.7);
}
.dz-handle:hover::before, .dz-handle-drag::before {
  background: #ffffff;
  box-shadow: 0 0 14px rgba(255, 255, 255, 1), 0 0 32px rgba(150, 225, 255, 0.9);
}
.dz-handle:hover::after, .dz-handle-drag::after,
.dz-handle:hover .dz-cap-b, .dz-handle-drag .dz-cap-b { background: #ffffff; }
.dz-handle-drag { cursor: grabbing; }
/* The decoded waveform used to carry drop-shadow(0 0 10px ...), which
   bloomed a blue halo around the whole canvas and smeared the bars into
   the card behind them. The bars are bright enough on their own. */
.dz-wave-real { filter: none; }

/* Playhead: a hairline showing where the preview is, draggable to scrub.
   Only appears once the file has decoded and the track is croppable. */
.dz-playhead {
  position: absolute;
  top: -3px;
  bottom: -3px;
  left: 0;
  width: 2px;
  margin-left: -1px;
  z-index: 6; /* over the selection fill, under the crop handles */
  background: #ffffff;
  box-shadow: 0 0 8px rgba(200, 240, 255, 0.9), 0 0 18px rgba(120, 215, 255, 0.5);
  cursor: ew-resize;
  display: none;
}
.dz-croppable .dz-playhead { display: block; }
/* The line itself is 2px, which is a miserable drag target -- this widens
   the GRAB area to 16px without widening what you see. */
.dz-playhead-grip {
  position: absolute;
  top: 0;
  bottom: 0;
  left: -7px;
  width: 16px;
}
/* the head: a small marker so the line has something to take hold of */
.dz-playhead::before {
  content: '';
  position: absolute;
  top: -4px;
  left: 50%;
  width: 9px;
  height: 7px;
  transform: translateX(-50%);
  background: #ffffff;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  box-shadow: 0 0 8px rgba(200, 240, 255, 0.8);
}
.dz-playhead:hover, .dz-playhead-drag {
  box-shadow: 0 0 12px rgba(255, 255, 255, 1), 0 0 26px rgba(120, 215, 255, 0.8);
}

/* eject: the card's X, top-right, riding just off the corner. Lives on
   #dz-file (not inside .dz-cargo) so the card's clip-path can't cut it. */
.dz-eject {
  position: absolute;
  top: -11px;
  right: -11px;
  z-index: 5;
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  font-size: 15px;
  line-height: 1;
  color: #ffd9d9;
  background: rgba(52, 12, 12, 0.96);
  border: 1px solid rgba(255, 120, 120, 0.6);
  clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.16s ease-out;
}
.dz-eject:hover, .dz-eject:focus-visible {
  transform: scale(1.14);
  background: rgba(120, 24, 24, 0.98);
  color: #fff;
  box-shadow: 0 0 16px rgba(255, 90, 90, 0.6);
}
.dz-eject:active { transform: scale(0.94); }

/* Preview: its own plate hanging off the bottom-left corner, like the
   eject opposite it -- but a SLANTED TAB rather than a cut-corner square.
   The octagon read as bulky at this size; the slant is the same shape
   language the modal buttons use and carries far less weight. Mounted on
   #dz-file so the card's clip-path can't slice it. */
.dz-preview {
  position: absolute;
  bottom: -13px;
  left: -13px;
  z-index: 5;
  width: 46px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  font-size: 13px;
  line-height: 1;
  color: #a8e4ff;
  background: rgba(8, 28, 46, 0.96);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 215, 255, 0.5);
  clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.16s ease-out, color 0.16s ease-out;
}
.dz-preview:hover, .dz-preview:focus-visible {
  transform: scale(1.12);
  background: rgba(22, 74, 116, 0.98);
  color: #fff;
}
.dz-preview:active { transform: scale(0.94); }
/* ---- PREVIEW VOLUME ----
   Rolls out from behind the play tab on hover. Continues the tab's slant
   so the two read as one object opening, not a tooltip landing on top.
   Monitoring only -- it never touches the uploaded file. */
.dz-vol {
  position: absolute;
  bottom: -13px;
  left: 34px; /* tucks under the tab's trailing slant */
  z-index: 4; /* behind the tab, so it emerges from under it */
  height: 28px;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 0 13px 0 16px;
  font-family: 'Courier New', monospace;
  white-space: nowrap;
  background: rgba(8, 28, 46, 0.96);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 215, 255, 0.5);
  border-left: none;
  clip-path: polygon(0 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
  opacity: 0;
  transform: translateX(-14px);
  pointer-events: none;
  transition: opacity 0.18s ease-out, transform 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Stays open while the cursor is ON it, and pinned open for the whole
   drag -- the thumb can easily be dragged off the plate. */
.dz-preview:hover + .dz-vol,
.dz-preview:focus-visible + .dz-vol,
.dz-preview-on + .dz-vol,
.dz-vol:hover,
.dz-vol.dz-vol-open {
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
}
.dz-vol-label {
  font-size: 9px;
  letter-spacing: 2px;
  color: rgba(150, 220, 255, 0.7);
}
/* A real <input type=range>, restyled: keyboard and screen readers get
   the control for free, which a div-and-pointer-handlers slider would
   have had to reimplement. The filled portion is painted with a gradient
   driven by --dz-vol, since the native track can't be split. */
.dz-vol-range {
  -webkit-appearance: none;
  appearance: none;
  width: 76px;
  height: 6px;
  margin: 0;
  background: linear-gradient(
    to right,
    #6ad1ff 0 var(--dz-vol, 80%),
    rgba(255, 255, 255, 0.12) var(--dz-vol, 80%) 100%);
  cursor: pointer;
}
.dz-vol-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 9px;
  height: 16px;
  background: #cdefff;
  box-shadow: 0 0 8px rgba(120, 215, 255, 0.8);
  /* angular cap, same language as the crop handles */
  clip-path: polygon(0 0, 100% 0, 100% 70%, 50% 100%, 0 70%);
  cursor: ew-resize;
}
.dz-vol-range::-moz-range-thumb {
  width: 9px;
  height: 16px;
  border: none;
  border-radius: 0;
  background: #cdefff;
  box-shadow: 0 0 8px rgba(120, 215, 255, 0.8);
  cursor: ew-resize;
}
.dz-vol-num {
  min-width: 34px;
  text-align: right;
  font-size: 12px;
  letter-spacing: 1px;
  color: #cdefff;
}
/* silenced: the plate says so plainly rather than leaving you to wonder
   why pressing play does nothing */
.dz-vol-muted .dz-vol-num { color: rgba(255, 150, 150, 0.9); }
.dz-vol-muted .dz-vol-label { color: rgba(255, 150, 150, 0.6); }

/* playing: the tab lights up, and breathes so it's obvious where the
   sound is coming from */
.dz-preview-on {
  color: #06202f;
  background: #8fdcff;
  border-color: #cdefff;
  animation: dz-preview-pulse 1.2s ease-in-out infinite;
}
@keyframes dz-preview-pulse {
  0%, 100% { filter: drop-shadow(0 0 4px rgba(143, 220, 255, 0.5)); }
  50% { filter: drop-shadow(0 0 12px rgba(143, 220, 255, 0.95)); }
}
/* 1fr | auto | 1fr -- the two outer tracks are equal, so the readout in
   the middle sits DEAD CENTRE of the card no matter how wide the file
   size on the right happens to be. (space-between left it hugging the
   left edge.) */
.dz-cargo-meta {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: baseline;
  gap: 16px;
  margin-top: 8px;
  font-family: 'Courier New', monospace;
  letter-spacing: 2px;
}
.dz-cargo-size {
  grid-column: 3;
  text-align: right;
  font-size: 14px;
  color: rgba(150, 220, 255, 0.75);
  transition: color 0.2s;
}
/* Over the upload limit: the size itself goes amber, and the line below
   says what happens about it. Amber, not red -- this is survivable (the
   crop gets uploaded instead), not a rejection. */
.dz-cargo-size.dz-size-over { color: #ffce6b; }
.dz-cargo-over {
  margin-top: 7px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 2px;
  text-align: center;
  color: #ffce6b;
  text-shadow: 0 0 14px rgba(255, 190, 80, 0.45);
  animation: dz-over-in 0.4s ease-out;
}
.dz-cargo-over.hidden { display: none; }
@keyframes dz-over-in {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: none; }
}
/* the live crop readout -- one colour, plain wording */
.dz-cargo-crop {
  grid-column: 2;
  text-align: center;
  font-size: 17px;
  font-weight: bold;
  letter-spacing: 2px;
  color: #ffd23c;
  text-shadow: 0 0 10px rgba(255, 210, 60, 0.45);
  white-space: nowrap;
}
/* once submitted, the card joins the green "secured" state */
#dz-pad.dz-received .dz-cargo { border-color: rgba(120, 255, 190, 0.5); box-shadow: 0 0 26px rgba(80, 255, 180, 0.22); }
#dz-pad.dz-received .dz-cargo-name { color: #8fffcb; }
/* ---- CARGO RECEIVED ----
   A genuinely different state, not a recolour: the bay CLOSES. Corner
   brackets clamp inward, the intake glow dies, the hazard stripes go
   green (dock secured), and the plate sits on a solid green wash. */
#dz-pad.dz-received {
  border-color: rgba(120, 255, 190, 0.75);
  cursor: pointer; /* still re-droppable to swap */
  background:
    linear-gradient(#5effb0, #5effb0) left 40px top 40px / 34px 3px,
    linear-gradient(#5effb0, #5effb0) left 40px top 40px / 3px 34px,
    linear-gradient(#5effb0, #5effb0) right 40px top 40px / 34px 3px,
    linear-gradient(#5effb0, #5effb0) right 40px top 40px / 3px 34px,
    linear-gradient(#5effb0, #5effb0) left 40px bottom 40px / 34px 3px,
    linear-gradient(#5effb0, #5effb0) left 40px bottom 40px / 3px 34px,
    linear-gradient(#5effb0, #5effb0) right 40px bottom 40px / 34px 3px,
    linear-gradient(#5effb0, #5effb0) right 40px bottom 40px / 3px 34px,
    radial-gradient(ellipse 70% 60% at 50% 50%, rgba(30, 150, 100, 0.28), rgba(6, 34, 24, 0.6));
  background-repeat: no-repeat;
  animation: dz-lock 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
/* the clamp: brackets snap in as the cargo locks */
@keyframes dz-lock {
  0%   { filter: brightness(2.2); }
  100% { filter: brightness(1); }
}
#dz-pad.dz-received::before,
#dz-pad.dz-received::after {
  background: repeating-linear-gradient(-45deg, rgba(94, 255, 176, 0.8) 0 14px, rgba(6, 20, 14, 0.9) 14px 28px);
  opacity: 0.7;
}
#dz-pad.dz-received .dz-intake { opacity: 0 !important; animation: none; }
#dz-pad.dz-received #dz-pad-glyph {
  color: #7dffb9;
  text-shadow: 0 0 26px rgba(80, 255, 180, 0.9);
  animation: none;
  transform: none;
}
#dz-pad.dz-received #dz-pad-main { color: #baffdc; text-shadow: 0 0 22px rgba(80, 255, 180, 0.7); }
#dz-pad.dz-received #dz-pad-sub { color: rgba(150, 255, 205, 0.75); }
#dz-pad.dz-received #dz-count { color: #8fffcb; text-shadow: 0 0 12px rgba(80, 255, 180, 0.55); }
/* hovering a received pad hints the swap without re-opening the bay */
#dz-pad.dz-received:hover { filter: brightness(1.12); }
#dz-pad.dz-received:hover #dz-pad-sub { color: #d6ffec; }

/* ---- SUBMIT: directly under the waveform, inside the pad ---- */
#btn-submit-track {
  width: min(420px, 60%);
  margin-top: 18px;
  padding: 16px 0 13px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 28px;
  letter-spacing: 4px;
  color: #04121f;
  background: linear-gradient(180deg, #9fe0ff, #4aa8e0);
  border: none;
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.18s ease-out;
}
#btn-submit-track:hover, #btn-submit-track:focus-visible {
  transform: translate(-3px, -3px) scale(1.05);
  filter: brightness(1.15);
  box-shadow: 4px 4px 0 rgba(24, 80, 130, 0.7), 0 0 26px rgba(90, 200, 255, 0.55);
}
#btn-submit-track:active { transform: scale(0.97); }
#prod-status {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 2px;
  color: #ffd23c;
  min-height: 22px;
  text-align: center;
}

/* ============================================================
   PAN DEPLOY -- the round-start transition.
   Both screens become 3D panes floating in the MACHINE void: the live
   lobby pane flies off LEFT, the game pane flies in from the RIGHT and
   settles flat into the player's POV. Sequenced by cartStart() in
   main.js. Perf rule (learned from the terminal circuits): transforms +
   opacity ONLY. No canvas loops, no blur() anywhere in this block.
   ============================================================ */

/* Backdrop: the MACHINE void the panes fly through. Opaque so the real
   screen swap (which happens mid-pan) can never peek through around the
   panes. Faint static grid; a plain layered gradient, painted once,
   never animated. */
#cart-overlay {
  position: fixed;
  inset: 0;
  z-index: 640; /* over everything ordinary, under the FILTER glass (700) */
  pointer-events: all; /* eat clicks -- the UI is mid-flight, nothing is real */
  background:
    repeating-linear-gradient(0deg, transparent 0 46px, rgba(60, 255, 170, 0.045) 46px 47px),
    repeating-linear-gradient(90deg, transparent 0 46px, rgba(60, 255, 170, 0.045) 46px 47px),
    radial-gradient(ellipse 110% 90% at 50% 30%, #041008 0%, #020805 55%, #010402 100%);
}

/* The green wash. Above the panes (650/652), below the FILTER glass
   (700). Purely decorative -- never eats a click. Opacity is driven by
   cartStart()'s WAAPI so it can hold through the flight and then bleed
   off over the incoming screen. */
#cart-wash {
  position: fixed;
  inset: 0;
  z-index: 660;
  pointer-events: none;
  opacity: 0;
  /* Dark BACKGROUND, not a dark everything. This layer sits ABOVE the
     flying panes, so its alpha is a veil over the whole screen -- the first
     dark version used 0.86-0.97 and blanketed the UI along with the
     backdrop, which is why the lobby went almost invisible.
     It is a thin tint now: barely there through the middle so the panes and
     the glitch field read straight through it, deepening only toward the
     edges where there is nothing to hide. The darkness comes from the
     overlay BEHIND everything (#cart-overlay), not from a sheet in front. */
  background:
    radial-gradient(ellipse 120% 85% at 50% 35%, transparent 40%, rgba(2, 12, 7, 0.5) 100%),
    radial-gradient(ellipse 130% 110% at 50% 42%,
      rgba(10, 44, 28, 0.2) 0%,
      rgba(4, 20, 12, 0.38) 55%,
      rgba(1, 7, 4, 0.62) 100%);
}
#cart-wash.hidden { display: none; }

/* NOTE: the arriving screen's layers are faded by ONE opacity animation
   on the screen element itself (cartFlyIn in main.js) -- that single
   fade carries its background, #dz-texture and every piece of furniture
   together, with the CRT glass fading on identical timing beside it.
   The old per-child stagger that used to live here was removed: four
   layers on four clocks is exactly how a layer ends up "popping". */

/* Shared pane treatment for BOTH flying screens. Fixed so the two can
   overlap the viewport at once without fighting the document flow; the
   FILTER's own tremble is stripped (a detached pane shouldn't inherit
   the glass shake -- and the flight transforms would fight the CSS
   animation for the transform channel anyway); the rim glow makes each
   one read as an OBJECT in the void. */
.cart-flying,
body.cart-panning .screen.active {
  position: fixed;
  inset: 0;
  animation: none !important;
  outline: 2px solid rgba(130, 255, 205, 0.7);
  outline-offset: -2px;
  box-shadow:
    0 0 34px rgba(60, 255, 170, 0.4),
    0 0 90px rgba(60, 255, 170, 0.18);
  overflow: hidden;
  will-change: transform;
}
/* The OUTGOING pane: keeps rendering after applyScreen() strips its
   .active mid-pan (display:none would vaporize it mid-flight), sits
   under the incoming pane, and gets the solid backing (it's a dead
   monitor -- it shouldn't show its own live colours).
   The INCOMING pane deliberately gets NO background override: it must
   fly in wearing its REAL colours so the green wash above it can fade
   off onto them. Forcing a flat green here (as this rule used to, with
   !important, for both panes) made the whole transition read as "green,
   green, green, SNAP to blue" the instant cleanup dropped the class --
   the wash was fading invisibly behind an opaque green pane. */
.cart-flying {
  display: block !important;
  z-index: 650 !important;
  background: #04120b !important;
}
/* The outgoing pane is a cloned GHOST now (see cartStart): a snapshot no
   re-render can repaint mid-flight. Dead to input -- the live UI it
   mirrors is hidden right behind it. */
.cart-ghost { pointer-events: none !important; }
/* The wager overlay's copy riding inside the ghost, so the locked frame
   is what the dying monitor shows. Restates the root styling the removed
   id owned; fixed-position resolves against the ghost (its filter/
   transform make it the containing block), so the locked frame recedes
   WITH the pane. Frozen solid -- a snapshot does not flicker, roar, or
   re-slam its chains. */
.cart-ghost-wager {
  position: fixed;
  inset: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(9, 4, 15, 0.94);
}
.cart-ghost-wager * { animation: none !important; transition: none !important; }
/* CRT DEATH static: the moment the countdown hits zero, the lobby's
   "monitor" dies -- this is the snow. An SVG turbulence still (painted
   once, no canvas) flickered by hard background jumps; the matching
   color-drain runs on the element's filter channel as WAAPI from
   cartStart() (a CSS animation here would be killed by the pane's own
   animation:none). Outgoing pane only -- the incoming screen is alive. */
.cart-flying::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)'/%3E%3C/svg%3E");
  mix-blend-mode: screen;
  animation: cart-noise 0.14s steps(2) infinite;
}
@keyframes cart-noise {
  0%   { background-position: 0 0; opacity: 0.34; }
  50%  { background-position: -68px 34px; opacity: 0.2; }
  100% { background-position: 42px -22px; opacity: 0.34; }
}
/* THE BODY COLUMN IS NOT RECREATED INSIDE THE PANE ANY MORE, and the rule
   that did it is gone rather than shortened. It existed because the panes
   are fixed full-viewport, which discards the page's centered 700px body
   column -- true while the beat vote, the pad, the cut and the results
   were narrow column screens. Every one of them is a full-bleed STAGE now
   (each carries its own `body:has(...) { max-width: none; padding: 0 }`),
   so the rule was handing them back a column they had already given up --
   and winning, since `body.cart-panning ....active` carries two classes
   against the screen's own one. MEASURED at 1280px: the beat rack laid out
   15px wide under 946px of side padding, which is the three beat cards
   'stacked on top of each other' the owner saw during PAN DEPLOY, snapping
   to their real 1121px the frame the class came off. A screen must look
   the same flying as it does landed -- the flight is the same furniture
   seen from further away, not a second layout. */
/* The INCOMING pane: parked off-right AT DEPTH the instant it goes
   .active (the swap is invisible -- no one-frame flash at center; pose
   matches cartFlyIn's first keyframe), then cartFlyIn's WAAPI flight
   takes over the transform and carousels it in. */
body.cart-panning .screen.active {
  z-index: 652;
  transform: translateX(calc(120 * var(--dvw, 1vw))) scale(0.62);
}

/* ---- stamp: slams on during ignition ---- */
#cart-stamp {
  position: absolute;
  top: calc(9 * var(--dvh, 1vh));
  left: 0;
  right: 0;
  z-index: 664;
  text-align: center;
  opacity: 0; /* cart-stamp-slam flips it on */
}
#cart-stamp.on { animation: cart-stamp-slam 0.34s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
#cart-stamp-main {
  display: block;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(34px, calc(5.4 * var(--dvw, 1vw)), 62px);
  letter-spacing: 6px;
  color: #baffdc;
  text-shadow: 0 0 18px rgba(80, 255, 180, 0.75), 0 0 60px rgba(60, 255, 170, 0.3);
}
#cart-stamp-kata {
  display: block;
  margin-top: 6px;
  font-family: 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 10px;
  color: rgba(140, 255, 205, 0.6);
}
@keyframes cart-stamp-slam {
  0%   { opacity: 0; transform: scale(2.4); }
  55%  { opacity: 1; transform: scale(0.94); }
  76%  { transform: scale(1.05); }
  100% { opacity: 1; transform: scale(1); }
}

/* ---------- MENU JUKEBOX ----------
   Bottom-right of the main menu. Collapsed it's a spinning record and a
   track name; hovering grows the plate to reveal the scrub bar and
   transport. The record only spins while audio is actually playing, so
   the widget never claims to be doing something it isn't. */
#jukebox {
  /* Matches the profile plate and the social button -- see #social-toggle. The
     album art counter-rotates (#jb-art) so only the CHROME shifts colour. */
  filter: hue-rotate(var(--logo-hue));
  position: fixed;
  right: 22px;
  bottom: var(--hpb-bottom);
  z-index: 690;
  font-family: 'Courier New', monospace;
}
#jukebox.hidden { display: none; }


#jb-head { min-width: 0; }
#jb-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 1px;
  color: #eafff0;
  white-space: nowrap;
  overflow: clip;
  overflow-clip-margin: 10px;
  text-overflow: ellipsis;
  text-shadow: 0 0 7px rgba(57, 255, 20, 0.35);
}
#jb-sub {
  display: flex;
  gap: 8px;
  margin-top: 3px;
  font-size: 11px;
  letter-spacing: 2px;
  color: #5a8f50;
  white-space: nowrap;
}
#jb-count { color: #45703d; }

/* Scrub + transport now live in #jb-flyout, a separate pane above the
   compact bar -- see the flyout rules further down. These two no longer
   need their own show/hide transitions; the flyout's single slide-up
   handles that for everything inside it at once. */
#jb-scrub {
  display: flex;
  align-items: center;
  gap: 9px;
}
.jb-time {
  width: 30px;
  overflow: hidden;
  font-size: 11px;
  letter-spacing: 1px;
  color: #6da762;
}
#jb-bar {
  position: relative;
  flex: 1;
  height: 4px;
  min-width: 0;
  background: rgba(255, 255, 255, 0.1);
  cursor: pointer;
}
#jb-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 0;
  background: #86ff66;
  box-shadow: 0 0 7px rgba(57, 255, 20, 0.55);
}
/* the grab target is 14px tall even though the line is 4px -- a 4px
   scrub bar is miserable to hit */
#jb-bar::after {
  content: '';
  position: absolute;
  inset: -6px 0;
}

#jb-controls {
  display: flex;
  align-items: center;
  /* Centred cluster, reference-player style -- the row only ever appears
     inside the open card now, where ragged-left read as unfinished. */
  justify-content: center;
  gap: 14px;
  margin-top: 12px;
}
.jb-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 26px;
  padding: 0;
  background: none;
  border: none;
  color: #86ff66;
  cursor: pointer;
  transition: transform 0.14s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.14s;
}
.jb-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.jb-btn:hover { transform: scale(1.18); color: #eafff0; }
.jb-btn:active { transform: scale(0.92); }
.jb-btn-sm { color: #5a8f50; }
.jb-btn-sm svg { width: 14px; height: 14px; }
/* shuffle lit = on */
.jb-btn-sm.jb-on { color: #39ff14; }
.jb-btn-main svg { width: 26px; height: 26px; fill: currentColor; stroke: none; }
/* one button, two glyphs -- whichever the state isn't is hidden */
.jb-ico-pause { display: none; }
#jukebox.jb-playing .jb-ico-play { display: none; }
#jukebox.jb-playing .jb-ico-pause { display: block; }
/* ---- volume ----
   Pushed to the right of the transport. A real <input type=range> so it's
   keyboard-reachable; the filled portion is a gradient driven by --jb-vol,
   since a native track can't be split. */
#jb-vol {
  display: flex;
  align-items: center;
  gap: 6px;
  /* rides in the centred cluster with the others -- auto used to push it to
     the right edge of the old flyout, which no longer exists */
  margin-left: 0;
}
#jb-vol-range {
  -webkit-appearance: none;
  appearance: none;
  width: 68px;
  height: 4px;
  margin: 0;
  background: linear-gradient(
    to right,
    #86ff66 0 var(--jb-vol, 45%),
    rgba(255, 255, 255, 0.12) var(--jb-vol, 45%) 100%);
  cursor: pointer;
}
#jb-vol-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 8px;
  height: 14px;
  background: #eafff0;
  box-shadow: 0 0 7px rgba(57, 255, 20, 0.7);
  /* angular cap, same language as the crop handles */
  clip-path: polygon(0 0, 100% 0, 100% 70%, 50% 100%, 0 70%);
  cursor: ew-resize;
}
#jb-vol-range::-moz-range-thumb {
  width: 8px;
  height: 14px;
  border: none;
  border-radius: 0;
  background: #eafff0;
  box-shadow: 0 0 7px rgba(57, 255, 20, 0.7);
  cursor: ew-resize;
}
/* one button, two glyphs -- whichever the state isn't is hidden */
.jb-ico-mute { display: none; }
#jukebox.jb-muted .jb-ico-spk { display: none; }
#jukebox.jb-muted .jb-ico-mute { display: block; }
#jukebox.jb-muted #jb-mute { color: #a85454; }
#jukebox.jb-muted #jb-vol-range { --jb-vol: 0%; }

/* ==================== SOCIAL DRAWER ====================
   Full-height panel on the right edge: friends, your lobby, and search.
   Its own accent vars rather than --mp-accent, which is scoped to
   #screen-mpmenu and doesn't reach a body-level element. */
#social-panel {
  /* Same treatment as the button, or opening a pink button would reveal a green
     drawer behind it. */
  filter: hue-rotate(var(--logo-hue));
  --sc-accent: #39ff14;
  --sc-soft: rgba(57, 255, 20, 0.5);
  --sc-dim: rgba(57, 255, 20, 0.16);
  --sc-ink: #cdeecb;
  position: fixed;
  top: 0;
  bottom: 0;
  right: 0;
  width: clamp(420px, calc(32 * var(--dvw, 1vw)), 620px);
  /* Above the jukebox (690) so it isn't punched through at the bottom
     right; below the CRT glass (700) so the scanlines still sit over it.
     #profile-view / #clan-view were raised to 696 to open above this. */
  z-index: 692;
  display: flex;
  flex-direction: column;
  background:
    linear-gradient(180deg, rgba(10, 24, 12, 0.97), rgba(3, 9, 4, 0.98));
  border-left: 1px solid var(--sc-soft);
  /* NO clip-path on this element: it clips descendants, so the angular
     edge would slice the scrolling list. The notch is drawn by ::before
     instead. */
  box-shadow: -30px 0 60px rgba(0, 0, 0, 0.65), inset 1px 0 0 var(--sc-dim);
  transform: translateX(100%);
  transition: transform 0.34s cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}
#social-panel.social-open { transform: none; }
/* display:none only once it's off-screen, so the slide-out is visible --
   toggling display would cancel the transition outright. */
#social-panel.hidden { display: none; }
/* Angular accent down the leading edge -- the plate language, without
   clip-path's clipping side effects. */
#social-panel::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: -1px;
  width: 3px;
  background: linear-gradient(180deg, transparent, var(--sc-accent) 12%, var(--sc-accent) 88%, transparent);
  opacity: 0.55;
  pointer-events: none;
}

/* ---- header ---- */
#social-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 20px 24px 15px;
  border-bottom: 1px solid var(--sc-dim);
}
#social-title-wrap { display: flex; align-items: baseline; gap: 10px; }
#social-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 38px;
  letter-spacing: 4px;
  color: #eafff0;
  text-shadow: 0 0 18px var(--sc-soft);
}
.social-kata {
  font-size: 16px;
  letter-spacing: 3px;
  color: rgba(134, 255, 102, 0.42);
}
#social-close {
  flex: none;
  width: 42px;
  height: 42px;
  background: none;
  border: 1px solid var(--sc-dim);
  color: var(--sc-ink);
  font-size: 21px;
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#social-close:hover {
  color: #fff;
  border-color: var(--sc-soft);
  transform: translate(-2px, -2px) scale(1.06);
}

/* ---- tabs (first tabbed UI in the app -- this is the pattern) ---- */
#social-tabs {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  border-bottom: 1px solid var(--sc-dim);
}
.social-tab {
  position: relative;
  padding: 16px 6px 14px;
  background: none;
  border: none;
  font-family: 'Courier New', monospace;
  font-size: 20px;
  letter-spacing: 2px;
  color: rgba(150, 210, 145, 0.62);
  cursor: pointer;
  transition: color 0.22s ease-out, background 0.22s ease-out;
}
.social-tab:hover { color: var(--sc-ink); background: rgba(57, 255, 20, 0.05); }
/* The underline is a scaled pseudo-element, not a border swap: transform
   animates on the compositor, and a border would shift the text. */
.social-tab::after {
  content: '';
  position: absolute;
  left: 12%;
  right: 12%;
  bottom: 0;
  height: 2px;
  background: var(--sc-accent);
  box-shadow: 0 0 12px var(--sc-soft);
  transform: scaleX(0);
  transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.social-tab-on { color: #eafff0; }
.social-tab-on::after { transform: scaleX(1); }
.social-count {
  display: inline-block;
  min-width: 24px;
  margin-left: 8px;
  padding: 1px 6px;
  font-style: normal;
  font-size: 15px;
  color: #041008;
  background: var(--sc-accent);
  border-radius: 2px;
}
.social-count.hidden { display: none; }

/* ---- scrolling body ---- */
.social-body {
  flex: 1;
  min-height: 0; /* lets overflow actually scroll inside the flex column */
  overflow-y: auto;
  padding: 18px 22px 26px;
}
.social-body.hidden { display: none; }
.social-body::-webkit-scrollbar { width: 8px; }
.social-body::-webkit-scrollbar-thumb { background: var(--sc-dim); }

.social-section-head {
  margin: 4px 0 11px;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 3px;
  color: rgba(134, 255, 102, 0.6);
}
.social-section-head.hidden { display: none; }
#social-requests.hidden, #social-outgoing.hidden { display: none; }
#social-requests, #social-outgoing { margin-bottom: 20px; }
.social-empty {
  margin: 26px 6px;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  line-height: 1.7;
  letter-spacing: 1px;
  color: rgba(150, 210, 145, 0.45);
}
.social-empty.hidden { display: none; }
.social-empty b { color: var(--sc-ink); font-weight: normal; }
.social-note {
  margin: 14px 6px 0;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  color: rgba(255, 206, 107, 0.75);
}
.social-note.hidden { display: none; }

/* ---- one person ---- */
#social-friends, #social-lobby, #social-results,
#social-request-list, #social-outgoing-list { list-style: none; margin: 0; padding: 0; }
.social-row {
  display: grid;
  grid-template-columns: 52px minmax(0, 1fr) auto;
  align-items: center;
  gap: 13px;
  padding: 12px 13px;
  margin-bottom: 8px;
  background: rgba(255, 255, 255, 0.022);
  border: 1px solid transparent;
  border-left: 2px solid var(--sc-dim);
  transition: background 0.2s, border-color 0.2s, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.social-row:hover {
  background: rgba(57, 255, 20, 0.055);
  border-left-color: var(--sc-accent);
  transform: translateX(-3px);
}
/* Rows carrying two or more buttons drop them onto their own full-width
   line. At the bigger type size ACCEPT + DECLINE alone claimed 192px of a
   420px drawer, which squeezed the name into an ellipsis -- the name is the
   point of the row, so the buttons move instead. */
.social-row-stack { grid-template-columns: 52px minmax(0, 1fr); }
.social-row-stack .social-acts {
  grid-column: 1 / -1;
  margin-top: 11px;
  gap: 8px;
}
.social-row-stack .social-acts .social-btn { flex: 1; text-align: center; }
/* ...except the remove ✕, which stays a fixed square rather than stretching
   to a third of the row. */
.social-row-stack .social-acts .social-btn-danger:last-child { flex: 0 0 auto; min-width: 42px; }

.social-av {
  /* relative is LOAD-BEARING: the worn frame is an absolute child at
     inset -11%, and every frame host must be its containing block. This
     was the one host that wasn't -- the frame resolved against whatever
     ancestor happened to be positioned, and on row hover the row's
     translateX made the ROW that ancestor, stretching the plate across
     it ("the banner is broken"). */
  position: relative;
  width: 52px;
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 25px;
  background: #12240f;
  border: 1px solid var(--sc-dim);
  overflow: hidden;
}
.social-av img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Counter-rotates the drawer's hue shift: a tinted person's photo reads as
     broken, the same way tinted album art would. */
  filter: hue-rotate(calc(-1 * var(--logo-hue)));
}
.social-text { min-width: 0; }
.social-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 27px;
  /* This face's swashes (the $ especially) dip well below the baseline --
     with no line-height set here at all, the browser's default leading
     left them landing on top of .social-sub, not above it. */
  line-height: 1.3;
  letter-spacing: 1px;
  color: #eafff0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Matched-length shadow so the hover glow interpolates instead of
     snapping -- see the results-podium names for the same rule. */
  text-shadow: 0 0 12px rgba(134, 255, 102, 0), 0 0 30px rgba(134, 255, 102, 0);
  transition: color 0.28s ease-out, text-shadow 0.28s ease-out;
  cursor: pointer;
}
.social-name:hover {
  color: #fff;
  text-shadow: 0 0 12px rgba(134, 255, 102, 0.8), 0 0 30px rgba(134, 255, 102, 0.4);
}
.social-sub {
  margin-top: 6px;
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 1px;
  color: rgba(150, 210, 145, 0.6);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.social-sub.is-online { color: #86ff66; }
.social-sub.is-lobby { color: #8fd8ff; }
.social-tag { color: rgba(134, 255, 102, 0.55); }

/* ---- row actions ---- */
.social-acts { display: flex; align-items: center; gap: 6px; }
.social-btn {
  padding: 8px 11px 7px;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 1px;
  color: var(--sc-ink);
  background: none;
  border: 1px solid var(--sc-dim);
  cursor: pointer;
  white-space: nowrap;
  transition: color 0.18s, border-color 0.18s, background 0.18s,
              transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.social-btn:hover:not(:disabled) {
  color: #041008;
  background: var(--sc-accent);
  border-color: var(--sc-accent);
  transform: translate(-2px, -2px);
}
.social-btn:disabled { opacity: 0.32; cursor: default; }
.social-btn-danger { color: #ff9b9b; border-color: rgba(255, 90, 90, 0.28); }
.social-btn-danger:hover:not(:disabled) {
  color: #1a0505;
  background: #ff6a6a;
  border-color: #ff6a6a;
}
.social-btn-go { color: #9fe4ff; border-color: rgba(120, 200, 255, 0.3); }
.social-btn-go:hover:not(:disabled) {
  color: #04141c;
  background: #7fd4ff;
  border-color: #7fd4ff;
}

/* ---- search ---- */
#social-search-wrap { margin-bottom: 16px; }
#social-search {
  width: 100%;
  padding: 14px 15px;
  font-family: 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 2px;
  color: #eafff0;
  background: rgba(0, 0, 0, 0.45);
  border: 1px solid var(--sc-dim);
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
}
#social-search:focus {
  border-color: var(--sc-soft);
  box-shadow: 0 0 0 1px var(--sc-dim), 0 0 22px rgba(57, 255, 20, 0.12);
}
#social-search::placeholder { color: rgba(150, 210, 145, 0.38); }

/* ---- the toggle ---- */
/* ---- the cred counter ---- */
/* Angular plate, clipped corners, katakana -- the same language as the
   rest of the HUD. Hue-rotated by --logo-hue like #social-toggle beside
   it, so the two read as one control cluster on every screen. */
#cred-hud {
  filter: hue-rotate(var(--logo-hue));
  position: fixed;
  top: 16px;
  /* 16 (the toggle's own right offset) + 54 (its width) + 12 gap. */
  right: 82px;
  z-index: 692;
  height: 54px;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 0 16px 0 13px;
  background: rgba(6, 16, 8, 0.82);
  /* NOT the inset ring the other clipped plates wear (THE CLIP EATS THE
     BORDER, #prod-sample-box): #cred-hud is re-skinned three more times
     further down -- the last word gives it a border-color, its own
     box-shadow and clip-path: none -- so an inset ring here is overridden
     before it ever paints. Fix it there if it ever needs fixing. */
  border: 1px solid rgba(57, 255, 20, 0.42);
  clip-path: polygon(11px 0, 100% 0, 100% calc(100% - 11px), calc(100% - 11px) 100%, 0 100%, 0 11px);
  font-family: 'Courier New', monospace;
  user-select: none;
  pointer-events: none;
}
#cred-hud.hidden { display: none; }
#cred-hud-mark {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: #86ff66;
  stroke-width: 1.5;
  stroke-linejoin: round;
  opacity: 0.85;
}
#cred-hud-n {
  font-size: 21px;
  font-weight: bold;
  line-height: 1;
  color: #eafff0;
  text-shadow: 0 0 12px rgba(57, 255, 20, 0.5);
  letter-spacing: 1px;
  font-variant-numeric: tabular-nums;
}
#cred-hud-k {
  font-size: 10px;
  letter-spacing: 3px;
  color: #86ff66;
  opacity: 0.72;
}
/* The earn flash. Rides up and out from the counter rather than appearing
   somewhere else on screen -- the number that changed is the thing the
   eye should already be on. */
#cred-hud-gain {
  position: absolute;
  top: 100%;
  right: 14px;
  margin-top: 4px;
  font-style: normal;
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #39ff14;
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.7);
  animation: credGain 2.2s ease-out forwards;
}
#cred-hud-gain.hidden { display: none; }
@keyframes credGain {
  0%   { opacity: 0; transform: translateY(6px) scale(0.9); }
  14%  { opacity: 1; transform: translateY(0) scale(1.12); }
  28%  { transform: translateY(0) scale(1); }
  72%  { opacity: 1; transform: translateY(-2px); }
  100% { opacity: 0; transform: translateY(-14px); }
}
/* The counter pulses when it changes -- the plate ignites, then cools. */
#cred-hud.credited {
  animation: credPlate 1.1s ease-out;
}
@keyframes credPlate {
  0%   { border-color: rgba(57, 255, 20, 0.42); box-shadow: none; }
  22%  { border-color: #39ff14; box-shadow: 0 0 26px rgba(57, 255, 20, 0.55); }
  100% { border-color: rgba(57, 255, 20, 0.42); box-shadow: none; }
}
/* Narrow windows: the pull-out button wins, the wordmark is what goes. */
@media (max-width: 640px) {
  #cred-hud { right: 78px; height: 46px; padding: 0 11px 0 9px; gap: 6px; }
  #cred-hud-k { display: none; }
  #cred-hud-n { font-size: 17px; }
}

#social-toggle {
  /* Authored green, then hue-rotated by the same --logo-hue the logo cycle
     drives -- exactly how #home-profile-badge is themed. That's what keeps this
     button the same colour as the profile plate on every screen. */
  filter: hue-rotate(var(--logo-hue));
  position: fixed;
  top: 16px;
  right: 16px;
  z-index: 692;
  width: 54px;
  height: 54px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(6, 16, 8, 0.82);
  border: 1px solid rgba(57, 255, 20, 0.42);
  border-radius: 50%;
  color: #86ff66;
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
              border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
#social-toggle.hidden { display: none; }
#social-toggle svg {
  width: 27px;
  height: 27px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}
#social-toggle:hover {
  color: #eafff0;
  border-color: #39ff14;
  transform: scale(1.09);
  box-shadow: 0 0 22px rgba(57, 255, 20, 0.4), 0 0 50px rgba(57, 255, 20, 0.16);
}
#social-toggle.social-on { color: #eafff0; border-color: #39ff14; }
/* Unanswered friend requests. Pinned to the button, so it's visible with
   the drawer shut -- which is the only time it matters. */
#social-toggle-dot {
  /* Undoes the button's hue-rotate. An alert badge that drifts to whatever
     colour the screen happens to be stops reading as an alert. */
  filter: hue-rotate(calc(-1 * var(--logo-hue)));
  position: absolute;
  top: -3px;
  right: -3px;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'Courier New', monospace;
  font-style: normal;
  font-size: 12px;
  color: #1a0505;
  background: #ff6a6a;
  border-radius: 10px;
  box-shadow: 0 0 14px rgba(255, 90, 90, 0.6);
}
#social-toggle-dot.hidden { display: none; }

/* Narrow screens: a 420px minimum would swallow a small laptop, so the
   drawer becomes a near-full-width sheet instead. */
@media (max-width: 720px) {
  #social-panel { width: min(calc(100 * var(--dvw, 1vw)), 460px); }
}


/* ==================== JUKEBOX PLATE (rebuild) ====================
   Supersedes the shape/layout rules ~240 lines above; the transport button
   styling up there still applies and is untouched. Deliberately appended
   rather than edited in place -- later rules of equal specificity win, and
   rewriting that whole block blind risked breaking the controls.

   Built the same way as #home-profile-badge, because the ask was "exactly like
   the profile button": a solid neon shape with a clip-path, an inset ::after
   holding the dark fill with a slightly SMALLER clip-path, and the 3px gap
   between them left over as the glowing edge. Two stacked drop-shadows give the
   bloom; hover adds hard offset shadows for the 3D extrusion.

   Shape is the profile plate MIRRORED: the profile steps down on its right, this
   steps down on its left, so the pair frame the screen. */
#jukebox {
  --jb-w: 372px;
  /* Same height as #home-profile-badge (--hpb-height: 84px) -- the two
     plates share --hpb-bottom too, so equal height is what actually makes
     their top edges and bottom bars land on the same screen line. */
  --jb-h: 84px;
  /* The FULL PLAYER CARD the plate grows into on hover (user call: the
     panel transforms, nothing pops up). Bottom edge is fixed, so all the
     growth is upward. Budgeted top to bottom: 34 disc top + 180 disc +
     12 gap + title/artist block + breathing room + scrub/transport + the
     volume line's own 30px of floor. */
  --jb-h-open: 408px;
  /* where the left foot's top edge sits, and where the diagonal lands.
     Rise (--jb-step) is 44px -- the EXACT px value of the profile plate's
     own diagonal rise, not just a matching percentage, since both plates
     are now the same 84px height. Run (diag-end - diag-start = 22px) is
     narrower than the rise, so the climb reads as a steep skinny spike
     rather than a wide ramp. */
  --jb-step: 44px;
  /* Run of 56px over the 44px rise -- the EXACT diagonal the profile
     plate uses (its clip-path steps 263px -> 319px across the same 44px).
     Matching the rise alone was not enough: the two plates sat at the
     same height with visibly different slant angles, which is what stopped
     them reading as a mirrored pair. 88 - 56 = 32. */
  --jb-diag-start: 32px;
  --jb-diag-end: 88px;
  width: var(--jb-w);
  height: var(--jb-h);
  /* Same two-glow stack as the profile plate: a tight hot core plus a wide
     bloom. hue-rotate stays last so the whole thing tracks the screen. */
  filter:
    drop-shadow(0 0 8px rgba(57, 255, 20, 0.9))
    drop-shadow(0 0 28px rgba(57, 255, 20, 0.6))
    hue-rotate(var(--logo-hue));
  cursor: pointer;
  /* HEIGHT is the whole show now -- the plate grows into the player card.
     Filter must not transition, since hue-rotate changes every frame and
     would animate against itself. */
  transition: height 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* .jb-open is the same state as hover, forceable from script (tests, and a
   future touch fallback) -- keep the three selectors in lockstep on every
   rule below. */
#jukebox:hover,
#jukebox:focus-within,
#jukebox.jb-open {
  height: var(--jb-h-open);
  filter:
    drop-shadow(0 0 10px rgba(57, 255, 20, 1))
    drop-shadow(0 0 30px rgba(57, 255, 20, 0.7))
    hue-rotate(var(--logo-hue));
}

#jb-shell {
  position: absolute;
  inset: 0;
  background: #86ff66;
  clip-path: polygon(
    0 var(--jb-step),
    var(--jb-diag-start) var(--jb-step),
    var(--jb-diag-end) 0,
    100% 0,
    100% calc(100% - 14px),
    calc(100% - 14px) 100%,
    0 100%
  );
}
#jb-shell::after {
  content: '';
  position: absolute;
  inset: 3px;
  background: rgba(6, 18, 8, 0.92);
  box-shadow: inset 0 0 26px rgba(57, 255, 20, 0.3);
  clip-path: polygon(
    0 calc(var(--jb-step) - 2px),
    calc(var(--jb-diag-start) - 4px) calc(var(--jb-step) - 2px),
    calc(var(--jb-diag-end) - 4px) 0,
    100% 0,
    100% calc(100% - 14px),
    calc(100% - 14px) 100%,
    0 100%
  );
}

/* ---- the record, back as a RECORD but neon ----
   The sketch read as a square sleeve, but a vinyl is the jukebox's identity, so
   this is the original disc with the plate's neon vocabulary applied: a 3px
   glowing rim instead of the old 1px hairline, grooves lit green rather than
   plain black, and a neon spindle hub.

   The rim, the glow and the hover extrusion all live on #jb-disc, which
   deliberately does NOT rotate: box-shadow is painted in the element's own
   coordinate space, so spinning this layer would send the 3D corner shadows
   orbiting around the disc. The spin moved to #jb-art (below) instead -- the
   art and its sheen carry the motion, the chrome stays put. */
#jb-disc {
  position: absolute;
  left: calc(var(--jb-diag-end) + 6px);
  /* top:12 + 50 = 62px bottom edge, clear of #jb-vol-bar's top edge (68px,
     see its own comment) by 6px -- was 58px and overlapping the volume line. */
  top: 12px;
  width: 50px;
  height: 50px;
  bottom: auto;
  border-radius: 50%;
  border: 3px solid #86ff66;
  background: #050d05;
  box-shadow:
    0 0 12px rgba(57, 255, 20, 0.85),
    0 0 26px rgba(57, 255, 20, 0.4),
    inset 0 0 10px rgba(57, 255, 20, 0.4);
  overflow: hidden;
  /* left/top/width/height all ride the same curve as the card's growth, so
     the little record READS as becoming the big one -- one object, two
     sizes, never a swap. */
  transition:
    left 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    top 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    width 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 0.15s;
}
/* Open: the vinyl is the album art, big and centred -- the CUB3D layout in
   this app's vocabulary. Left edge lands at 96px, safely right of the
   diagonal notch (--jb-diag-end: 88px) at every point of its top arc. */
#jukebox:hover #jb-disc,
#jukebox:focus-within #jb-disc,
#jukebox.jb-open #jb-disc {
  left: calc(50% - 90px);
  top: 34px;
  width: 180px;
  height: 180px;
  bottom: auto;
  box-shadow:
    0 0 16px rgba(57, 255, 20, 0.85),
    0 0 36px rgba(57, 255, 20, 0.4),
    inset 0 0 14px rgba(57, 255, 20, 0.4);
}

/* Grooves get a neon pass: a black groove for depth immediately followed by a
   green one for the light catching it, so the rings read as lit rather than
   just dark. Sits UNDER the art layer's own gradients in the stack. */
/* THE GEOMETRY IS THE RULE, not a thing an ancestor happens to supply: this
   is an <i>, so with no position/inset of its own it is a 0x0 inline box and
   the cover simply never paints -- the var is set, the URL resolves, the file
   is served 200, and the record is black. That is exactly how it shipped when
   the neon pass rewrote this rule and dropped the four declarations below,
   and it is invisible to every check that reads --jb-art rather than the
   element's RECT. Measure the box, not the variable.
   The counter-rotate is the other half: the whole jukebox is hue-rotated on
   the menu's --logo-hue clock, so the art has to spin the hue back or the
   cover wears the chrome's colour instead of its own. */
#jb-art {
  position: absolute;
  inset: 0;
  pointer-events: none;
  filter: hue-rotate(calc(-1 * var(--logo-hue)));
  border-radius: 50%;
  background:
    repeating-radial-gradient(circle at 50% 50%,
      rgba(0, 0, 0, 0.4) 0 1px,
      rgba(120, 255, 90, 0.3) 1px 2px,
      transparent 2px 5px),
    radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.22), transparent 55%),
    radial-gradient(circle at 50% 50%, transparent 58%, rgba(57, 255, 20, 0.22) 100%),
    var(--jb-art, none) center / cover no-repeat;
}

/* Neon spindle: dark hole, glowing collar. */
#jb-disc-label {
  display: block;
  /* `inset` without `position` is inert -- same drop as #jb-art above, and it
     left the hub as a zero-height strip across the middle of the record. */
  position: absolute;
  inset: 39%;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, #061006 0 32%, #9dff80 32% 100%);
  box-shadow: 0 0 6px rgba(57, 255, 20, 0.9), 0 0 0 1px rgba(0, 0, 0, 0.6);
}

/* Spin the ART, not the rim -- see the note on #jb-disc. */
#jukebox.jb-playing #jb-disc { animation: none; }
#jukebox.jb-playing #jb-art { animation: jb-spin 3.2s linear infinite; }
@keyframes jb-spin { to { transform: rotate(360deg); } }

/* Title over artist, where the sketch's squiggles are. */
#jb-plate {
  position: absolute;
  left: calc(var(--jb-diag-end) + 68px);
  right: 20px;
  top: 14px;
  width: auto;
  height: auto;
  padding: 0;
  background: none;
  border: none;
  clip-path: none;
  overflow: visible;
  /* Slides from beside the little record to under the big one -- same
     curve as everything else so the whole card moves as one thing. */
  transition:
    left 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    right 0.3s cubic-bezier(0.16, 1, 0.3, 1),
    top 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
#jukebox:hover #jb-plate,
#jukebox:focus-within #jb-plate,
#jukebox.jb-open #jb-plate {
  left: 24px;
  right: 24px;
  top: 228px;
  text-align: center;
}
#jb-head { display: block; }
#jb-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 1px;
  color: #86ff66;
  text-shadow: 0 0 6px rgba(57, 255, 20, 1), 0 0 18px rgba(57, 255, 20, 0.8);
  white-space: nowrap;
  /* clip, not hidden -- the hover state below adds a 3px extrusion offset
     on top of the 18px blur, and hidden clipped both flat at the box edge. */
  overflow: clip;
  overflow-clip-margin: 24px;
  text-overflow: ellipsis;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), text-shadow 0.15s;
}
#jb-sub {
  margin-top: 3px;
  font-size: 14px;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.72);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Open: headline size, centred under the art. The extrusion translate the
   old hover had is gone -- centred text that also slides sideways reads as
   misalignment, not depth. font-size transitions with the layout. */
#jukebox:hover #jb-title,
#jukebox:focus-within #jb-title,
#jukebox.jb-open #jb-title {
  font-size: 27px;
  text-shadow:
    0 0 6px rgba(57, 255, 20, 1),
    0 0 20px rgba(57, 255, 20, 0.85);
}
#jb-title { transition: font-size 0.3s cubic-bezier(0.16, 1, 0.3, 1), text-shadow 0.15s; }
#jukebox:hover #jb-sub,
#jukebox:focus-within #jb-sub,
#jukebox.jb-open #jb-sub {
  justify-content: center;
  font-size: 15px;
  margin-top: 6px;
}

/* ---- scrub + transport ----
   No flyout any more (user call): the ONE plate grows into the full player
   card, and this block simply lives inside it, fading in once the card has
   mostly arrived. The old design -- a second outlined pane sliding up above
   the bar, with a translateY dance to keep it pinned -- is gone with its
   whole class of hover-dead-zone bugs, because there is no second shape to
   leave and re-enter: the pointer never exits #jukebox on its way to the
   controls. */
#jb-flyout {
  position: absolute;
  left: 22px;
  right: 22px;
  bottom: 32px;
  padding: 0;
  opacity: 0;
  pointer-events: none;
  /* This is the CLOSING transition (the base state is where a leave lands):
     fast and with NO delay, so the controls are already gone while the card
     shrinks instead of lingering over the collapsing layout. The first
     version put one delayed transition here for both directions, which made
     the fade-out feel slow -- the delay belongs to opening only, on the
     rule below. */
  transition: opacity 0.08s ease-out;
}
#jukebox:hover #jb-flyout,
#jukebox:focus-within #jb-flyout,
#jukebox.jb-open #jb-flyout {
  opacity: 1;
  pointer-events: auto;
  /* The OPENING transition: in on a delay -- the card needs ~a beat of
     growth before there is room for this, and fading it in over the
     collapsed layout looked like two UIs briefly fighting. */
  transition: opacity 0.18s ease-out 0.14s;
}
/* The flyout's old outlined pane (#jb-flyout-shell) is gone from the HTML
   entirely -- the card is the shell now. */

/* VOLUME as the bottom edge line -- the profile's XP bar, made draggable.
   bottom:12px (was 20px, which put it under the disc) mirrors the disc's
   own 12px top inset -- same margin top and bottom, and its 68px top edge
   clears the disc's 62px bottom edge by 6px. */
#jb-vol-bar {
  position: absolute;
  left: 18px;
  right: 26px;
  bottom: 12px;
  height: 4px;
  background: rgba(57, 255, 20, 0.4);
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.5);
  z-index: 3;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s;
}
/* The volume line stays put when the card opens -- it's the plate's bottom
   edge, part of the shape's identity, and the one thing that anchors the
   transform. (It used to take the hover extrusion nudge; on a card whose
   every other element is travelling, the nudge read as drift.) */
/* origin dot, same as the XP bar's */
#jb-vol-bar::before {
  content: '';
  position: absolute;
  left: -12px;
  top: 50%;
  transform: translateY(-50%);
  width: 7px;
  height: 7px;
  border: 2px solid #86ff66;
  border-radius: 50%;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
/* end cap */
#jb-vol-bar::after {
  content: '';
  position: absolute;
  right: -2px;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 13px;
  background: #86ff66;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
/* The real input, laid flat over the line: invisible track, the fill drawn by
   --jb-vol (already maintained by jbSetVolume), and a tall thin hit area so it
   can actually be grabbed at 4px. */
#jb-vol-bar #jb-vol-range {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
  height: 18px;
  margin: 0;
  padding: 0;
  background: linear-gradient(
    to right,
    #86ff66 0 var(--jb-vol, 60%),
    transparent var(--jb-vol, 60%) 100%
  ) no-repeat center / 100% 4px;
  border: none;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
  filter: drop-shadow(0 0 6px rgba(57, 255, 20, 0.9));
}
#jb-vol-bar #jb-vol-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 4px;
  height: 14px;
  background: #eafff0;
  border: none;
  border-radius: 0;
  box-shadow: 0 0 8px rgba(57, 255, 20, 1);
  cursor: pointer;
}
#jb-vol-bar #jb-vol-range::-moz-range-thumb {
  width: 4px;
  height: 14px;
  background: #eafff0;
  border: none;
  border-radius: 0;
  box-shadow: 0 0 8px rgba(57, 255, 20, 1);
}
#jb-vol-bar #jb-vol-range::-webkit-slider-runnable-track { background: none; }
#jb-vol-bar #jb-vol-range::-moz-range-track { background: none; }

/* ================= RESULTS PODIUM: TIERED REVEALS =================
   Three ranks, three levels of ceremony. 3rd is deliberately plain -- it
   has to be, or 2nd and 1st have nothing to be bigger THAN. The whole
   effect is relative.

   Scoped strictly to .rs-slot-1 / .rs-slot-2. The standing rule that
   game-flow screens get zero glitch effects is about the SCREEN-WIDE CRT
   stack (playScreenGlitch + the FILTER); these are local per-element
   reveals on a screen that is meant to feel like a ceremony. Nothing here
   touches the global layers. */

/* Anchor for the scan bar / aura / brackets, which are absolutely
   positioned children. The plate's clip-path crops them to its angular
   silhouette for free -- a sweep can never spill past the plate edge. */
/* position only -- NOT overflow:hidden. The plate's clip-path already crops
   children to its angular silhouette, so overflow was redundant, and a
   second clipper is a second hard edge for a name's glow to run into. */
.rs-slot { position: relative; }

/* ---------- 3rd place: UNCHANGED ----------
   Still the plain fade-up from .rs-slot / .rs-slot.rs-in above. */

/* ---------- shared: the scan bar ----------
   One bright line that rakes down the plate as it arrives, like a head
   passing over tape. 2nd gets green, 1st gets gold (below). */
.rs-scan {
  position: absolute;
  left: -10%;
  right: -10%;
  height: 42%;
  top: -45%;
  pointer-events: none;
  z-index: 3;
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(134, 255, 102, 0.05) 42%,
    rgba(190, 255, 170, 0.5) 50%,
    rgba(134, 255, 102, 0.05) 58%,
    transparent 100%);
  opacity: 0;
}
.rs-slot-2.rs-in .rs-scan { animation: rs-scan-sweep 0.72s cubic-bezier(0.3, 0, 0.2, 1) 0.1s both; }
.rs-slot-1.rs-in .rs-scan {
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(255, 215, 94, 0.06) 40%,
    rgba(255, 245, 205, 0.72) 50%,
    rgba(255, 215, 94, 0.06) 60%,
    transparent 100%);
  /* The trailing sweep is `forwards`, NOT `both`: with `both` it would apply
     its 0% keyframe during its own 1.5s delay, which sits right on top of
     the first sweep and cancels it out entirely. */
  animation: rs-scan-sweep 0.95s cubic-bezier(0.3, 0, 0.2, 1) 0.22s both,
             rs-scan-sweep 1.5s cubic-bezier(0.4, 0, 0.3, 1) 1.5s forwards;
}
@keyframes rs-scan-sweep {
  0%   { top: -45%; opacity: 0; }
  12%  { opacity: 1; }
  88%  { opacity: 1; }
  100% { top: 105%; opacity: 0; }
}

/* ---------- 2nd place: SIGNAL LOCK ----------
   Doesn't just fade in -- it ARRIVES: overshoots, snaps back, and the
   border ignites as it settles. Roughly twice the event 3rd is, and
   comfortably less than 1st. */
.rs-slot-2.rs-in {
  transition: none; /* the animation owns transform/opacity now */
  animation: rs2-lock 0.66s cubic-bezier(0.16, 1, 0.3, 1) both,
             rs2-border 1.1s ease-out both;
}
@keyframes rs2-lock {
  0%   { opacity: 0; transform: translateY(34px) scale(0.965); }
  55%  { opacity: 1; transform: translateY(-6px) scale(1.012); }
  78%  { transform: translateY(2px) scale(0.997); }
  100% { opacity: 1; transform: none; }
}
@keyframes rs2-border {
  0%   { border-color: rgba(134, 255, 102, 0.1); }
  30%  { border-color: rgba(190, 255, 170, 0.95); }
  100% { border-color: rgba(134, 255, 102, 0.28); }
}
/* The name lands a beat after the plate, with one hard chromatic split
   that resolves -- a signal locking on rather than a word fading in. */
.rs-slot-2.rs-in .rs-name { animation: rs2-name 0.5s steps(1, end) 0.22s both; }
@keyframes rs2-name {
  0%   { opacity: 0; text-shadow: none; }
  25%  { opacity: 1; text-shadow: -5px 0 0 rgba(255, 0, 80, 0.85), 5px 0 0 rgba(0, 240, 255, 0.85); }
  55%  { text-shadow: 2px 0 0 rgba(255, 0, 80, 0.6), -2px 0 0 rgba(0, 240, 255, 0.6); }
  100% { opacity: 1; text-shadow: none; }
}

/* ---------- 1st place: CHAMPION BOOT ----------
   A CRT coming to life. Collapses to a single hot line, blows open to
   full height, tears itself apart chromatically, then settles and
   BREATHES for as long as it's on screen. */
.rs-slot-1.rs-in {
  transition: none;
  animation:
    rs1-boot 0.62s cubic-bezier(0.16, 1, 0.3, 1) both,
    rs1-chroma 0.9s steps(1, end) 0.1s both;
}
@keyframes rs1-boot {
  0%   { opacity: 0; transform: scaleY(0.012) scaleX(0.55); }
  22%  { opacity: 1; transform: scaleY(0.02) scaleX(1.06); }  /* the hot line */
  48%  { transform: scaleY(1.06) scaleX(1.01); }              /* blows open */
  70%  { transform: scaleY(0.975) scaleX(0.998); }
  100% { opacity: 1; transform: none; }
}
/* Chromatic tear on the whole plate. steps(1,end) so it JUMPS between
   states -- a smooth interpolation reads as a blur, not a glitch. */
@keyframes rs1-chroma {
  0%   { filter: none; }
  30%  { filter: drop-shadow(-16px 0 0 rgba(255, 0, 80, 0.75)) drop-shadow(16px 0 0 rgba(0, 240, 255, 0.75)); }
  42%  { filter: drop-shadow(9px 0 0 rgba(255, 0, 80, 0.6)) drop-shadow(-9px 0 0 rgba(0, 240, 255, 0.6)); }
  56%  { filter: drop-shadow(-4px 0 0 rgba(255, 0, 80, 0.45)) drop-shadow(4px 0 0 rgba(0, 240, 255, 0.45)); }
  70%  { filter: drop-shadow(2px 0 0 rgba(255, 215, 94, 0.5)); }
  100% { filter: none; }
}

/* Sustained gold aura, on its own layer so it never fights the entry's
   filter channel (two animations cannot share `filter`). */
.rs-aura {
  position: absolute;
  inset: -30%;
  pointer-events: none;
  z-index: 0;
  background: radial-gradient(ellipse at 50% 45%, rgba(255, 200, 70, 0.28), transparent 62%);
  opacity: 0;
}
.rs-slot-1.rs-in .rs-aura { animation: rs1-aura 3.6s ease-in-out 0.7s infinite; }
@keyframes rs1-aura {
  0%, 100% { opacity: 0.35; transform: scale(0.97); }
  50%      { opacity: 0.85; transform: scale(1.03); }
}

/* Targeting brackets that snap onto the corners once the plate settles --
   the "locked, confirmed" beat at the end of the sequence. */
.rs-bracket {
  position: absolute;
  width: 34px;
  height: 34px;
  pointer-events: none;
  z-index: 4;
  border: 3px solid #ffd75e;
  filter: drop-shadow(0 0 8px rgba(255, 215, 94, 0.9));
  opacity: 0;
}
.rs-bracket-tr { top: 10px; right: 10px; border-left: none; border-bottom: none; }
.rs-bracket-bl { bottom: 10px; left: 10px; border-right: none; border-top: none; }
.rs-slot-1.rs-in .rs-bracket-tr { animation: rs1-brk-tr 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) 0.72s both; }
.rs-slot-1.rs-in .rs-bracket-bl { animation: rs1-brk-bl 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) 0.82s both; }
@keyframes rs1-brk-tr {
  0%   { opacity: 0; transform: translate(16px, -16px); }
  100% { opacity: 1; transform: none; }
}
@keyframes rs1-brk-bl {
  0%   { opacity: 0; transform: translate(-16px, 16px); }
  100% { opacity: 1; transform: none; }
}

/* The cred line and the score arrive after the plate, not with it, so the
   eye reads plate -> who -> what they scored instead of all at once. */
.rs-slot-1.rs-in .rs-place { animation: rs1-place 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.5s both; }
@keyframes rs1-place {
  0%   { opacity: 0; transform: scale(1.9); letter-spacing: 26px; filter: blur(6px); }
  100% { opacity: 1; transform: none; letter-spacing: 5px; filter: none; }
}
.rs-slot-1.rs-in .rs-score { animation: rs1-score 0.45s ease-out 0.95s both; }
@keyframes rs1-score {
  0%   { opacity: 0; transform: translateY(14px); }
  100% { opacity: 1; transform: none; }
}

/* ---------- name hover glow: smooth falloff ----------
   The old glow was three shadows at 16 / 44 / 90px with big alpha gaps
   between them. Each layer's blur ends somewhere the next one hasn't
   picked up yet, so instead of one soft halo you see the seams between
   three of them -- visible rings, exactly the "you can tell the edges"
   read.

   Fix is more layers, closer together, on a curve: radii roughly double
   each step and alpha falls off smoothly, so no single layer has an edge
   to notice. Max radius is also pulled in (58px, was 90px) so the light
   dies out BEFORE it reaches the plate's clip-path boundary -- a glow
   that runs into that boundary gets sliced flat, which is a hard edge no
   amount of blur can hide.

   Resting states carry the same SIX layers at zero alpha: a text-shadow
   list only interpolates against a list of equal length, otherwise the
   hover snaps on instead of igniting. */
.rs-name.rs-clickable,
.rs-rname.rs-clickable {
  text-shadow:
    0 0 4px  rgba(134, 255, 102, 0),
    0 0 9px  rgba(134, 255, 102, 0),
    0 0 17px rgba(134, 255, 102, 0),
    0 0 28px rgba(134, 255, 102, 0),
    0 0 42px rgba(134, 255, 102, 0),
    0 0 58px rgba(134, 255, 102, 0);
}
.rs-name.rs-clickable:hover,
.rs-rname.rs-clickable:hover {
  color: #f2fff0;
  text-shadow:
    0 0 4px  rgba(205, 255, 190, 0.5),
    0 0 9px  rgba(160, 255, 130, 0.4),
    0 0 17px rgba(134, 255, 102, 0.3),
    0 0 28px rgba(134, 255, 102, 0.2),
    0 0 42px rgba(134, 255, 102, 0.12),
    0 0 58px rgba(134, 255, 102, 0.06);
}

/* The winner keeps a resting gold glow (they're lit even before you point
   at them), so its six layers start warm rather than at zero. */
.rs-slot-1 .rs-name {
  text-shadow:
    0 0 4px  rgba(255, 236, 170, 0.22),
    0 0 9px  rgba(255, 215, 94, 0.18),
    0 0 17px rgba(255, 210, 90, 0.14),
    0 0 28px rgba(255, 200, 70, 0.1),
    0 0 42px rgba(255, 200, 70, 0.06),
    0 0 58px rgba(255, 200, 70, 0.03);
}
.rs-slot-1 .rs-name.rs-clickable:hover {
  color: #fffaf0;
  text-shadow:
    0 0 4px  rgba(255, 244, 205, 0.6),
    0 0 9px  rgba(255, 228, 150, 0.46),
    0 0 17px rgba(255, 215, 94, 0.34),
    0 0 28px rgba(255, 205, 80, 0.22),
    0 0 42px rgba(255, 200, 70, 0.13),
    0 0 58px rgba(255, 200, 70, 0.07);
}

/* ---------- podium names: fit, don't truncate or shove ----------
   min-width:0 is the important one. A grid item defaults to min-width:auto,
   which refuses to shrink below its own content -- so a long name didn't
   wrap or clip, it forced its whole podium COLUMN wider and shifted the
   layout around it. That's the UI moving when a name is big. */
.rs-slot { min-width: 0; }

/* The name has to span the plate for rsFitName to have anything to measure
   against: in a centred column flex the element is otherwise only as wide as
   its own text, so "does it overflow?" is always false. Full width + centred
   text looks identical and makes the measurement meaningful. */
.rs-name {
  width: 100%;
  white-space: nowrap;
  text-align: center;
}

/* ---------- no text selection ----------
   Drag-selecting the UI (and the blue highlight that comes with it) reads as
   a web page, not a game -- and it happens constantly, since almost every
   interaction here is a click on top of text. Inputs opt back IN: you still
   need to select, edit and copy inside a field. */
body {
  -webkit-user-select: none;
  user-select: none;
}
input, textarea, [contenteditable="true"], .sw-selectable {
  -webkit-user-select: text;
  user-select: text;
}

/* ---------- 1st place: texture + scale ----------
   The plate was a flat gold rectangle. Four stacked layers give it a surface:
   fine scanlines (it's a screen), widely-spaced vertical rules (circuitry),
   a diagonal hatch (machined metal), and a warm bloom behind the middle so
   the centre reads lit rather than evenly painted. All low-alpha on purpose
   -- the winner's name has to stay the most legible thing on the screen. */
.rs-slot-1 {
  background-color: rgba(32, 25, 6, 0.86);
  background-image:
    repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.17) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(90deg, rgba(255, 215, 94, 0.055) 0 1px, transparent 1px 48px),
    repeating-linear-gradient(45deg, rgba(255, 195, 65, 0.04) 0 2px, transparent 2px 11px),
    radial-gradient(ellipse 80% 62% at 50% 32%, rgba(255, 205, 80, 0.2), transparent 68%);
}

/* Everything on the winner's plate goes UP a size class. It is the whole
   point of the screen; it should not be reading at the same weight as the
   two plates flanking it. */
.rs-slot-1 .rs-place {
  font-size: clamp(46px, calc(4.2 * var(--dvw, 1vw)), 82px);
  letter-spacing: 3px;
}
.rs-slot-1 .rs-name {
  font-size: clamp(64px, calc(6.8 * var(--dvw, 1vw)), 130px);
  /* Bigger than the 64px base margin -- the winner's hover glow reaches
     90px, and its name is also bigger, so 64px would still clip it. */
  overflow-clip-margin: 96px;
}
.rs-slot-1 .rs-track {
  font-size: clamp(24px, calc(2 * var(--dvw, 1vw)), 40px);
}

/* Redefined (a later @keyframes of the same name wins) so the label settles
   at the 3px tracking the longer "1ST PLACE" needs -- the original ended at
   5px, which was tuned for the three-character version. */
@keyframes rs1-place {
  0%   { opacity: 0; transform: scale(1.9); letter-spacing: 26px; filter: blur(6px); }
  100% { opacity: 1; transform: none; letter-spacing: 3px; filter: none; }
}

/* ---------- winner plate: audio-reactive glow ----------
   --rs-glow is 0..1, written every animation frame by rsDrawViz from the
   SAME analyser that draws the waveform bars, weighted to the low/mid bins
   so it follows the kick rather than the hi-hats.

   The entry animation (rs1-chroma) owns the filter channel while it runs and
   is redefined below WITHOUT a fill, so once it finishes the property falls
   back to this rule instead of being pinned at `none` forever. Two
   animations can't share a property, but an animation and a static
   declaration hand over cleanly.

   Deliberately restrained: a 5px floor so the plate is always faintly lit,
   climbing to ~27px on a hit. It should read as the plate breathing with the
   track, not as a strobe. */
.rs-slot-1 {
  --rs-glow: 0;
  filter:
    drop-shadow(0 0 calc(5px + var(--rs-glow) * 22px) rgba(255, 205, 80, calc(0.22 + var(--rs-glow) * 0.4)))
    drop-shadow(0 0 calc(14px + var(--rs-glow) * 46px) rgba(255, 180, 40, calc(0.08 + var(--rs-glow) * 0.22)));
}
/* Same keyframes as before, but no `both`: with a fill the final `filter:
   none` keyframe would hold forever and permanently cancel the glow above. */
@keyframes rs1-chroma {
  0%   { filter: none; }
  30%  { filter: drop-shadow(-16px 0 0 rgba(255, 0, 80, 0.75)) drop-shadow(16px 0 0 rgba(0, 240, 255, 0.75)); }
  42%  { filter: drop-shadow(9px 0 0 rgba(255, 0, 80, 0.6)) drop-shadow(-9px 0 0 rgba(0, 240, 255, 0.6)); }
  56%  { filter: drop-shadow(-4px 0 0 rgba(255, 0, 80, 0.45)) drop-shadow(4px 0 0 rgba(0, 240, 255, 0.45)); }
  70%  { filter: drop-shadow(2px 0 0 rgba(255, 215, 94, 0.5)); }
  100% { filter: none; }
}
.rs-slot-1.rs-in {
  animation:
    rs1-boot 0.62s cubic-bezier(0.16, 1, 0.3, 1) both,
    rs1-chroma 0.9s steps(1, end) 0.1s;
}

/* Right-clickable names get the same affordance as the lobby roster. */
.rs-name.rs-clickable, .rs-rname.rs-clickable { cursor: pointer; }

/* ================= BEAT SHOWCASE — "ARENA: the floor is judging" =========
   The screen used to be a bordered box with a small ring floating in dead
   space. The room is now the furniture: running order down the left, the
   brief top-right, the track burning in the middle, the floor along the
   bottom, your verdict on the right.

   Palette is the showcase's own teal; GOLD is reserved for exactly two
   things -- who is on air, and a locked verdict -- so it always means
   "this is the thing that matters right now".

   Appended, so these win over the base block at 6100-6792 on equal
   specificity. */

#screen-listen { --bs-glow: 0; }  /* defined before the first frame writes it */

/* Arena spotlight, folded into the EXISTING background stack rather than a
   new layer. A texture div would force position:relative on the screen's
   children, and .screen-leave is position:fixed inside this screen -- that
   pairing is a known trap here. */
#screen-listen.active {
  background:
    radial-gradient(ellipse 62% 46% at 50% 44%, rgba(60, 255, 170, 0.1), transparent 72%),
    repeating-linear-gradient(0deg, transparent 0 52px, rgba(60, 255, 170, 0.035) 52px 53px),
    repeating-linear-gradient(90deg, transparent 0 52px, rgba(60, 255, 170, 0.035) 52px 53px),
    radial-gradient(ellipse 120% 100% at 50% 0%, #05140c 0%, #020a06 55%, #010503 100%);
}

/* Shared katakana tick for the new furniture. */
#screen-listen .bs-kata {
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 5px;
  color: rgba(140, 255, 205, 0.5);
}

/* ---------- THE BRIEF (fills the old empty spacer column) ---------- */
#bs-brief {
  min-width: 0;
  text-align: right;
  font-family: 'Courier New', monospace;
  align-self: start;
  padding-top: 4px;
}
.bs-brief-kata {
  font-size: 15px;
  letter-spacing: 4px;
  color: rgba(159, 255, 207, 0.7);
  margin-bottom: 8px;
}
.bs-brief-row {
  display: flex;
  align-items: baseline;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 5px;
  min-width: 0;
}
.bs-brief-row.hidden { display: none; }
.bs-brief-k {
  flex: 0 0 auto;
  font-size: 12px;
  letter-spacing: 3px;
  color: rgba(130, 255, 205, 0.42);
}
#bs-brief-beat {
  font-size: 15px;
  letter-spacing: 1px;
  color: #9fffcf;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
#bs-brief-words {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: flex-end;
}
.bs-word {
  font-size: 13px;
  letter-spacing: 2px;
  color: #eaffe3;
  padding: 2px 9px;
  background: rgba(10, 40, 26, 0.75);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(130, 255, 205, 0.3);
  clip-path: polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%);
}

/* ---------- THE STAGE ---------- */
/* Grid, not flex, and with EXPLICIT placement: #bs-rig and #bs-mine share
   column 3 and exactly one is display:none at a time -- but in the
   tallying window BOTH are hidden, and auto-placement would then wrap the
   survivor onto a second row. */
#bs-stage {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  grid-template-columns: clamp(118px, calc(11 * var(--dvw, 1vw)), 168px) 1fr clamp(210px, calc(19 * var(--dvw, 1vw)), 292px);
  gap: clamp(18px, calc(2.4 * var(--dvw, 1vw)), 40px);
  align-items: stretch;
}
#bs-queue    { grid-column: 1; grid-row: 1; min-width: 0; }
#bs-viz-wrap { grid-column: 2; grid-row: 1; min-width: 0; }
#bs-rig, #bs-mine { grid-column: 3; grid-row: 1; }

/* The running-order rail was cut: the server never sends listenOrder, so
   every queued slot could only ever say SEALED -- seven rows of nothing.
   What survived is the COUNT, promoted to a headline in the floor strip
   where the status sentence used to be. */
#screen-listen #bs-progress {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  line-height: 1;
  color: rgba(159, 255, 207, 0.55);
}
#bs-progress b {
  font-size: clamp(38px, calc(3.4 * var(--dvw, 1vw)), 62px);
  font-weight: normal;
  color: #9fffcf;
  text-shadow: 0 0 10px rgba(80, 255, 180, 0.55), 0 0 30px rgba(60, 255, 170, 0.25);
  font-variant-numeric: tabular-nums;
}
#bs-progress i {
  font-style: normal;
  font-size: clamp(20px, calc(1.8 * var(--dvw, 1vw)), 32px);
  color: rgba(130, 255, 205, 0.4);
}
#bs-progress span {
  font-size: clamp(22px, calc(2 * var(--dvw, 1vw)), 36px);
  font-variant-numeric: tabular-nums;
}
/* TRACK label, so the pair of numbers reads as a position and not a score.
   attr(data-k), not a literal: the i18n sweep fills data-k from bs.track. */
#bs-progress::before {
  content: attr(data-k);
  align-self: center;
  margin-right: 4px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 4px;
  color: rgba(130, 255, 205, 0.45);
}


/* ---------- THE VIZ STAGE ---------- */
/* Glow on the WRAP, not on the screen: filter on an ancestor would become
   the containing block for .screen-leave (position:fixed inside here). */
#bs-viz-wrap {
  position: relative;
  filter:
    drop-shadow(0 0 calc(6px + var(--bs-glow) * 20px) rgba(80, 255, 180, 0.32))
    drop-shadow(0 0 calc(20px + var(--bs-glow) * 52px) rgba(60, 255, 170, 0.16));
}
/* .rs-bracket geometry, teal. TR and BL only -- exactly the two corners the
   wrap's clip-path does NOT notch, so they never fight the silhouette. */
.bs-bracket {
  position: absolute;
  width: 34px;
  height: 34px;
  pointer-events: none;
  z-index: 3;
  border: 3px solid #86ffc2;
  filter: drop-shadow(0 0 8px rgba(80, 255, 180, 0.85));
}
.bs-bracket-tr { top: 12px; right: 12px; border-left: none; border-bottom: none; }
.bs-bracket-bl { bottom: 12px; left: 12px; border-right: none; border-top: none; }

/* ON AIR badge -- the neon plate recipe (solid shape + inset ::after with a
   tighter clip; the 3px band between them IS the edge). */
#bs-onair {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 4;
  pointer-events: none;
}
#bs-onair.hidden { display: none; }
#bs-onair span {
  position: relative;
  z-index: 1;
  display: block;
  padding: 6px 22px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 6px;
  color: #06180f;
  font-weight: bold;
}
#bs-onair::before {
  content: '';
  position: absolute;
  inset: 0;
  background: #86ffc2;
  clip-path: polygon(10px 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
}

/* ---------- THE FLOOR ---------- */
/* max-height is a clearance budget, not decoration: #voice-speakers is
   fixed bottom-left at 134px and grows UPWARD, so the strip plus the
   screen's 36px bottom padding has to stay under that. */
#bs-floor {
  display: flex;
  align-items: center;
  gap: clamp(14px, calc(2 * var(--dvw, 1vw)), 32px);
  margin-top: clamp(10px, calc(1.4 * var(--dvh, 1vh)), 18px);
  max-height: 96px;
  padding: 8px clamp(6px, calc(1.4 * var(--dvw, 1vw)), 22px);
  border-top: 1px solid rgba(130, 255, 205, 0.22);
}
#bs-floor-label {
  flex: 0 0 auto;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(15px, calc(1.2 * var(--dvw, 1vw)), 20px);
  letter-spacing: 3px;
  color: #9fffcf;
}
#bs-roster {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  /* ONE row, always. With wrap, the strip was a different layout per window
     aspect: seven plates fit on the wide-aspect grid and wrapped onto a
     second line at exactly 16:9 -- the canonical view. Nowrap lets the
     plates shrink instead (only the name gives -- it ellipsizes; the icon,
     tag and mark are all flex-basis auto), so the floor holds its shape at
     every width. */
  flex-wrap: nowrap;
  gap: 8px;
  list-style: none;
  margin: 0;
  padding: 0;
  overflow: hidden;
}
.bs-face {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  max-width: 240px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 1px;
  color: #d8ffd0;
  cursor: pointer;
  clip-path: polygon(0 7px, 7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%);
  border: 1px solid rgba(130, 255, 205, 0.35);
  background: linear-gradient(to right, rgba(10, 40, 26, 0.72), transparent 58%);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.15s, border-color 0.15s;
}
.bs-face .player-icon {
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  font-size: 13px;
  border-radius: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.bs-face-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bs-face-mark {
  flex: 0 0 auto;
  font-size: 11px;
  letter-spacing: 2px;
  color: rgba(159, 255, 207, 0.45);
}
.bs-face .clan-tag { flex: 0 0 auto; font-size: 12px; }
/* Away: the lobby's empty-slot language. */
.bs-face-away {
  border: 1px dashed rgba(130, 255, 205, 0.14);
  background: none;
  color: rgba(159, 255, 207, 0.26);
  filter: saturate(0.4);
}
/* House hover extrusion. clip-path clips box-shadow, so the wall is chained
   drop-shadows instead. */
.bs-face:hover {
  transform: translate(-3px, -3px);
  color: #eaffe3;
  border-color: rgba(134, 255, 194, 0.9);
  filter:
    drop-shadow(2px 2px 0 rgba(16, 78, 48, 0.95))
    drop-shadow(4px 4px 0 rgba(11, 56, 34, 0.7))
    drop-shadow(0 0 14px rgba(80, 255, 180, 0.5));
}
.bs-face:active { transform: translate(0, 0) scale(0.97); transition-duration: 0.06s; }
/* ON AIR: gold, lifted, breathing with the track. */
.bs-face-onair {
  transform: translateY(-6px) scale(1.06);
  color: #fff6d8;
  border-color: rgba(255, 215, 94, 0.85);
  background: linear-gradient(to right, rgba(48, 36, 8, 0.9), rgba(48, 36, 8, 0.3) 60%, transparent);
  filter:
    drop-shadow(0 0 calc(5px + var(--bs-glow) * 20px) rgba(255, 210, 87, 0.75))
    drop-shadow(0 0 calc(16px + var(--bs-glow) * 44px) rgba(255, 180, 40, 0.28));
}
.bs-face-onair .bs-face-mark { color: #ffd257; }
.bs-face-onair:hover { transform: translateY(-8px) scale(1.07); }
/* YOUR locked verdict -- the only per-person vote state that exists here. */
.bs-face-voted {
  border-color: rgba(255, 215, 94, 0.7);
  color: #ffe9a8;
}
.bs-face-voted .bs-face-mark {
  color: #ffd257;
  font-size: 14px;
  font-weight: bold;
}
/* Staggered ignition on track change, reusing the lobby's keyframes. */
#bs-floor.bs-floor-ignite .bs-face {
  /* BACKWARDS, not BOTH. A "both" fill keeps the animation owning the
     transform property after it has finished, which pinned every plate at
     the keyframe end (translateX(0)) and silently beat both the on-air lift
     and the hover lift -- an animation always outranks a plain declaration.
     "backwards" still applies the from-state during the stagger delay, so
     nothing flashes in un-animated, but hands transform back to CSS the
     moment the animation ends. */
  animation: mp-slot-ignite 0.3s cubic-bezier(0.22, 1, 0.36, 1) backwards;
  animation-delay: calc(var(--slot-i, 0) * 55ms);
}

/* ---------- THE TALLY ----------
   ANONYMOUS by design. Deliberately not the same size, order or alignment
   as the roster plates, so nobody can read "pip 2 = the second face". */
#bs-tally {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 5px;
}
#bs-pips { display: flex; gap: 4px; }
.bs-pip {
  width: 4px;
  height: 14px;
  transform: skewX(-18deg);
  background: rgba(130, 255, 205, 0.16);
  transition: background 0.18s ease-out, box-shadow 0.18s ease-out;
  transition-delay: calc(var(--slot-i, 0) * 24ms);
}
.bs-pip-on {
  background: #86ffc2;
  box-shadow: 0 0 7px rgba(80, 255, 180, 0.8);
}
#screen-listen #bs-status {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 2px;
  color: rgba(159, 255, 207, 0.66);
  text-align: right;
  min-height: 0;
}

/* ---------- YOUR TRACK, on trial ---------- */
#bs-mine { justify-content: center; gap: 8px; text-align: center; }
#bs-mine-tag {
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 5px;
  color: rgba(255, 226, 150, 0.72);
}
#screen-listen #bs-mine-big { font-size: clamp(28px, calc(2.4 * var(--dvw, 1vw)), 40px); }

/* The rig is the one thing on this screen you actually operate, and it was
   reading as the smallest thing on it -- the rack next to it is enormous.
   The number and the fader both go up hard. There is room: the rig column
   is centred with a lot of empty space above and below it, so this grows
   into space that was already dead. (The 900px-tall trim block near the
   end of the file walks both back for short screens.) */
/* The number and the fader share one column, so every pixel the number
   takes is a pixel the fader doesn't get. At 250px the number was eating
   ~370px of a 658px column and the strip was left with 230px -- bigger
   number, SMALLER mixer, which is backwards. The mixer gets priority:
   the number stays clearly bigger than it was (170 -> 205) and the rest
   of the column goes to the strip. padding trimmed for the same reason. */
#screen-listen #bs-num { font-size: clamp(130px, calc(13 * var(--dvw, 1vw)), 205px); padding: 0; }
#screen-listen #bs-lock { font-size: 38px; letter-spacing: 4px; padding: 18px 10px; }
#screen-listen #bs-rig { flex: 0 0 clamp(260px, calc(23 * var(--dvw, 1vw)), 360px); }
/* The rig FILLS its column instead of floating in the middle of it. The
   fader was sitting at a fixed size with a wide margin of empty column on
   either side, which is what made the one control you actually operate the
   smallest thing on a screen full of huge instruments.
   width:100% takes the full column; flex:1 takes whatever height is left
   after the number, the LOCK IN button and the tally line have had theirs.
   min-height:0 is required -- a flex child defaults to min-height:auto and
   would refuse to shrink below its content, pushing the tally off. */
#screen-listen #bs-rig {
  /* align-self was `center`, which makes the column size to its CONTENT --
     so the fader's flex:1 had nothing to grow into and the whole rig sat
     shrink-wrapped in the middle of an empty column. Stretching it to the
     grid row is what actually hands the leftover height to the strip. */
  align-self: stretch;
  justify-content: space-between;
  gap: 10px;
  padding: 4px 0;
  min-height: 0;
}
#screen-listen #bs-fader {
  --bsf-cap-h: 64px;
  width: 100%;
  height: auto;
  flex: 1 1 auto;
  min-height: 220px;
  padding: 12px 14px 16px;
}
/* The moving parts scale with the plate. A 38px cap on a 100px-wide strip
   read fine; on a full-column strip it looks like a chip someone dropped in
   a groove -- the plate grew and the instrument inside it didn't, leaving
   a third of the width dead on the right. Everything below is sized off
   the real plate width so the strip reads as one machined object again.
   Cap HEIGHT comes from --bsf-cap-h above, which the throw maths and the
   scale padding already key off; only the width is free. */
#screen-listen .bsf-body { gap: 0 16px; }
#screen-listen .bsf-track { width: 76px; }
#screen-listen .bsf-slit { width: 18px; }
#screen-listen .bsf-glow { width: 10px; }
#screen-listen .bsf-cap { width: 108px; }
#screen-listen .bsf-cap::after { height: 5px; }
/* The engraved scale grows with it -- numerals you can read across the room
   and tick marks long enough to point at the slit they belong to. */
#screen-listen .bsf-tick { font-size: 17px; height: 18px; gap: 7px; }
#screen-listen .bsf-tick::after { width: 16px; }
#screen-listen .bsf-scale-r .bsf-tick::before { width: 11px; }
#screen-listen .bsf-head { font-size: 14px; }
/* The engraved scale and its numerals have to grow with the plate or the
   fader just looks like the same instrument photographed closer. */
/* (An earlier, smaller pass at these three sat here and was silently
   winning on source order -- same specificity, further down the file.
   The sizes now live in the block above with the rest of the strip.) */


/* ---------- rig tally: count + meter on one line ----------
   The pip meter moved up here from the floor strip so the number and the
   picture of that number sit together, directly under LOCK IN. */
#bs-rig-tally {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 20px;
  flex-wrap: nowrap;
}
#screen-listen #bs-rig-status {
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 3px;
  color: rgba(159, 255, 207, 0.8);
  white-space: nowrap;
}
/* Slightly taller here than it was in the floor strip: sitting beside
   17px type it has to read as part of the same line, not as debris. */
#bs-rig-tally #bs-pips { gap: 3px; }
#bs-rig-tally .bs-pip { width: 5px; height: 17px; }

/* The floor keeps the situation line only; its tally column no longer
   holds a meter, so it stops being a column. */
#bs-tally { flex-direction: row; align-items: center; }


/* ---------- THE ANALYSER RACK ----------
   Replaces the decorative circular scene. Reads as lab equipment: every
   module is a labelled instrument with a real scale, and the numbers next
   to them are the numbers a producer actually checks.

   Grid, so each module gets a definite box for its canvas to size to --
   a canvas with no intrinsic size in a flex child collapses. */
#bs-rack {
  position: absolute;
  inset: 12px;
  display: grid;
  grid-template-columns: 1fr clamp(150px, 15%, 210px) clamp(96px, 10%, 128px);
  /* All THREE rows are fr. An `auto` row here sized itself to the waveform
     module's max-content height -- and a <canvas> carries an intrinsic 2:1
     ratio, so at ~860px wide it claimed ~430px of height, swallowed the
     rack and left the fr rows collapsed to just their headers. */
  grid-template-rows: 1.15fr 1fr 0.9fr;
  gap: 10px;
  grid-template-areas:
    "spec  goni  loud"
    "scope goni  loud"
    "wave  wave  wave";
}
.bs-mod-spec  { grid-area: spec; }
.bs-mod-scope { grid-area: scope; }
.bs-mod-goni  { grid-area: goni; }
.bs-mod-loud  { grid-area: loud; }
.bs-mod-wave  { grid-area: wave; }

.bs-mod {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 5px 8px 7px;
  background: rgba(4, 16, 10, 0.55);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(130, 255, 205, 0.22);
  clip-path: polygon(0 7px, 7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%);
}
/* flex-basis 0, NOT auto: with `auto` the canvas contributes its intrinsic
   2:1 height to the module's content size, which is what let it dictate the
   grid instead of filling what the grid gave it. At basis 0 it simply takes
   whatever vertical room is left after the header and readouts. */
.bs-mod canvas {
  flex: 1 1 0;
  width: 100%;
  min-height: 0;
  display: block;
}
.bs-mod-h {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: 7px;
  margin-bottom: 4px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(159, 255, 207, 0.72);
  white-space: nowrap;
  overflow: hidden;
}
#screen-listen .bs-mod-h .bs-kata { font-size: 10px; letter-spacing: 2px; }
/* Numeric readouts. Tabular-ish spacing so a changing value doesn't make
   the label jitter sideways every frame. */
.bs-mod-r {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px;
  margin-top: 3px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 2px;
  color: rgba(130, 255, 205, 0.5);
}
.bs-mod-r b {
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #9fffcf;
  font-variant-numeric: tabular-nums;
}
.bs-mod-rms {
  margin-left: auto;
  font-size: 12px;
  letter-spacing: 1px;
  color: #9fffcf;
  font-variant-numeric: tabular-nums;
}


/* ---------- LEFT RAIL: running order + in-match chat ----------
   The queue is short and the column was mostly empty under it, so chat
   takes the remaining height. The rail slides away entirely on the handle,
   and the stage's first grid track goes to zero so the rack takes the room
   rather than leaving a gap where the rail was. */
#bs-rail {
  grid-column: 1;
  grid-row: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow: hidden;
  transition: opacity 0.22s ease-out;
}
#bs-queue { flex: 0 0 auto; }

#bs-chat {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 6px 8px 8px;
  background: rgba(4, 16, 10, 0.5);
  border: 1px solid rgba(130, 255, 205, 0.2);
  clip-path: polygon(0 7px, 7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%);
}
#bs-chat-head {
  flex: 0 0 auto;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(159, 255, 207, 0.7);
  margin-bottom: 5px;
}
#bs-chat-log {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  list-style: none;
  margin: 0;
  padding: 0 2px 0 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  scrollbar-width: thin;
  scrollbar-color: rgba(130, 255, 205, 0.3) transparent;
}
#bs-chat-log::-webkit-scrollbar { width: 5px; }
#bs-chat-log::-webkit-scrollbar-thumb { background: rgba(130, 255, 205, 0.3); }
.bs-chat-msg {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  line-height: 1.35;
  color: #d8ffd0;
  /* Long single-word messages must not widen the rail and shove the rack. */
  overflow-wrap: anywhere;
}
.bs-chat-msg b {
  display: block;
  font-size: 10px;
  letter-spacing: 2px;
  font-weight: normal;
  color: rgba(159, 255, 207, 0.55);
  cursor: pointer;
}
.bs-chat-msg b:hover { color: #86ffc2; }
.bs-chat-mine b { color: rgba(255, 215, 94, 0.7); }
.bs-chat-mine { color: #fff6d8; }

/* The quoted original, server-verified -- sits above the reply itself,
   visually one notch quieter so the new message still reads as the
   thing that's actually being said right now. */
.bs-chat-quote {
  margin-bottom: 3px;
  padding-left: 6px;
  border-left: 2px solid rgba(159, 255, 207, 0.3);
  font-size: 0.85em;
  color: rgba(216, 255, 208, 0.5);
  overflow-wrap: anywhere;
}
.bs-chat-quote b {
  display: inline;
  margin-right: 4px;
  font-size: 1em;
  letter-spacing: normal;
  color: rgba(159, 255, 207, 0.6);
  cursor: pointer;
}
.bs-chat-reply-btn {
  display: inline-block;
  margin-left: 6px;
  padding: 0 2px;
  background: transparent;
  border: none;
  color: rgba(159, 255, 207, 0.35);
  font-size: 0.9em;
  line-height: 1;
  cursor: pointer;
  vertical-align: middle;
}
.bs-chat-reply-btn:hover { color: #86ffc2; }

/* The "replying to X" strip above the input -- built/torn down by
   bsChatSetReply/bsChatClearReply, hidden the rest of the time. */
#bs-chat-replying {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 4px 0;
  padding: 4px 8px;
  background: rgba(20, 60, 38, 0.5);
  border-left: 2px solid rgba(159, 255, 207, 0.5);
  font-family: 'Courier New', monospace;
  font-size: 12px;
}
#bs-chat-replying.hidden { display: none; }
#bs-chat-replying span {
  flex: 0 0 auto;
  letter-spacing: 1px;
  color: rgba(159, 255, 207, 0.85);
}
#bs-chat-replying i {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-style: normal;
  color: rgba(216, 255, 208, 0.6);
}
#bs-chat-replying button {
  flex: 0 0 auto;
  background: transparent;
  border: none;
  color: rgba(255, 120, 120, 0.7);
  cursor: pointer;
  font-size: 13px;
}
#bs-chat-replying button:hover { color: #ff8080; }

#bs-chat-form {
  flex: 0 0 auto;
  display: flex;
  gap: 5px;
  margin-top: 6px;
}
#bs-chat-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 5px 7px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 1px;
  color: #eaffe3;
  background: rgba(2, 10, 6, 0.8);
  border: 1px solid rgba(130, 255, 205, 0.25);
  border-radius: 0;
  outline: none;
}
#bs-chat-input:focus { border-color: rgba(134, 255, 194, 0.75); }
#bs-chat-input::placeholder { color: rgba(130, 255, 205, 0.28); letter-spacing: 2px; }
#bs-chat-send {
  flex: 0 0 auto;
  width: 30px;
  padding: 0;
  font-size: 12px;
  color: #06180f;
  background: #86ffc2;
  border: none;
  cursor: pointer;
  clip-path: polygon(5px 0, 100% 0, calc(100% - 5px) 100%, 0 100%);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.15s;
}
#bs-chat-send:hover { transform: translate(-2px, -2px); filter: brightness(1.12); }
#bs-chat-send:active { transform: none; transition-duration: 0.06s; }

/* ---------- the collapse handle ----------
   Sits on the STAGE, so it survives the rail sliding away. */
#bs-stage { position: relative; }
#bs-rail-tab {
  position: absolute;
  left: calc(clamp(118px, calc(11 * var(--dvw, 1vw)), 168px) + 1px);
  top: 50%;
  transform: translateY(-50%);
  z-index: 5;
  width: 15px;
  height: 62px;
  padding: 0;
  background: rgba(6, 24, 15, 0.92);
  border: 1px solid rgba(130, 255, 205, 0.4);
  border-left: none;
  cursor: pointer;
  clip-path: polygon(0 0, 100% 7px, 100% calc(100% - 7px), 0 100%);
  transition: left 0.28s cubic-bezier(0.16, 1, 0.3, 1), background 0.15s;
}
#bs-rail-tab:hover { background: rgba(12, 48, 30, 0.95); }
/* The arrow, drawn from a rotated square so it flips with the state. */
#bs-rail-tab span {
  display: block;
  width: 5px;
  height: 5px;
  margin: 0 auto;
  border-left: 2px solid #86ffc2;
  border-bottom: 2px solid #86ffc2;
  transform: rotate(45deg);
  transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
#screen-listen.bs-rail-out #bs-rail-tab span { transform: rotate(-135deg); }

/* Collapsed: the track goes to zero and the rack takes the width. */
#screen-listen.bs-rail-out #bs-stage {
  grid-template-columns: 0px 1fr clamp(210px, calc(19 * var(--dvw, 1vw)), 292px);
}
#screen-listen.bs-rail-out #bs-rail { opacity: 0; pointer-events: none; }
#screen-listen.bs-rail-out #bs-rail-tab { left: 1px; }


/* ---------- REORGANISED STAGE ----------
   rail (chat) down the left spanning both rows, the rack top-middle, the
   react bar under it, and the rating rig down the right spanning both rows.
   The floor strip stays full-width below the stage. */
#bs-stage {
  grid-template-rows: 1fr auto;
  grid-template-areas:
    "rail rack rig"
    "rail react rig";
}
#bs-rail      { grid-area: rail; }
#bs-viz-wrap  { grid-area: rack; }
#bs-react     { grid-area: react; }
#bs-rig       { grid-area: rig; align-self: center; }
#bs-mine      { grid-area: rig; align-self: center; }

/* ---------- REACT BAR ---------- */
#bs-react {
  display: flex;
  align-items: center;
  gap: clamp(10px, calc(1.4 * var(--dvw, 1vw)), 22px);
  padding: 7px clamp(8px, calc(1 * var(--dvw, 1vw)), 16px);
  background: rgba(4, 16, 10, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(130, 255, 205, 0.2);
  clip-path: polygon(0 7px, 7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%);
}
#bs-react-label {
  flex: 0 0 auto;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(159, 255, 207, 0.6);
}
#bs-react-row {
  flex: 1 1 auto;
  display: flex;
  flex-wrap: wrap;
  gap: clamp(5px, calc(0.7 * var(--dvw, 1vw)), 12px);
}
.bs-react-btn {
  flex: 0 0 auto;
  width: clamp(34px, calc(3 * var(--dvw, 1vw)), 46px);
  height: clamp(30px, calc(2.6 * var(--dvw, 1vw)), 40px);
  padding: 0;
  font-size: clamp(17px, calc(1.5 * var(--dvw, 1vw)), 23px);
  line-height: 1;
  background: rgba(10, 40, 26, 0.6);
  border: 1px solid rgba(130, 255, 205, 0.28);
  cursor: pointer;
  clip-path: polygon(0 5px, 5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.15s, background 0.15s;
}
.bs-react-btn:hover {
  transform: translate(-3px, -3px) scale(1.08);
  background: rgba(18, 62, 40, 0.85);
  border-color: rgba(134, 255, 194, 0.9);
  filter: drop-shadow(2px 2px 0 rgba(16, 78, 48, 0.9)) drop-shadow(0 0 12px rgba(80, 255, 180, 0.45));
}
.bs-react-btn:active { transform: translate(0, 0) scale(0.94); transition-duration: 0.06s; }

/* ---------- THE SPRAY ----------
   Fixed and click-through: it covers the whole screen, so if it ever ate
   pointer events it would block voting, chat and the leave button. z-index
   sits in the HUD band, under the CRT glass (700) so the scanlines still
   wash over the emoji. */
#bs-spray {
  position: fixed;
  inset: 0;
  z-index: 690;
  pointer-events: none;
  overflow: hidden;
}
.bs-spray-bit {
  position: absolute;
  bottom: 8%;
  font-size: clamp(22px, calc(2.2 * var(--dvw, 1vw)), 38px);
  will-change: transform, opacity;
  animation-name: bs-spray-fly;
  animation-timing-function: cubic-bezier(0.2, 0.6, 0.3, 1);
  animation-fill-mode: both;
}
@keyframes bs-spray-fly {
  0%   { opacity: 0; transform: translate(0, 40px) scale(0.4) rotate(0deg); }
  12%  { opacity: 1; transform: translate(calc(var(--dx) * 0.15), -20px) scale(var(--sc)) rotate(calc(var(--rot) * 0.2)); }
  100% { opacity: 0; transform: translate(var(--dx), -calc(62 * var(--dvh, 1vh))) scale(var(--sc)) rotate(var(--rot)); }
}

/* ---------- speaking HUD vs chat ----------
   #voice-speakers is fixed bottom-left and grows upward, which lands it
   straight on top of the chat input on this screen. It's also redundant
   here: the floor roster plates already ring when their owner talks (see
   the #bs-roster selector in voice.js), so the same information is on
   screen twice, one copy covering the other. */
body:has(#screen-listen.active) #voice-speakers { display: none; }

/* ---------- header clearance ----------
   LEAVE is fixed at top-left; BEAT SHOWCASE was landing right under it and
   the two read as one crowded block. Push the header column clear. */
#screen-listen #bs-left { margin-top: 30px; }

/* ---------- showcase volume ----------
   Fixed beside LEAVE. The offset clears the LEAVE plate at its hover size,
   which is wider than its resting size -- sizing to the resting width would
   have them collide the moment you point at it. */
#bs-vol {
  position: fixed;
  /* left / top / height are set by bsPlaceVolume() from the LEAVE plate's
     real box -- see the note there on why a hardcoded offset drifted. */
  top: 16px;
  left: 208px;
  z-index: 690;
  display: flex;
  align-items: center;
  gap: 0;
}
#bs-vol-btn {
  flex: 0 0 auto;
  width: 52px;
  height: 100%;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(6, 24, 15, 0.9);
  border: 1px solid rgba(130, 255, 205, 0.45);
  cursor: pointer;
  /* Same cut corners as .sw-corner-btn, so it reads as the same kind of
     object as the LEAVE plate beside it rather than a different widget. */
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.15s, background 0.15s;
}
#bs-vol-btn svg {
  width: 26px; height: 26px;
  fill: none; stroke: #86ffc2; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
}
/* NO hover glow. These rules used to lift and bloom the button, and they
   outranked the plain overrides further down the file on specificity
   (:hover adds a class-level, so #bs-vol-btn:hover beat #bs-vol-btn) --
   which is why the glow survived being "removed" twice. Deleted at the
   source rather than overridden again. The border is the feedback. */
#bs-vol-btn:active { transition-duration: 0.06s; }
/* Muted swaps the arcs for the cross, so the icon states the level rather
   than just being a button that happens to be at zero. */
.bs-vol-x { display: none; }
#bs-vol.bs-vol-muted .bs-vol-wave { display: none; }
#bs-vol.bs-vol-muted .bs-vol-x { display: block; }
#bs-vol.bs-vol-muted #bs-vol-btn svg { stroke: #ff7a7a; }

/* Slides out of the button rather than appearing beside it. */
#bs-vol-panel {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 0;
  overflow: hidden;
  height: 100%;
  padding: 0;
  background: rgba(6, 24, 15, 0.9);
  border: 1px solid rgba(130, 255, 205, 0);
  border-left: none;
  transition: max-width 0.28s cubic-bezier(0.16, 1, 0.3, 1), padding 0.28s, border-color 0.2s;
}
#bs-vol.bs-vol-open #bs-vol-panel {
  max-width: 190px;
  padding: 0 12px;
  border-color: rgba(130, 255, 205, 0.45);
}
/* (The panel's hover bloom lived here. Same story as the button's: three
   selector levels deep, so it outranked every later override and kept
   glowing. Gone -- there is nothing left in this control that glows.) */
#bs-vol-range {
  width: 118px;
  height: 4px;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  background: linear-gradient(to right, #86ffc2 0 var(--p, 100%), rgba(130,255,205,0.18) var(--p, 100%) 100%);
  cursor: pointer;
  outline: none;
}
#bs-vol-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 5px; height: 16px;
  background: #eaffe3;
  box-shadow: 0 0 8px rgba(80, 255, 180, 0.9);
  cursor: pointer;
}
#bs-vol-range::-moz-range-thumb {
  width: 5px; height: 16px; border: none; border-radius: 0;
  background: #eaffe3;
  box-shadow: 0 0 8px rgba(80, 255, 180, 0.9);
}
#bs-vol-num {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 1px;
  color: rgba(159, 255, 207, 0.75);
  font-variant-numeric: tabular-nums;
  min-width: 26px;
  text-align: right;
}


/* ---------- THE FLOOR, scaled up ----------
   It was squeezed to ~96px to clear #voice-speakers, which is fixed
   bottom-left and grows upward. That HUD is now hidden on this screen (the
   plates below already ring when their owner talks), so the clearance
   budget that was holding the whole strip down is gone -- and this is the
   room looking at you, which is the point of the screen. */
#bs-floor {
  max-height: 170px;
  gap: clamp(18px, calc(2.2 * var(--dvw, 1vw)), 40px);
  padding: 14px clamp(10px, calc(1.6 * var(--dvw, 1vw)), 26px);
  border-top-width: 2px;
}
#bs-floor-label {
  font-size: clamp(21px, calc(1.8 * var(--dvw, 1vw)), 32px);
  letter-spacing: 4px;
}
#bs-roster {
  gap: 10px;
  align-content: center;
}
.bs-face {
  gap: 11px;
  padding: 10px 16px;
  max-width: 320px;
  font-size: clamp(15px, calc(1.15 * var(--dvw, 1vw)), 19px);
  letter-spacing: 1px;
  clip-path: polygon(0 9px, 9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%);
}
.bs-face .player-icon {
  width: 32px;
  height: 32px;
  font-size: 18px;
  border-radius: 4px;
}
.bs-face .clan-tag { font-size: 14px; }
.bs-face-mark { font-size: 13px; letter-spacing: 3px; }
.bs-face-voted .bs-face-mark { font-size: 19px; }
/* The on-air plate leads the row, so it gets the bigger lift now that
   there is vertical room for one. */
.bs-face-onair { transform: translateY(-8px) scale(1.08); }
.bs-face-onair:hover { transform: translateY(-10px) scale(1.09); }

/* The counter grows with the strip it sits in. */
#bs-progress b { font-size: clamp(48px, calc(4.4 * var(--dvw, 1vw)), 80px); }
#bs-progress span { font-size: clamp(26px, calc(2.3 * var(--dvw, 1vw)), 42px); }
#bs-progress i { font-size: clamp(24px, calc(2 * var(--dvw, 1vw)), 38px); }
#bs-progress::before { font-size: 14px; letter-spacing: 5px; }


/* ---------- REACT BAR: emoji only, centred, big ----------
   The label came off -- eight emoji explain themselves, and the words were
   pushing the row off-centre. The row now centres in the same column the
   rack occupies, which is what puts it directly under the ON AIR tab
   (that tab is centred in #bs-viz-wrap, the same grid column). */
#bs-react {
  justify-content: center;
  padding: 9px clamp(8px, calc(1 * var(--dvw, 1vw)), 16px);
}
#bs-react-row {
  flex: 0 0 auto;
  justify-content: center;
  gap: clamp(10px, calc(1.1 * var(--dvw, 1vw)), 20px);
}
.bs-react-btn {
  width: clamp(52px, calc(4.4 * var(--dvw, 1vw)), 74px);
  height: clamp(46px, calc(3.8 * var(--dvw, 1vw)), 64px);
  font-size: clamp(26px, calc(2.4 * var(--dvw, 1vw)), 38px);
  clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
}
.bs-react-btn:hover { transform: translate(-4px, -4px) scale(1.12); }


/* ---------- volume: one object when open ----------
   Collapsed, the button carries the house silhouette (cut top-right and
   bottom-left) so it matches LEAVE beside it. Opened, that top-right cut
   would land in the MIDDLE of the combined shape and snap the top line in
   half -- which is what made it read as two widgets stuck together.
   So on open the cuts migrate outward: the button keeps only its left
   corners, the panel takes the right ones, and the seam between them goes
   square. One continuous top edge across both. */
#bs-vol.bs-vol-open #bs-vol-btn {
  clip-path: polygon(0 0, 100% 0, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  border-right: none;
}
#bs-vol.bs-vol-open #bs-vol-panel {
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%);
}
/* NO divider at the seam. A hairline there was meant to show where the
   button half ended -- but a vertical line straight down the join is
   exactly what makes two halves read as two separate plates, which was
   the whole thing being fixed. The shapes are flush (measured: 0px gap,
   equal heights, shared top edge); leaving the seam completely bare is
   what makes it read as one object. */
#bs-vol-panel { position: relative; }
/* The two halves must share one background, or the seam shows as a tonal
   step even with the borders gone. */
#bs-vol.bs-vol-open #bs-vol-btn,
#bs-vol.bs-vol-open #bs-vol-panel { background: rgba(6, 24, 15, 0.92); }


/* ---------- volume: ONE plate that grows ----------
   Previous versions were a button and a panel side by side, each with its
   own border, background, clip-path and animation. However precisely they
   were joined, two shapes animating next to each other read as two things,
   and the seam showed as a break in the top edge the moment either one
   moved (visible in the recording as a notch at the join).
   So there is now only ONE box. #bs-vol itself is the plate -- one border,
   one background, one set of cut corners -- and the thing that animates is
   its WIDTH. The top edge is a single unbroken line that literally grows
   to the right, because it is a single line. The children carry no
   decoration of their own at all. */
#bs-vol {
  /* --vw-shut / --vw-open are written by bsPlaceVolume() from LEAVE's real
     box, so the shut size always matches the plate beside it. */
  width: var(--vw-shut, 190px);
  overflow: hidden;
  /* OPAQUE, not 0.9 alpha. #screen-listen's background is a radial spotlight
     over a grid texture; at 90% the plate let that through and the uneven
     wash underneath read as a glow inside the panel. Measured: the plate
     itself carries no shadow or filter -- the haze was the screen showing
     through it. */
  background: #061810;
  border: 1px solid rgba(130, 255, 205, 0.45);
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  transition:
    width 0.34s cubic-bezier(0.16, 1, 0.3, 1),
    border-color 0.15s,
    background 0.15s,
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#bs-vol.bs-vol-open { width: var(--vw-open, 380px); }
/* Every child is now just content sitting on the plate. */
#bs-vol-btn,
#bs-vol.bs-vol-open #bs-vol-btn,
#bs-vol-panel,
#bs-vol.bs-vol-open #bs-vol-panel {
  background: none;
  border: none;
  clip-path: none;
  filter: none;
  transform: none;
  max-width: none;
  transition: none;
}
/* The slider row keeps its own width so it doesn't squash while the plate
   is still opening -- it slides into view behind the clip instead of
   compressing, which is what makes the growth read as a reveal. */
#bs-vol-panel {
  flex: 0 0 auto;
  width: 172px;
  padding: 0 14px 0 2px;
  overflow: hidden;
}
/* Hover feedback belongs to the whole plate now. The extrusion lift is
   allowed only while SHUT: once open the slider is under the cursor, and
   shifting the plate by 2px mid-drag moves the track out from under the
   thumb you're holding. */
/* Hover lights the BORDER, not the fill. Opening the panel requires
   hovering, so a lifted background means the plate is bright green the
   entire time you can see it -- that isn't feedback, it's just a glowing
   panel. The edge says "live"; the fill stays the same dark plate. */
#bs-vol:hover { border-color: rgba(134, 255, 194, 0.95); }
/* Shut, the plate keeps the house extrusion lift -- a solid offset shadow,
   NOT a bloom. The 0 0 12px glow that used to ride along with it is gone:
   the panel opening on hover is already all the feedback this needs, and a
   control that halos every time the pointer passes it is noise. */
#bs-vol:hover:not(.bs-vol-open) {
  transform: translate(-2px, -2px);
  filter: drop-shadow(2px 2px 0 rgba(16, 78, 48, 0.9));
}
/* No glow while open. The plate is clipped and its background is nearly
   opaque, so a drop-shadow silhouette sits directly behind the shape and
   bleeds out from under every edge -- which reads as a haze on the INSIDE
   of the border rather than a halo outside it. The border alone is enough. */
#bs-vol.bs-vol-open { filter: none; }

/* ---------- volume level: segments, not a line ----------
   The old track was a single bright hairline running the width of the
   panel -- the brightest thing on the plate and, being a long thin lit
   line, the exact shape that was already reading badly at the seam. It's
   now a ladder of blocks: same information, reads as a meter on an
   instrument rather than a highlight. The mask cuts the gaps, so the fill
   gradient underneath still tells the level continuously. */
/* --p is the level, written by bsSetVolume(). The gradient is a hard cut,
   not a fade: everything left of the level is lit, everything right of it
   drops to a dim ghost, and the mask chops both into blocks. Dragging
   therefore lights and darkens whole segments as the cut passes them,
   which is the "moving it left and right" the control was missing --
   before this, --p was never set, so the bar sat fully lit at every
   level and the number was the only thing that moved. */
#bs-vol-range {
  width: 118px;
  height: 20px;
  background: linear-gradient(to right, #86ffc2 0 var(--p, 100%), rgba(96, 190, 150, 0.17) var(--p, 100%) 100%);
  -webkit-mask: repeating-linear-gradient(to right, #000 0 5px, transparent 5px 8px);
          mask: repeating-linear-gradient(to right, #000 0 5px, transparent 5px 8px);
}
/* Muted reads as the whole ladder gone cold, not as a bar that happens to
   be empty -- same rule the speaker icon follows. */
#bs-vol.bs-vol-muted #bs-vol-range { background: rgba(255, 122, 122, 0.22); }
#bs-vol.bs-vol-muted #bs-vol-num { color: rgba(255, 122, 122, 0.9); }
/* No thumb. The lit segments already say where the level is, and a thumb
   is one more small bright line on a control that just lost one. Dragging
   and clicking the track still work -- that's the input, not the thumb. */
#bs-vol-range::-webkit-slider-thumb { -webkit-appearance: none; width: 0; height: 20px; background: none; box-shadow: none; }
#bs-vol-range::-moz-range-thumb { width: 0; height: 20px; border: none; background: none; box-shadow: none; }
#bs-vol-num { font-size: 13px; color: rgba(159, 255, 207, 0.9); }


/* ---------- LOCK IN: the tally lives on the plate ----------
   The count and its meter used to float under the button as a loose line of
   small text, which read as debris next to a control that size. They're
   now a strip along the bottom edge INSIDE the button: the plate is the
   whole control, so the room's progress belongs on it.
   The button is dark-on-bright, so the strip inverts -- a cut-in panel of
   the plate's own colour darkened, with the pips lit in the bright green
   the plate is made of. That's what makes it read as machined into the
   button rather than printed on top of it. */
#screen-listen #bs-lock {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  padding: 0;
  overflow: hidden;
  /* ---- the shape changes when you commit ----
     Unlocked it's the plain slanted plate; locking notches all four corners
     in, quickly and with a slight overshoot so the cuts bite past their
     final depth and settle.
     clip-path only interpolates between polygons with the SAME number of
     vertices, so this shape is written with EIGHT points in both states.
     Here the pairs sit on top of each other (1&8 on the top-left corner,
     2&3 on the top-right, and so on) which draws exactly the same
     silhouette as the 4-point original -- they only separate once .locked
     pulls them apart. Without the matching count the browser gives up and
     snaps between the two shapes instead of morphing. */
  clip-path: polygon(
    12px 0,
    100% 0,
    100% 0,
    calc(100% - 12px) 100%,
    calc(100% - 12px) 100%,
    0 100%,
    0 100%,
    12px 0
  );
  transition:
    clip-path 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
    transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
    filter 0.18s ease-out;
}
/* The same eight points, pulled apart into 16px cuts. The left and right
   edges are slanted, so the points that travel down them shift 2px sideways
   as well -- otherwise the cut would sit slightly proud of the edge it is
   supposed to be biting into. */
#screen-listen #bs-lock.locked {
  clip-path: polygon(
    calc(12px + 16px) 0,
    calc(100% - 16px) 0,
    100% 16px,
    calc(100% - 10px) calc(100% - 16px),
    calc(100% - 12px - 16px) 100%,
    16px 100%,
    0 calc(100% - 16px),
    10px 16px
  );
}
#screen-listen #bs-lock-label {
  padding: 16px 10px 12px;
  line-height: 1;
}
#screen-listen #bs-rig-tally {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin: 0;
  padding: 7px 10px 8px;
  background: rgba(4, 22, 13, 0.82);
  /* Skewed to match the plate's cut sides, so the strip's edges run
     parallel to the button's rather than cutting across them. */
  border-top: 1px solid rgba(4, 16, 8, 0.5);
}
#screen-listen #bs-rig-status {
  margin: 0;
  min-height: 0;
  font-size: 14px;
  letter-spacing: 2px;
  color: rgba(160, 255, 210, 0.85);
  white-space: nowrap;
}
/* Bigger pips, and each one lights with its own stagger (--slot-i, already
   set per pip) -- so a vote landing reads as a segment switching on rather
   than a number quietly changing. That's the "looks like it works". */
#screen-listen #bs-rig-tally #bs-pips { gap: 4px; }
#screen-listen #bs-rig-tally .bs-pip { width: 6px; height: 20px; }
/* Locked, the text is emptied (see bsApplyLockUI) and the meter stands
   alone. display:none rather than just empty text: an empty span is still a
   flex item, so it would keep claiming the row's 10px gap and leave the bar
   sitting a few pixels left of centre. */
#screen-listen #bs-lock.locked #bs-rig-status { display: none; }
/* Locked, the plate goes gold -- but MUTED gold. At full brightness it was
   the loudest thing on the screen, which is backwards: once you've voted
   the button is finished and the track is what matters. Knocked back and
   desaturated so it still reads as "yours, done" without shouting. */
#screen-listen #bs-lock.locked {
  background: linear-gradient(180deg, #c9b98a, #9a854a);
  filter: saturate(0.72) brightness(0.92);
}
#screen-listen #bs-lock.locked #bs-lock-label { color: #1a1405; text-shadow: none; }
#screen-listen #bs-lock.locked #bs-rig-tally { background: rgba(30, 22, 6, 0.82); }
#screen-listen #bs-lock.locked #bs-rig-status { color: rgba(255, 233, 168, 0.9); }
#screen-listen #bs-lock.locked .bs-pip-on { background: #ffd257; box-shadow: 0 0 8px rgba(255, 210, 87, 0.85); }
/* Disabled once locked -- but the tally is information, not a control, so
   it must not fade out with the button. */
#screen-listen #bs-lock:disabled { opacity: 1; }


/* ---------- CHAT: wide enough to be a chat ----------
   The rail was calc(11 * var(--dvw, 1vw)) of near-transparent panel with 10-12px type, so it read
   as a decorative sliver rather than somewhere to talk -- you couldn't tell
   at a glance what it even was. Three separate things were wrong: it was
   too NARROW (messages wrapped every three words), too FAINT (a 0.5-alpha
   fill over a dark screen is barely a panel), and too SMALL (10px names).
   All three move together here; the rack gives up the width, which it can
   afford -- it's the biggest thing on the screen. */
/* ONE source of truth for the rail's width. The collapse handle is
   positioned off this too -- it used to carry its own hardcoded copy of the
   old clamp, so widening the rail left the handle stranded in the middle of
   the chat panel, an arrow floating over nothing. Two places holding the
   same number is what caused that; now there is one. */
#screen-listen { --bs-rail-w: clamp(230px, calc(19 * var(--dvw, 1vw)), 330px); --bs-rig-w: clamp(260px, calc(23 * var(--dvw, 1vw)), 360px); }
#screen-listen.bs-rail-out { --bs-rail-w: 0px; }
#screen-listen #bs-stage,
#screen-listen.bs-rail-out #bs-stage {
  grid-template-columns: var(--bs-rail-w) 1fr var(--bs-rig-w);
}
/* The react bar lives in the MIDDLE column, so it centres on that column --
   not on the screen. The rail and the rig aren't the same width (330 vs
   360), which left the emoji bar sitting 15px left of true centre.
   Shifting it by half the difference puts it on the screen's centre line,
   and because both widths are variables it stays there at every viewport
   instead of being a magic number that drifts. */
#screen-listen #bs-react {
  transform: translateX(calc((var(--bs-rig-w) - var(--bs-rail-w)) / 2));
}
/* Collapsed rail: the whole middle column moves, so the shift changes with
   it -- the variable already handles that, since --bs-rail-w goes to 0. */
/* The handle rides the rail's edge -- a notch cut into the panel's right
   border, not a chip hovering near it. Sized to be GRABBED: at 15x62 with a
   5px chevron it was a speck you had to aim at, on a screen where every
   other control is enormous. It has to survive being the smallest thing
   here without becoming the hardest thing to hit.
   Width is bounded by the stage's column gap (40px at 1920) -- any wider
   and it starts overlapping the rack. */
#screen-listen #bs-rail-tab,
#screen-listen.bs-rail-out #bs-rail-tab {
  left: calc(var(--bs-rail-w) + 1px);
  width: 34px;
  height: 168px;
  background: rgba(3, 14, 9, 0.95);
  border-color: rgba(130, 255, 205, 0.45);
  clip-path: polygon(0 0, 100% 12px, 100% calc(100% - 12px), 0 100%);
}
#screen-listen #bs-rail-tab:hover {
  background: rgba(16, 56, 36, 0.97);
  border-color: rgba(134, 255, 194, 0.95);
}
/* The chevron scales with it -- a 5px arrow on a 34px tab would just look
   like the tab had a fleck in it. */
#screen-listen #bs-rail-tab span {
  width: 12px;
  height: 12px;
  border-left-width: 3px;
  border-bottom-width: 3px;
}
#screen-listen #bs-chat {
  padding: 0;
  /* A real plate, not a wash: solid fill and a border you can find. */
  background: rgba(3, 14, 9, 0.92);
  border-color: rgba(130, 255, 205, 0.34);
}
/* The header becomes a title BAR -- a lit strip across the top of the panel.
   That single band is what makes it obviously "the chat" from across the
   room, which the old floating 11px label never did. */
#screen-listen #bs-chat-head {
  margin: 0;
  padding: 9px 12px 8px;
  font-size: 14px;
  letter-spacing: 4px;
  color: #061810;
  background: linear-gradient(180deg, rgba(134, 255, 194, 0.92), rgba(90, 220, 160, 0.86));
}
#screen-listen #bs-chat-head .bs-kata { color: rgba(6, 24, 16, 0.65); }
#screen-listen #bs-chat-log { padding: 10px 8px 10px 12px; gap: 9px; }
/* Message type up from 12/10 -> 15/12 -> 19/14 -> 23/17 across three
   rounds of "still reads small next to how much empty rail sits under
   it", and the name gets the UI green at full strength instead of 55% --
   at this size the name is what you scan. */
#screen-listen .bs-chat-msg { font-size: 23px; line-height: 1.4; }
#screen-listen .bs-chat-msg b { font-size: 17px; letter-spacing: 2px; color: #86ffc2; }
#screen-listen .bs-chat-mine b { color: #ffd75e; }
#screen-listen #bs-chat-form { margin: 0; padding: 0 10px 10px; gap: 7px; }
#screen-listen #bs-chat-input { padding: 9px 10px; font-size: 18px; }
#screen-listen #bs-chat-input::placeholder { color: rgba(130, 255, 205, 0.45); }
#screen-listen #bs-chat-send { width: 40px; font-size: 15px; }


/* ---------- LOCK IN: ask to be pressed ----------
   It sat there as a flat green plate. It's the ONE thing on this screen you
   have to do, on a timer, and it looked like a label. Now it breathes --
   a slow pulse while your vote is still unlocked, stopping the instant you
   lock, because a button that keeps demanding attention after you've done
   the thing is just noise. Brighter fill and a hard edge-light do the rest. */
#screen-listen #bs-lock:not(.locked) {
  background: linear-gradient(180deg, #b6ffd8, #4ef19a);
  animation: bs-lock-beckon 1.9s ease-in-out infinite;
}
#screen-listen #bs-lock-label {
  /* A touch of dark bloom under the glyphs so the label stays crisp on the
     brighter plate rather than dissolving into it. */
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
@keyframes bs-lock-beckon {
  0%, 100% {
    box-shadow: 0 0 0 0 rgba(120, 255, 190, 0), 0 0 18px rgba(80, 255, 180, 0.35);
    filter: brightness(1);
  }
  46% {
    box-shadow: 0 0 0 7px rgba(120, 255, 190, 0.13), 0 0 40px rgba(80, 255, 180, 0.75);
    filter: brightness(1.16);
  }
}
/* Hover already lifts and brightens; while beckoning, the pulse has to stop
   or the two fight and the button jitters under the cursor. */
#screen-listen #bs-lock:hover:not(.locked),
#screen-listen #bs-lock:focus-visible:not(.locked) {
  animation: none;
  box-shadow: 4px 4px 0 rgba(20, 90, 55, 0.7), 0 0 34px rgba(80, 255, 180, 0.8);
}
@media (prefers-reduced-motion: reduce) {
  #screen-listen #bs-lock:not(.locked) { animation: none; }
}


/* The byline sat 18px under the title, which on a title this size left it
   floating between the track name and the rack rather than reading as part
   of the same block. Tightened so the two belong together. */
/* Dead: overridden by the centring rule further down. Kept as history of
   why this number moved twice. */


/* ---------- the chat tab's beacon ----------
   A light runs the whole way around the handle, then the handle goes quiet
   for ten seconds and does it again. It's the only control on this screen
   that HIDES something, so it has to occasionally say it's there -- but a
   permanent pulse next to a live audio rack would just be another thing
   flickering. One lap, then silence, is enough.
   Built as a rotating conic wedge behind an inset plate, so what you see is
   only the sliver on the border. The tab's clip-path crops the wedge to the
   tab's own trapezoid, which is why the light follows the real edge and not
   a rectangle around it. */
#screen-listen #bs-rail-tab { position: absolute; overflow: hidden; }
#screen-listen #bs-rail-tab::before {
  content: '';
  position: absolute;
  /* Oversized and square so the wedge sweeps past every corner; a box the
     tab's own 34x168 shape would sweep a lopsided ellipse. */
  left: 50%;
  top: 50%;
  width: 340px;
  height: 340px;
  margin: -170px 0 0 -170px;
  background: conic-gradient(
    from 0deg,
    rgba(134, 255, 194, 0) 0deg,
    rgba(134, 255, 194, 0) 300deg,
    rgba(134, 255, 194, 0.55) 336deg,
    #d9ffe9 352deg,
    rgba(134, 255, 194, 0.55) 358deg,
    rgba(134, 255, 194, 0) 360deg
  );
  animation: bs-tab-beacon 11s linear infinite;
  pointer-events: none;
}
/* The inner plate. Everything except a 2px rim gets covered, so the wedge
   only ever shows as a travelling edge-light. */
#screen-listen #bs-rail-tab::after {
  content: '';
  position: absolute;
  inset: 2px;
  background: rgba(3, 14, 9, 0.98);
  clip-path: polygon(0 0, 100% 11px, 100% calc(100% - 11px), 0 100%);
  z-index: 1;
  pointer-events: none;
}
#screen-listen #bs-rail-tab span { position: relative; z-index: 2; }
/* The plate is opaque and sits over the tab's own background, so the hover
   fill has to move onto the plate or hovering does nothing visible. */
#screen-listen #bs-rail-tab:hover::after { background: rgba(16, 56, 36, 0.98); }
@keyframes bs-tab-beacon {
  /* One lap in the first ~1.4s, then parked and invisible for the rest of
     the 11s. Opacity does the waiting -- the wedge can't be moved offstage,
     since a full-bleed conic is always somewhere on the element. */
  0%   { transform: rotate(0deg);   opacity: 0; }
  2%   { opacity: 1; }
  11%  { opacity: 1; }
  14%  { transform: rotate(360deg); opacity: 0; }
  100% { transform: rotate(360deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #screen-listen #bs-rail-tab::before { animation: none; opacity: 0; }
}


/* ---------- THE FLOOR is a panel ----------
   It was a hairline border-top with nothing behind it, so the roster read
   as loose plates scattered along the bottom of the page rather than as a
   section of the interface. Everything else on this screen is a machined
   plate; the floor is the one part that wasn't, which is exactly why it
   didn't feel like anything.
   Same recipe as the chat rail and the rack: solid fill, a border you can
   find, and the house cut corners. */
#screen-listen #bs-floor {
  background:
    /* faint scanline weave, same as the fader plate -- keeps a big empty
       fill from reading as a flat grey slab */
    repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.014) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, rgba(8, 26, 17, 0.96), rgba(3, 14, 9, 0.97));
  border: 1px solid rgba(130, 255, 205, 0.3);
  clip-path: polygon(0 14px, 14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%);
  box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5);
  /* The cut corners eat into the ends, so the label and the track counter
     need to sit clear of them. */
  padding: 14px clamp(18px, calc(2 * var(--dvw, 1vw)), 34px);
}
/* The plates ON the panel have to separate from it now that it has a fill
   of its own -- their old gradient faded to TRANSPARENT at 58%, so most of
   each plate was just the page showing through and they'd sink into the
   new fill. Same left-to-right gradient, but landing on a dark solid
   instead of on nothing.
   :not() on the state classes: on-air is gold and away is dimmed, and this
   selector outranks both (0,2,0 vs 0,1,0), so without the guard it would
   silently repaint the two plates that carry actual meaning. */
#screen-listen .bs-face:not(.bs-face-onair):not(.bs-face-away) {
  background: linear-gradient(to right, rgba(14, 50, 33, 0.95), rgba(3, 15, 10, 0.92) 65%);
}


/* ---------- results: smoother arrivals ----------
   The podium landed hard: the winner's plate snapped open from a 1px CRT
   line in 0.62s and then tore itself apart chromatically on steps(1), which
   JUMPS by design. Fine as a glitch, but the screen it lives on is the
   moment the round pays off, and the whole thing read as jittery.
   Everything below is the same choreography, slowed and eased -- the plate
   still arrives, it just settles instead of snapping.
   Transform and opacity only for the movement: they're the two properties
   the compositor can animate without touching layout or paint, so a longer
   animation costs nothing extra. */
@keyframes rs1-arrive {
  0%   { opacity: 0; transform: scale(0.9) translateY(34px); }
  60%  { opacity: 1; }
  100% { opacity: 1; transform: none; }
}
/* The chromatic tear survives, but as a soft bloom that eases out rather
   than a stepped tear. Wider offsets with real blur radii, interpolated --
   the colour separation is still there, it just breathes apart instead of
   flickering. NO fill, exactly as before: the static .rs-slot-1 rule owns
   the filter channel afterwards and drives the audio-reactive glow, and a
   fill here would pin it at `none` forever. */
@keyframes rs1-bloom {
  0%   { filter: drop-shadow(-30px 0 26px rgba(255, 0, 80, 0.6)) drop-shadow(30px 0 26px rgba(0, 240, 255, 0.6)); }
  45%  { filter: drop-shadow(-11px 0 18px rgba(255, 0, 80, 0.4)) drop-shadow(11px 0 18px rgba(0, 240, 255, 0.4)); }
  100% { filter: drop-shadow(0 0 12px rgba(255, 205, 80, 0)) drop-shadow(0 0 12px rgba(0, 240, 255, 0)); }
}
.rs-slot-1.rs-in {
  animation:
    rs1-arrive 1.35s cubic-bezier(0.22, 1, 0.36, 1) both,
    rs1-bloom 1.5s cubic-bezier(0.33, 1, 0.68, 1);
}
/* 2nd keeps its signal-lock overshoot AND its border ignite -- it's the
   middle tier and has to stay more of an event than 3rd. Only the motion
   softens: the overshoot halves (-6px/1.012 -> -3px/1.006) and the whole
   thing runs half again as long, so it settles instead of snapping.
   Redefining the keyframes rather than swapping the animation, so the
   border ignite that rides alongside it survives. */
@keyframes rs2-lock {
  0%   { opacity: 0; transform: translateY(26px) scale(0.975); }
  60%  { opacity: 1; transform: translateY(-3px) scale(1.006); }
  100% { opacity: 1; transform: none; }
}
.rs-slot-2.rs-in {
  animation: rs2-lock 1.05s cubic-bezier(0.22, 1, 0.36, 1) both,
             rs2-border 1.5s ease-out both;
}
/* 3rd stays plain on purpose -- it's the baseline the other two are measured
   against -- but its plain fade gets the same unhurried curve. */
.rs-slot { transition: opacity 0.9s cubic-bezier(0.22, 1, 0.36, 1), transform 0.9s cubic-bezier(0.22, 1, 0.36, 1); }

/* The standings assemble more slowly and from closer in -- an 18px drop at
   0.6s per row reads as rows being thrown down; 10px at 1.1s reads as them
   settling. The per-row stagger widens to match. */
@keyframes rs-settle {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
#screen-results #rs-rest-wrap {
  animation: rs-settle 1.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
#screen-results #rs-rest li {
  animation: rs-settle 1.1s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(0.35s + var(--rs-i, 0) * 0.13s);
}
/* RETURN TO LOBBY: a slow 2s fade, and no movement at all. It's the one
   thing on screen you're meant to press, and it should appear rather than
   arrive -- sliding it up while someone is reading the standings just pulls
   the eye off them. Delayed until the list has finished assembling. */
@keyframes rs-actions-fade { from { opacity: 0; } to { opacity: 1; } }
#screen-results #rs-actions {
  animation: rs-actions-fade 2s ease-in-out 0.5s both;
}
@media (prefers-reduced-motion: reduce) {
  .rs-slot-1.rs-in, .rs-slot-2.rs-in,
  #screen-results #rs-rest-wrap, #screen-results #rs-rest li { animation: none; opacity: 1; transform: none; }
  .rs-slot { transition: none; }
  #screen-results #rs-actions { animation: rs-actions-fade 0.4s linear both; }
}


/* ================= THE INFERNO =================
   Locking a 10 sets the edges of the screen on fire for two seconds.

   The fire itself is a canvas particle system (swInferno in main.js) -- two
   earlier CSS-gradient versions read as coloured shapes rather than flame,
   because fire looks like fire through turbulent evolving detail and a
   gradient is smooth and static by definition.

   Everything here is just the overlay's frame: fixed, click-through, and
   completely absent when nothing is burning. No blend modes, no filters,
   nothing on the page underneath is restyled -- so nothing underneath
   re-renders, which is what keeps this off the analyser rack's back. */
#sw-inferno {
  position: fixed;
  inset: 0;
  /* Over everything in the game (the volume plate is 690, the corner LEAVE
     ~700) but UNDER #sw-modal at 980 -- a dialog asking you something has
     to stay readable even while the room is on fire. */
  z-index: 900;
  pointer-events: none;
  display: none;
  overflow: hidden;
  /* NO `contain: paint` / `contain: strict` here, deliberately. Paint
     containment creates a stacking context, and a mix-blend-mode child can
     only blend within its own stacking context -- so containing this would
     leave the tint sheets below blending against nothing instead of against
     the game. Layout and style containment are safe and keep the isolation
     that actually matters. */
  contain: layout style;
}
#sw-inferno.sw-inferno-on { display: block; }

/* ---- the UI's OWN colours heat up ----
   NOT a sheet over the top. Two attempts did that -- a mix-blend-mode
   layer -- and both just painted the screen solid red, burying everything.
   What's wanted is the thing the fader already does at 10: the interface's
   own greens turn yellow then red, while the dark background stays dark.

   The filter is built and animated in JS (swfBuildHeatFilter) as an SVG
   feColorMatrix, because CSS filter functions measurably cannot get there:
   hue-rotate() is a linear approximation that desaturates as it turns
   (best case from this palette was a dusty pink), and every sepia/saturate
   recipe collapsed to yellow. A colour matrix has the channel mapping
   written out, so it goes exactly where it's told.
   All this rule does is attach it while the class is present. */
/* Filter PREWARM (swfWarm in main.js): each matrix gets attached for two
   frames at its resting identity when the showcase screen opens, so the
   compositor builds the reference-filter path and re-rasters the screen
   through it BEFORE the first LOCK IN -- that build was part of the
   first-burn freeze. Identity matrix = pixel-identical output, so nothing
   shows. These sit ABOVE the burn rules on purpose: same specificity, and
   if a burn starts mid-warm the burn's filter+animation must win. */
body.sw-elem-warm-heat #screen-listen { filter: url(#sw-heat); }
body.sw-elem-warm-chill #screen-listen { filter: url(#sw-chill); }
body.sw-burning #screen-listen {
  filter: url(#sw-heat);
  animation: sw-pov-heat 2s cubic-bezier(0.32, 0.9, 0.3, 1) both;
}
/* The cold half: a 1 puts the screen through the blue matrix instead. */
body.sw-freezing #screen-listen {
  filter: url(#sw-chill);
  animation: sw-pov-cold 2s linear both;
}

/* ---- the camera moves ----
   Both are transforms on the SCREEN, which is also what carries the colour
   filter. That matters: a filter already makes this element the containing
   block for its position:fixed children (the volume plate, the LEAVE
   plate), so they travel with the move instead of staying nailed to the
   viewport while everything else shifts. A camera should take the whole
   frame with it.
   The overlays are deliberately NOT moved -- they're body-level, so the
   flames and the snow hold still while the interface moves under them,
   which reads as the effect being ON the lens rather than in the room.

   HEAT: a slow push in. The heat arrives and the view leans toward it,
   drifts, and settles back. One continuous move, no snap. */
@keyframes sw-pov-heat {
  0%   { transform: scale(1) translateY(0) rotate(0deg); }
  16%  { transform: scale(1.048) translateY(-7px) rotate(0.34deg); }
  44%  { transform: scale(1.028) translateY(-2px) rotate(-0.2deg); }
  68%  { transform: scale(1.036) translateY(-5px) rotate(0.1deg); }
  100% { transform: scale(1) translateY(0) rotate(0deg); }
}

/* COLD: a shiver. Hits hardest as the frost lands and decays away, rather
   than rattling evenly for two seconds -- an even shake reads as a broken
   monitor, a decaying one reads as an impact. Sub-pixel by the end, which
   is why the last few steps look almost identical.

   OVERSCAN. Every shaking frame is scaled up ~1.4%, which at 1920 wide is
   about 27px of hidden margin -- more than enough to cover a 5px shove.
   Without it, translating a full-bleed screen sideways exposes a bare strip
   of page along the opposite edge on every single shake frame. */
@keyframes sw-pov-cold {
  0%   { transform: scale(1) translate(0, 0) rotate(0deg); }
  3%   { transform: scale(1.014) translate(-5px, 3px) rotate(-0.28deg); }
  8%   { transform: scale(1.014) translate(4px, -4px) rotate(0.24deg); }
  12%  { transform: scale(1.014) translate(-4px, -2px) rotate(0.18deg); }
  17%  { transform: scale(1.014) translate(3px, 4px) rotate(-0.2deg); }
  22%  { transform: scale(1.014) translate(-3px, 2px) rotate(0.14deg); }
  28%  { transform: scale(1.013) translate(3px, -3px) rotate(-0.12deg); }
  34%  { transform: scale(1.012) translate(-2px, -2px) rotate(0.1deg); }
  41%  { transform: scale(1.011) translate(2px, 2px) rotate(-0.08deg); }
  49%  { transform: scale(1.009) translate(-2px, 1px) rotate(0.06deg); }
  57%  { transform: scale(1.007) translate(1px, -2px) rotate(-0.05deg); }
  66%  { transform: scale(1.005) translate(-1px, 1px) rotate(0.03deg); }
  76%  { transform: scale(1.004) translate(1px, 1px) rotate(-0.02deg); }
  86%  { transform: scale(1.002) translate(-1px, 0) rotate(0.01deg); }
  100% { transform: scale(1) translate(0, 0) rotate(0deg); }
}

/* ---- no scrollbars while the camera moves ----
   A transform contributes to the page's scrollable overflow (unlike
   position:fixed, which does not). Scaling the screen up ~4.8% therefore
   makes the document bigger than the viewport -- measured at 49px across
   and 40px down -- and the browser answers with scrollbars on both sides,
   which then steal 15px of width and shove the layout for the duration.
   `clip` rather than `hidden`: clip never creates a scroll container at
   all, so there is nothing to show a bar for and nothing to accidentally
   scroll. The rule is on BOTH because overflow set on body only reaches
   the viewport while html is `visible` -- stating it on html directly
   removes the dependency on that propagation quirk. */
html:has(> body.sw-burning),
html:has(> body.sw-freezing) { overflow: clip; }
body.sw-burning,
body.sw-freezing { overflow: clip; }

/* Camera moves are the textbook case for this setting -- the colour and the
   effect still play, the view just stops moving. */
@media (prefers-reduced-motion: reduce) {
  body.sw-burning #screen-listen,
  body.sw-freezing #screen-listen { animation: none; }
}
#swf-canvas {
  position: absolute;
  inset: 0;
  display: block;
  /* Width/height attributes are set from JS at ignition and zeroed on
     burnout; the CSS size is what the canvas is PAINTED at. */
}

/* A full-screen flare is exactly what this setting is for. The JS checks the
   same query and never even starts the loop, so this is the belt to that
   braces. */
@media (prefers-reduced-motion: reduce) {
  #sw-inferno, #sw-inferno.sw-inferno-on { display: none; }
}



/* ---------- FIREWORKS ----------
   Sits BEHIND the podium, inside #screen-results rather than in a body-level
   layer, so the stacking is decided by one parent instead of by z-index
   guesswork against every other fixed thing on the page.
   The backing store is rendered at SW_FW_SCALE and stretched by CSS -- the
   shader is 560 iterations per pixel, so the resolution is the only lever
   that matters. Fireworks are soft glows; the upscale doesn't show. */
#screen-results { position: relative; }
.sw-fw-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: none;
  pointer-events: none;
}
.sw-fw-layer.sw-fw-on { display: block; }
/* The two layers sit on either side of the podium: one behind, one in
   front. That is the whole point -- bursts happen AROUND the winner rather
   than only behind them. The front one is sparse and dimmed in JS so it
   reads as depth instead of covering the thing you came to look at. */
#sw-fw { z-index: 0; }
#rs-stage { position: relative; z-index: 1; }
#sw-fw-front { z-index: 2; }


/* ---------- the title's glow gets out of its box ----------
   #bs-title carries `overflow: hidden` so a long track name truncates with
   an ellipsis instead of running off the screen. But overflow clips the
   TEXT-SHADOW too, and the shadow reaches 70px while the line box is barely
   taller than the glyphs -- so the glow was being sliced off flat along the
   element's edges. That is the invisible wall.
   `clip` plus `overflow-clip-margin` is the fix: it still truncates the
   text at the box, but pushes the CLIPPING boundary out by 80px, which is
   more than the shadow's reach. The ellipsis behaviour is unchanged.
   (Browsers without overflow-clip-margin fall back to clipping at the box,
   i.e. exactly what happens today -- no regression.) */
#screen-listen #bs-title {
  overflow: clip;
  overflow-clip-margin: 80px;
}

/* ---------- header: tighter, and no ON AIR badge ----------
   The badge sat between the byline and the rack announcing something the
   giant title, the byline and the lit-up floor plate were already saying
   three other ways. Removed from the markup; this is belt and braces in
   case anything still renders it.
   The byline also comes up hard against the title -- 1.05 line-height on a
   116px face leaves a lot of air under the glyphs, so a small negative
   margin closes a gap that was never really typographic. */
#bs-onair { display: none !important; }
/* CENTRED. The -10px here (and the 2px above) were both tuned against a
   1.05 line-height; raising it to 1.15 to stop the descenders landing on
   this plate moved the title box, and the old negative margin then pulled
   the byline INTO the title -- measured at -5px of overlap above and 25px
   of air below. 18px puts equal space on both sides, so the name reads as
   sitting between the title and the rack rather than hanging off the title. */
#screen-listen #bs-byline { margin-top: 18px; }
/* ---------- REACTION BUBBLES ----------
   A speech bubble over the plate of whoever reacted. Clash Royale's emote,
   built out of this game's parts: a cut-corner plate instead of a rounded
   cartoon bubble, with a tail pointing down at its owner.
   The layer is FIXED and body-level, not inside the floor strip -- the floor
   carries a clip-path for its cut corners, and a bubble sitting above a
   plate would be sliced clean off by it. */
#bs-bubbles {
  position: fixed;
  inset: 0;
  z-index: 700;
  pointer-events: none;
  overflow: hidden;
}
.bs-bubble {
  position: absolute;
  /* left/top are the plate's own centre and top, written by bsBubble(); the
     translate lifts the bubble clear and centres it on the plate. */
  transform-origin: 50% 115%;
  translate: -50% -100%;
  margin-top: -14px;
  min-width: 62px;
  padding: 10px 14px 12px;
  font-size: 34px;
  line-height: 1;
  text-align: center;
  background: linear-gradient(180deg, rgba(12, 34, 22, 0.97), rgba(4, 16, 10, 0.97));
  border: 1px solid rgba(134, 255, 194, 0.75);
  clip-path: polygon(0 9px, 9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%);
  /* clip-path clips box-shadow, so the lift is a drop-shadow filter --
     the same rule the corner buttons follow. */
  filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 12px rgba(80, 255, 180, 0.35));
  animation: bs-bubble-pop 2.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* The tail. A rotated square tucked under the plate's bottom edge, so it
   reads as one shape rather than a box with a triangle stuck on it. */
.bs-bubble::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -6px;
  width: 13px;
  height: 13px;
  margin-left: -6px;
  background: rgba(4, 16, 10, 0.97);
  border-right: 1px solid rgba(134, 255, 194, 0.75);
  border-bottom: 1px solid rgba(134, 255, 194, 0.75);
  transform: rotate(45deg);
}
/* Springy in, hold, then let go upward -- the hold is most of it, because
   the point is to be READ, not to be an animation. */
@keyframes bs-bubble-pop {
  0%   { opacity: 0; scale: 0.3; }
  9%   { opacity: 1; scale: 1.14; }
  16%  { scale: 0.96; }
  22%  { scale: 1; }
  78%  { opacity: 1; scale: 1; translate: -50% -100%; }
  100% { opacity: 0; scale: 0.94; translate: -50% -128%; }
}
@media (prefers-reduced-motion: reduce) {
  .bs-bubble { animation: bs-bubble-pop 2.3s steps(1, end) both; }
}


/* ---------- results: catching up ----------
   Reloading on the podium, or joining after the reveal already ran, used to
   replay the whole show from third place -- so two people looking at the
   same screen were watching different moments. rsRun() now works out from
   the server's stamp where the ROOM is and marks any beat that already
   happened .rs-caught. Those plates are simply THERE: no boot, no chroma
   split, no scan, no bracket snap, no aura ignition.
   The winner's standing decorations (the held aura, the corner brackets in
   their final position) are NOT killed -- they're the state of the screen,
   not the entrance. Only the one-shot entrances are.
   Must sit after the .rs-slot-1.rs-in block: an animation outranks a plain
   declaration, so this has to win on source order at equal specificity. */
.rs-slot.rs-caught,
.rs-slot-1.rs-caught,
.rs-slot-2.rs-caught {
  animation: none;
  opacity: 1;
  transform: none;
  filter: none;
  /* transition too, not just animation: 3rd's plain reveal is a TRANSITION
     now (0.9s), and a catch-up setting opacity/transform would quietly play
     it -- a fade for someone who is supposed to have missed the reveal. */
  transition: none;
}
.rs-slot-1.rs-caught .rs-scan,
.rs-slot-2.rs-caught .rs-scan { animation: none; opacity: 0; }
.rs-slot-1.rs-caught .rs-place,
.rs-slot-1.rs-caught .rs-score,
.rs-slot-2.rs-caught .rs-name { animation: none; opacity: 1; transform: none; }
/* Brackets skip their snap-in but stay put, and the aura keeps breathing --
   that's what the plate looks like once it's landed, which is exactly the
   state a late arrival should find it in. */
.rs-slot-1.rs-caught .rs-bracket-tr,
.rs-slot-1.rs-caught .rs-bracket-bl { animation: none; opacity: 1; transform: none; }
.rs-slot-1.rs-caught .rs-aura { animation: rs1-aura 3.6s ease-in-out infinite; }


/* ---------- reaction cooldown ----------
   One reaction per 15s. Greyed rather than hidden, with the seconds
   remaining beside them: a button that silently ignores you reads as
   broken, one that visibly says "not yet" reads as a rule. */
.bs-react-btn:disabled {
  cursor: not-allowed;
  opacity: 0.3;
  filter: grayscale(0.85);
}
.bs-react-btn:disabled:hover {
  transform: none;
  background: rgba(10, 40, 26, 0.6);
  border-color: rgba(130, 255, 205, 0.28);
  filter: grayscale(0.85);
}
#bs-react-cool {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: rgba(159, 255, 207, 0.55);
  font-variant-numeric: tabular-nums;
  min-width: 34px;
  text-align: left;
}
#bs-react-cool.hidden { display: none; }


/* ---------- react buttons: emoji only, no plate ----------
   The square behind each one was fighting the emoji for attention and
   turning a row of eight into a row of eight BOXES. Bare emoji read faster
   and the hover does all the affordance work. */
.bs-react-btn {
  background: none;
  border: none;
  clip-path: none;
  width: auto;
  height: auto;
  padding: 4px 2px;
  filter: saturate(0.92);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.15s;
}
.bs-react-btn:hover {
  background: none;
  border: none;
  transform: translateY(-6px) scale(1.28);
  /* The glow is the affordance now that the plate is gone. */
  filter: saturate(1.15) drop-shadow(0 0 10px rgba(80, 255, 180, 0.75)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.6));
}
.bs-react-btn:active { transform: translateY(-2px) scale(1.06); transition-duration: 0.06s; }
.bs-react-btn:disabled,
.bs-react-btn:disabled:hover {
  background: none;
  border: none;
  transform: none;
  opacity: 0.22;
  filter: grayscale(1);
}
/* No plates to space out any more, so the row can breathe on its own. */
#bs-react-row { gap: clamp(14px, calc(1.6 * var(--dvw, 1vw)), 30px); }


/* ---------- react bar: shrink the plate to its contents ----------
   A grid item stretches to fill its track by default, so this was a
   full-width panel wrapped around eight emoji with a lot of nothing either
   side. justify-self:center makes it shrink-to-fit AND keeps it centred in
   the same column the rack occupies -- which is what keeps it lined up
   under the ON AIR tab. */
#bs-react {
  justify-self: center;
  width: auto;
  max-width: 100%;
  padding: 8px clamp(18px, calc(1.8 * var(--dvw, 1vw)), 34px);
}


/* ---------- floor plates: stop them colliding ----------
   Three things were making these look sloppy, all of them mine:

   1. SCALE. The on-air plate used scale(1.08) and hover used scale too --
      a scaled element is drawn wider than the box the row laid out for it,
      so it grew straight into its neighbours. Transform never tells the
      layout anything. Vertical lift only now; nothing changes width.
   2. HARD OFFSET SHADOWS. The extrusion threw 2px/4px shadows down-RIGHT,
      i.e. directly onto the next plate along. Replaced with a centred glow
      that fades on all sides instead of pointing at a neighbour.
   3. NO STACKING ORDER. With everything at the same level, whichever plate
      came later in the DOM won and the lifted one got cut off by it. The
      lifted plate now sits above its siblings.

   Also: real GAP between them, so even the lift has room. */
#bs-roster { gap: 14px 16px; }

.bs-face {
  position: relative;
  z-index: 1;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
              border-color 0.15s, background 0.15s, filter 0.15s;
}
.bs-face:hover {
  transform: translateY(-5px);          /* no scale, no sideways drift */
  z-index: 3;
  color: #eaffe3;
  border-color: rgba(134, 255, 194, 0.95);
  filter: drop-shadow(0 0 12px rgba(80, 255, 180, 0.5));
}
.bs-face:active {
  transform: translateY(-1px);
  transition-duration: 0.06s;
}
.bs-face-onair {
  transform: translateY(-5px);          /* was translateY(-8px) scale(1.08) */
  z-index: 2;
}
.bs-face-onair:hover { transform: translateY(-7px); }

/* The truncation was the ON AIR tag eating the name's room; give names more
   to work with now that plates no longer overlap. */
.bs-face { max-width: 360px; }


/* ---------- react bar: sit it in the middle of its own band ----------
   The stage used one gap value for both axes, so the generous COLUMN gap
   (up to 40px, right for separating rail / rack / rig) was also being
   applied between the rack and this bar -- pushing it down and leaving a
   dead band above the floor. Split the axes: keep the columns apart, pull
   the rows together, and centre the bar in whatever height is left. */
#bs-stage {
  column-gap: clamp(18px, calc(2.4 * var(--dvw, 1vw)), 40px);
  row-gap: clamp(8px, calc(0.9 * var(--dvh, 1vh)), 14px);
}
#bs-react { align-self: center; }

/* Bigger, now that the plate hugs them and there is room. */
.bs-react-btn { font-size: clamp(34px, calc(3 * var(--dvw, 1vw)), 50px); }
#bs-react-row { gap: clamp(16px, calc(1.9 * var(--dvw, 1vw)), 34px); }


/* ---------- header column: line up with the chat rail below it ----------
   #bs-left carried its own padding-left (up to 46px) while #bs-stage's rail
   starts flush at the screen padding -- so the timer sat inset from the chat
   panel directly beneath it and nothing in that left column shared an edge.
   Zeroing it puts BEAT SHOWCASE, the timer and the chat rail on one line. */
#screen-listen #bs-left { padding-left: 0; }

/* No optical nudge here: the boxes are aligned exactly, and guessing at
   side bearing without being able to SEE the result is how things end up
   4px out. If the glyph edge reads inset once you look at it, that is a
   one-line margin. */

/* ---------- BEAT SHOWCASE responsive ----------
   MUST stay at the end of the file, below the arena's base rules. A media
   query carries no extra weight: at equal specificity the last rule in the
   file wins, so sitting above those rules meant these silently did nothing
   (the stage stayed 3 columns at 800px wide). */
@media (max-width: 860px) {
  #bs-top { grid-template-columns: 1fr; gap: 14px; }
  #bs-brief { text-align: left; }
  .bs-brief-row { justify-content: flex-start; }
  #bs-brief-words { justify-content: flex-start; }
  #bs-stage { grid-template-columns: 1fr; }
  #bs-rail     { grid-column: 1; grid-row: 1; }
  #bs-viz-wrap { grid-column: 1; grid-row: 2; min-height: calc(34 * var(--dvh, 1vh)); }
  #bs-rig, #bs-mine { grid-column: 1; grid-row: 3; }
  /* The rail goes horizontal and drops its labels -- the numbers and bars
     still carry position, which is all it owes at this width. */
  #bs-floor { flex-direction: column; align-items: stretch; max-height: none; }
  #bs-roster { flex-wrap: nowrap; overflow-x: auto; }
  #bs-tally { flex-direction: row; align-items: center; justify-content: space-between; }
  #screen-listen #bs-status { text-align: left; }
  .bs-face-name { display: none; }
}

/* Short viewports: the display faces give first. */
@media (max-height: 540px) {
  #screen-listen #bs-timer { font-size: clamp(52px, calc(6.4 * var(--dvw, 1vw)), 96px); }
  #screen-listen #bs-title { font-size: clamp(42px, calc(5.8 * var(--dvw, 1vw)), 88px); }
  #screen-listen #bs-floor { max-height: 136px; }
  #screen-listen #bs-num { font-size: clamp(72px, calc(8 * var(--dvw, 1vw)), 118px); }
  /* The fader now STRETCHES to fill the rig column, so there's no fixed
     height to walk back -- it takes what's left after the number shrinks
     above. Only its floor needs lowering, or that min-height is what
     overflows a short screen. */
  #screen-listen #bs-fader { min-height: 160px; --bsf-cap-h: 38px; }
  #screen-listen #bs-lock { font-size: 27px; padding: 12px 10px; }
  #screen-listen .bsf-tick { font-size: 10px; height: 11px; }
  #screen-listen .bsf-cap { width: 46px; }
}

@media (prefers-reduced-motion: reduce) {
  #bs-floor.bs-floor-ignite .bs-face { animation: none; }
  /* Kills the audio-reactive breathe. These are the only two consumers of
     --bs-glow, so dropping filter here stops the pulsing without losing any
     static styling. */
  #bs-viz-wrap, .bs-face-onair { filter: none; }
}


/* ================= SETTINGS =================
   Console-menu layout: tabs across the top, then rows of label-left /
   control-right grouped under section headings. Deliberately dense and
   deliberately terse -- a settings screen is a list of switches, not a
   document. Nothing here is a placeholder; every row drives something. */
#screen-settings.active {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: clamp(24px, calc(4 * var(--dvh, 1vh)), 56px) 24px 40px;
}
#set-head {
  width: min(920px, calc(94 * var(--dvw, 1vw)));
  flex: 0 0 auto;
}
#set-title {
  margin: 0 0 18px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(34px, calc(3.4 * var(--dvw, 1vw)), 54px);
  letter-spacing: 6px;
  color: #eaffe3;
  text-shadow: 0 0 18px rgba(80, 255, 180, 0.5);
}
#set-title .set-kata {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 5px;
  color: rgba(140, 255, 205, 0.45);
  font-style: normal;
}
/* Tabs. The active one is a solid plate and the rest are outlines, so which
   pane you are on is readable at a glance rather than by an underline. */
#set-tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 18px;
  border-bottom: 1px solid rgba(130, 255, 205, 0.22);
  padding-bottom: 12px;
}
.set-tab {
  flex: 1 1 0;
  padding: 12px 8px 10px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 4px;
  color: rgba(159, 255, 207, 0.7);
  background: rgba(4, 16, 10, 0.6);
  border: 1px solid rgba(130, 255, 205, 0.28);
  clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
  cursor: pointer;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.set-tab:hover { color: #d8ffd0; border-color: rgba(134, 255, 194, 0.7); }
/* The 🌐 language button rides the tab rail but is NOT a tab: it never goes
   solid, because it isn't a pane you can be "on" -- one click steps to the
   next language and the page re-sweeping is the feedback. Kept narrow
   (flex 0) so four real tabs still own the row. */
#set-lang-globe {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 12px 14px 10px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 1px;
  color: rgba(159, 255, 207, 0.85);
  background: rgba(4, 16, 10, 0.6);
  border: 1px solid rgba(130, 255, 205, 0.28);
  clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}
#set-lang-globe:hover { color: #d8ffd0; border-color: rgba(134, 255, 194, 0.7); }
.set-tab-on {
  color: #061810;
  background: linear-gradient(180deg, #9fffcf, #4ef19a);
  border-color: transparent;
}

#set-body {
  width: min(920px, calc(94 * var(--dvw, 1vw)));
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(130, 255, 205, 0.3) transparent;
}
#set-body::-webkit-scrollbar { width: 6px; }
#set-body::-webkit-scrollbar-thumb { background: rgba(130, 255, 205, 0.3); }
.set-pane.hidden { display: none; }

/* Section heading: a full-width bar, like the reference. It separates
   groups without costing a row of its own. */
.set-sec {
  margin: 18px 0 8px;
  padding: 7px 14px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 5px;
  color: rgba(159, 255, 207, 0.8);
  background: rgba(10, 34, 22, 0.85);
  border-left: 3px solid rgba(134, 255, 194, 0.8);
}
.set-sec:first-child { margin-top: 0; }

/* The row. Label left, control right on a FIXED split, so every control on
   the screen lines up in one column no matter how long the label is. */
.set-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
  align-items: center;
  gap: 20px;
  padding: 9px 14px;
  border-bottom: 1px solid rgba(130, 255, 205, 0.07);
}
.set-row:hover { background: rgba(20, 60, 40, 0.28); }
.set-label {
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 1.5px;
  color: #d8ffd0;
}
.set-ctl { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }

/* ---- slider ---- */
.set-slider { gap: 14px; }
.set-slider input[type='range'] {
  flex: 1 1 auto;
  min-width: 0;
  height: 20px;
  appearance: none;
  -webkit-appearance: none;
  background: linear-gradient(to right, #86ffc2 0 var(--p, 100%), rgba(96, 190, 150, 0.17) var(--p, 100%) 100%);
  /* Same segmented ladder as the showcase volume, so a level looks like a
     level everywhere in the game rather than like two different widgets. */
  -webkit-mask: repeating-linear-gradient(to right, #000 0 5px, transparent 5px 8px);
          mask: repeating-linear-gradient(to right, #000 0 5px, transparent 5px 8px);
  cursor: pointer;
  outline: none;
}
.set-slider input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; width: 0; height: 20px; }
.set-slider input[type='range']::-moz-range-thumb { width: 0; height: 20px; border: none; background: none; }
.set-val {
  flex: 0 0 auto;
  min-width: 38px;
  text-align: right;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  font-weight: normal;
  color: #86ffc2;
  font-variant-numeric: tabular-nums;
}

/* ---- OFF | ON split toggle ---- */
.set-toggle {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;
  width: 100%;
  max-width: 260px;
  cursor: pointer;
  user-select: none;
  justify-self: end;
}
.set-toggle span {
  padding: 9px 0 8px;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 3px;
  transition: background 0.16s, color 0.16s;
}
.set-toggle .set-off,
.set-toggle .set-on { color: rgba(160, 200, 175, 0.4); background: rgba(6, 20, 13, 0.8); }
.set-toggle[data-on='1'] .set-on { color: #061810; background: linear-gradient(180deg, #9fffcf, #4ef19a); }
.set-toggle[data-on='0'] .set-off { color: #ffe3e3; background: linear-gradient(180deg, #ff6b6b, #c62828); }
.set-toggle:focus-visible { outline: 1px solid rgba(134, 255, 194, 0.9); outline-offset: 2px; }

/* ---- arrow / value / arrow stepper ---- */
.set-step {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  width: 100%;
  max-width: 300px;
  background: rgba(6, 20, 13, 0.8);
  border: 1px solid rgba(130, 255, 205, 0.25);
  justify-self: end;
}
.set-arrow {
  padding: 8px 12px;
  font-size: 14px;
  color: rgba(159, 255, 207, 0.85);
  background: none;
  border: none;
  cursor: pointer;
  transition: color 0.14s;
}
.set-arrow:hover { color: #eaffe3; }
.set-step-val {
  min-width: 0;
  padding: 8px 4px;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 1px;
  color: #d8ffd0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---- text field and action button ---- */
.set-text {
  width: 100%;
  max-width: 300px;
  padding: 9px 12px;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 1px;
  color: #eaffe3;
  background: rgba(2, 10, 6, 0.85);
  border: 1px solid rgba(130, 255, 205, 0.3);
  border-radius: 0;
  outline: none;
}
.set-text:focus { border-color: rgba(134, 255, 194, 0.85); }
.set-action {
  padding: 9px 20px 8px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 3px;
  color: #061810;
  background: linear-gradient(180deg, #9fffcf, #4ef19a);
  border: none;
  clip-path: polygon(8px 0, 100% 0, calc(100% - 8px) 100%, 0 100%);
  cursor: pointer;
  transition: filter 0.14s, transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.set-action:hover { filter: brightness(1.12); transform: translate(-2px, -2px); }
.set-note {
  padding: 8px 14px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1px;
  color: rgba(159, 255, 207, 0.6);
  min-height: 18px;
}
/* The mic select is still the source of truth for the mic code and the
   stepper drives it. Kept in the DOM, kept out of the way. */
.set-hidden-select { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
#screen-settings #mic-meter { flex: 1 1 auto; max-width: 300px; }

@media (max-width: 700px) {
  .set-row { grid-template-columns: 1fr; gap: 8px; }
  .set-ctl { justify-content: flex-start; }
  .set-toggle, .set-step { justify-self: stretch; max-width: none; }
  #set-tabs { flex-wrap: wrap; }
}


/* ---- what the VISUALS switches actually do ----
   Body classes, so one toggle reaches every screen at once and nothing has
   to be re-rendered to apply it. */
body.sw-no-crt #crt-overlay { display: none; }
body.sw-no-scan #crt-texture { display: none; }
/* Vote effects off: no fire, no snow, no colour shift. The JS also checks
   this before compiling or drawing anything, so it costs nothing when off --
   this rule is what hides the overlay if it is already mid-burn. */
body.sw-no-elem #sw-inferno { display: none !important; }
body.sw-no-elem.sw-burning #screen-listen,
body.sw-no-elem.sw-freezing #screen-listen { filter: none; animation: none; }
/* Camera shake off: the colour and the flames still play, the view holds
   still. Same split reduced-motion uses. */
body.sw-no-shake.sw-burning #screen-listen,
body.sw-no-shake.sw-freezing #screen-listen { animation: none; }

/* ---- the PERFORMANCE switches (SETTINGS > VISUALS) ----
   Each one retires a STANDING whole-viewport cost -- these are the four
   things that make weak integrated-GPU laptops chug, found by profiling,
   and the boot gate's HIGH-END / LOW-END question is nothing but a preset
   over them (setApplyRig in main.js). */
/* Glass Filter off: the full-screen soften backdrop-filter is the single
   most expensive standing effect -- it re-samples the ENTIRE picture every
   time anything under it changes, and the crtv tremble means something
   always is. The glass's gradients/vignette/bezel all stay; only the
   backdrop resample goes. */
body.sw-no-glass #crt-overlay .crt-soften {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* ...and every OTHER backdrop-filter in the app, which is the half this switch
   used to miss. The soften layer was the one that got profiled, so it was the
   one that got gated -- but a scrim is a second full-viewport backdrop resample
   stacked ON TOP of it, and #cr-tie-scrim and #holo-overlay additionally
   TRANSITION/ANIMATE the blur radius, which defeats caching the same way the
   listen screen's halo does.
   Nothing needs a background bump to compensate: every surface here is already
   >=72% opaque, so the blur was texture, not separation -- unlike #wg-chained
   (gated separately, further down) which is 50% and does trade blur for a
   darker plate. Panels are included as well as scrims: they are small, but they
   are drawn OVER the soften layer, and a stack of two backdrop filters is the
   case that actually stalls integrated graphics. */
body.sw-no-glass #sw-modal,
body.sw-no-glass #sd-backdrop,
body.sw-no-glass #holo-overlay,
body.sw-no-glass #holo-panel,
body.sw-no-glass #cr-tie-scrim,
body.sw-no-glass #shop-detail-scrim,
body.sw-no-glass #screen-wager,
body.sw-no-glass #term-frame,
body.sw-no-glass #creator-card {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
}
/* The tie scrim's own transition, which is a blur radius easing over 0.55s --
   the animated form of the cost the rule above just retired. */
body.sw-no-glass #cr-tie-scrim { transition: opacity 0.55s ease-out; }
/* Screen Glitch off: the crtv tremble/skew pair on the picture (the rule
   near the top of this file) plus the screen-change/ambient tear bursts
   (#crt-glitch -- its .cg-slice bands are full-width backdrop-filters).
   The selector list mirrors the crtv rule's exactly, each with the body
   class prefixed so every variant is outweighed -- including the
   lobbylist :has() re-declaration, which at (2,1,1) beats a plain
   body.sw-no-glitch #crt-texture and needs its own :has() match here. */
body.sw-no-glitch .screen,
body.sw-no-glitch #top-bar,
body.sw-no-glitch #voice-bar,
body.sw-no-glitch #home-profile-badge,
body.sw-no-glitch #profile-view,
body.sw-no-glitch #clan-view,
body.sw-no-glitch #crt-texture,
body.sw-no-glitch:has(#screen-lobbylist.active) #crt-texture {
  animation: none;
}
body.sw-no-glitch #crt-glitch { display: none; }
/* Color Cycle off: the CSS half. The JS half (rgbCycleStep) stops the
   10Hz --logo-hue/--ui-hue :root writes -- each one a whole-document
   style recalc -- and parks both at 0deg; this rule stops the store
   shelves' compositor-side drift so the whole app holds base green. */
body.sw-no-hue .store-shelf { animation: none; }
/* Glow Bloom off: the same four house tokens every neon surface reads
   (see THE HOUSE GLOW), re-issued with the wide halo pulled in. Tight
   cores survive, so everything stays lit -- it reads as the same sign
   on a cheaper tube, and every drop-shadow/box-shadow blur gets ~3x
   smaller, which is the point: blur cost scales with radius. */
body.sw-no-bloom {
  --sw-glow-core: 0 0 4px rgba(57, 255, 20, 0.85);
  --sw-glow-bloom: 0 0 10px rgba(57, 255, 20, 0.4);
  --sw-glow-core-hot: 0 0 5px rgba(57, 255, 20, 0.95);
  --sw-glow-bloom-hot: 0 0 12px rgba(57, 255, 20, 0.5);
}
/* Audio Meters off: the audio-reactive halo on the listen screen. NOTE these
   two do NOT read the house glow tokens above -- they hardcode their own
   drop-shadow pair around --bs-glow, which is exactly why Glow Bloom off never
   touched them and why the listen screen stayed expensive on a machine set to
   LOW-END. A blur RADIUS that changes every frame can't be cached the way a
   static one can, so this is a bigger win than its size suggests; the identical
   rule under prefers-reduced-motion below is the same cut for the same reason.
   The meters themselves keep drawing (at half rate, see fit() in main.js) --
   this drops the halo around them, not the readings. */
body.sw-no-viz #bs-viz-wrap,
body.sw-no-viz .bs-face-onair { filter: none; }


/* ---- chat bubbles ----
   Same plate as the emoji bubble, but it has to hold a sentence: wider,
   wrapping, set in readable type, and held for 9s. --bub-life is written by
   bsBubble so one keyframe set serves both lifetimes -- the emoji pops for
   2.3s and a message sits for 9s without needing a second animation. */
.bs-bubble { animation-duration: var(--bub-life, 2300ms); }
.bs-bubble-say {
  min-width: 0;
  max-width: 320px;
  padding: 11px 15px 12px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  line-height: 1.35;
  letter-spacing: 0.5px;
  color: #eaffe3;
  text-align: left;
  /* Someone pasting one enormous word must not stretch the bubble off the
     side of the screen. */
  overflow-wrap: anywhere;
  white-space: normal;
}
/* The hold is most of the 9s: pop in fast, sit, then leave. Percentages
   rather than seconds so the SAME keyframes work for both lifetimes. */
.bs-bubble-say { animation-name: bs-bubble-say; }
@keyframes bs-bubble-say {
  0%   { opacity: 0; scale: 0.4; }
  3%   { opacity: 1; scale: 1.06; }
  6%   { scale: 0.98; }
  8%   { scale: 1; }
  92%  { opacity: 1; scale: 1; translate: -50% -100%; }
  100% { opacity: 0; scale: 0.96; translate: -50% -118%; }
}


/* ---- the deploy glitch field ----
   Stretched edge to edge behind the MATCH DEPLOYING stamp. It renders at
   SW_DEP_SCALE and CSS scales it back up; image-rendering: pixelated keeps
   the upscale CRISP rather than smearing, which suits a pattern made of
   hard-stepped blocks and 3px stripes -- a smooth upscale would sand off
   exactly the detail that makes it read as a glitch. */
#cart-overlay { position: fixed; }
#sw-deploy {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  display: none;
  pointer-events: none;
  image-rendering: pixelated;
}
#sw-deploy.sw-deploy-on { display: block; }
/* The stamp rides above the field. Without this the canvas is a positioned
   element and the in-flow stamp underneath it loses. */
#cart-stamp { position: relative; z-index: 1; }


/* ================= DROP-OFF -> SHOWCASE =================
   A dark room with one statement in it. Under the FILTER glass (700) like
   every other transition layer, over everything else. */
#tox {
  position: fixed;
  inset: 0;
  z-index: 660;
  display: grid;
  place-items: center;
  pointer-events: all;   /* the UI is mid-change; nothing behind is real yet */
  overflow: hidden;
}
#tox.hidden { display: none; }

/* The room. Near-black with a single pool of light in the middle -- the
   thing that makes it feel like a ROOM rather than a black rectangle is
   that the darkness has a centre. */
#tox-room {
  position: absolute;
  inset: 0;
  background:
    repeating-linear-gradient(0deg, transparent 0 3px, rgba(0, 0, 0, 0.35) 3px 4px),
    radial-gradient(ellipse 70% 55% at 50% 50%, rgba(18, 54, 36, 0.85) 0%, rgba(3, 10, 6, 0.97) 55%, #000 100%);
}
#tox.tox-on #tox-room { animation: tox-room 2.9s ease-out both; }
@keyframes tox-room {
  0%   { opacity: 0; }
  6%   { opacity: 1; }
  84%  { opacity: 1; }
  100% { opacity: 0; }
}

#tox-copy {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 0 24px;
  text-align: center;
}
#tox-tag {
  font-family: 'Courier New', monospace;
  font-size: clamp(12px, calc(1 * var(--dvw, 1vw)), 16px);
  letter-spacing: 9px;
  color: rgba(134, 255, 194, 0.55);
}
/* The statement. Monospace ON PURPOSE: the decode swaps characters in
   place, and a proportional face would make the line jitter in width on
   every frame as letters change. */
#tox-main {
  font-family: 'Courier New', monospace;
  font-weight: bold;
  font-size: clamp(30px, calc(5.4 * var(--dvw, 1vw)), 84px);
  letter-spacing: clamp(3px, calc(0.7 * var(--dvw, 1vw)), 12px);
  color: #eaffe3;
  text-shadow: 0 0 22px rgba(80, 255, 180, 0.65), 0 0 70px rgba(60, 255, 170, 0.3);
  white-space: nowrap;
  /* The glow reaches 70px; without room to breathe it gets sliced flat by
     the line box, the same way the showcase title was. */
  overflow: clip;
  overflow-clip-margin: 80px;
}
#tox-sub {
  font-family: 'Courier New', monospace;
  font-size: clamp(12px, calc(1.15 * var(--dvw, 1vw)), 18px);
  letter-spacing: 6px;
  color: rgba(159, 255, 207, 0.8);
  min-height: 1.2em;   /* reserves its line so the main line never shifts */
}
#tox.tox-on #tox-tag { animation: tox-fade 0.5s ease-out 0.1s both; }
#tox.tox-on #tox-main { animation: tox-main 2.9s cubic-bezier(0.16, 1, 0.3, 1) both; }
#tox.tox-on #tox-sub { animation: tox-fade 0.6s ease-out 1.15s both; }
@keyframes tox-fade {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
@keyframes tox-main {
  0%   { opacity: 0; letter-spacing: 26px; filter: blur(6px); }
  10%  { opacity: 1; filter: blur(0); }
  86%  { opacity: 1; }
  100% { opacity: 0; }
}

/* One hard scan bar crossing the room, once. It is what stops the whole
   thing from being a static card with a fade. */
#tox-sweep {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  z-index: 3;
  background: linear-gradient(90deg, transparent, rgba(180, 255, 220, 0.9), transparent);
  box-shadow: 0 0 26px rgba(80, 255, 180, 0.75);
  opacity: 0;
}
#tox.tox-on #tox-sweep { animation: tox-sweep 1.5s cubic-bezier(0.4, 0, 0.2, 1) 0.15s both; }
@keyframes tox-sweep {
  0%   { top: -2%; opacity: 0; }
  8%   { opacity: 1; }
  88%  { opacity: 1; }
  100% { top: 102%; opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  #tox.tox-on #tox-room,
  #tox.tox-on #tox-main,
  #tox.tox-on #tox-tag,
  #tox.tox-on #tox-sub { animation: tox-fade 0.3s linear both; }
  #tox.tox-on #tox-sweep { animation: none; opacity: 0; }
}


/* ================= IDLE COST =================
   The flame behind the vote number only shows at 10, and the snow only at
   1 -- but `opacity: 0` hides an element without stopping ANYTHING. Both
   were running permanently: 18 flame blobs and 14 snowflakes animating
   every frame of every track, plus a blur(13px) contrast(26) on the flame
   core, which is the most expensive filter on the screen and forces its own
   layer to be re-rasterised. That is 32 animations and a two-stage filter
   for two effects that are invisible about 95% of the time.

   Fixed by gating on the classes that already exist (.bs-max at 10,
   .bs-min at 1):

   * animation-play-state: paused -- the browser stops SAMPLING a paused
     animation, so the per-frame work goes to zero. Chosen over display:none
     because the elements keep their opacity transition, so the flame still
     fades up and down instead of popping.
   * filter: none until it is actually needed. This is the big one: a
     filtered element has to be rasterised to its own surface whether or not
     anyone can see it. */
#bs-fire i,
#bs-fire-core i,
#bs-snow i { animation-play-state: paused; }
#bs-rig.bs-max #bs-fire i,
#bs-rig.bs-max #bs-fire-core i { animation-play-state: running; }
#bs-rig.bs-min #bs-snow i { animation-play-state: running; }

#bs-fire-core { filter: none; }
#bs-rig.bs-max #bs-fire-core { filter: blur(13px) contrast(26); }


/* ---- corner controls, during the transition ----
   LEAVE, the volume plate and the dev button are position:fixed and stack
   ABOVE #tox, so they stayed pin-sharp on top of the dark room -- three
   crisp widgets floating over a cinematic beat. Blurred and knocked back
   for its duration so the room is the only thing in focus.
   Still clickable on purpose: blurring is a look, not a lockout, and
   trapping someone who wants out mid-transition would be worse than the
   thing this fixes. */
/* NO `transition` here, deliberately. #bs-vol owns a transition shorthand
   for its WIDTH (the open/close morph), and declaring one in this rule
   replaces it for as long as the class is on -- measured: the width
   transition disappeared during the transition screen. The blur snaps on
   and off instead, which is right for a hard cut to a dark room anyway. */
body.tox-live .screen-leave,
body.tox-live #bs-vol,
body.tox-live #btn-global-leave,
body.tox-live #dev-skip-results {
  filter: blur(7px) saturate(0.6);
  opacity: 0.35;
}
@media (prefers-reduced-motion: reduce) {
  body.tox-live .screen-leave,
  body.tox-live #bs-vol,
  body.tox-live #btn-global-leave,
  body.tox-live #dev-skip-results { transition: none; }
}


/* ---- the mode card ----
   The card now names the mode and states the rule, so the two lines carry
   different weights: the mode is the headline, the rule is the thing you
   are being told. A rule set in 13px caption type reads as a footnote, so
   it comes up and gets a divider above it. */
#tox-sub {
  position: relative;
  margin-top: 8px;
  padding-top: 16px;
  font-size: clamp(14px, calc(1.5 * var(--dvw, 1vw)), 24px);
  letter-spacing: clamp(4px, calc(0.6 * var(--dvw, 1vw)), 9px);
  color: #86ffc2;
  text-shadow: 0 0 14px rgba(80, 255, 180, 0.5);
}
/* A short rule above it, centred -- separates the two lines without a box.
   Sized in em so it tracks the text rather than needing its own clamp. */
#tox-sub::before {
  content: '';
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 5em;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(134, 255, 194, 0.75), transparent);
}


/* =========================================================================
   THE CRATE -- Sample Flip's picking phase
   =========================================================================
   The showcase is a cold arena. This is deliberately a DIFFERENT ROOM: a
   dim late-night record shop, lit by one warm lamp over the shelf. The
   house green still runs the structure (it's the same game), but the
   ambient light and every "live" state are warm amber, and the backdrop is
   a warm black rather than the showcase's green-black. That single palette
   swap is what makes walking in here feel like going somewhere else.

   Amber is reserved for exactly two meanings -- the lamp, and the record
   currently on the deck. Your own pick and the room's tally stay green, so
   "what's playing" and "what's chosen" never read as the same signal. */

/* Full-bleed, like every other in-game screen. Without this the body's
   700px cap squeezed the shelf into two narrow columns, and its
   `padding: 56px 16px 40px` pushed a calc(100 * var(--dvh, 1vh)) screen 96px past the bottom of
   the page -- which is a vertical scrollbar on a screen that must never
   have one. */
body:has(#screen-crate.active) { max-width: none; padding: 0; }

#screen-crate {
  --cr-green: #86ffc2;
  --cr-green-dim: rgba(134, 255, 194, 0.5);
  --cr-amber: #ffc14d;
  --cr-ink: #eaffe3;
  /* Shared by every plate in the room so the extrusion depth is one
     decision rather than six. */
  --cr-deep: 3, 12, 8;
}

#screen-crate.active {
  /* HEIGHT, not min-height -- same reason as #screen-listen: the shelf and
     the deck both need a finite box to divide up, otherwise a tall crate
     grows the page instead of scrolling inside its own column. */
  height: calc(100 * var(--dvh, 1vh));
  max-height: calc(100 * var(--dvh, 1vh));
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 30px;
  overflow: hidden;
  background:
    /* the lamp: one warm pool falling from above the shelf */
    radial-gradient(ellipse 70% 55% at 32% -6%, rgba(255, 176, 64, 0.16) 0%, transparent 62%),
    /* the sign the room is standing in front of -- see #cr-horizon. Sits
       BEHIND the lamp in the stack so the back room still reads as warm and
       the arcade light is what the far wall is doing, not what the crate is
       lit by. */
    radial-gradient(ellipse 58% 34% at 50% 74%, rgba(255, 61, 154, 0.16) 0%, transparent 68%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #100b06 0%, #0a0508 58%, #030201 100%);
}
/* Children stack above the room texture. POSITIONED, so the layers below
   have something to be absolute AGAINST -- without this they resolve against
   the <body>, which is both the wrong box and 104px taller than the window,
   so the page grew a scrollbar behind a screen that is supposed to be
   exactly one viewport tall. */
#screen-crate.active { position: relative; }
#screen-crate.active > * { position: relative; z-index: 1; }
/* position: ABSOLUTE, restated at this specificity on purpose. The rule above
   is `#screen-crate.active > *` at (1,1,0) and beats a bare `#cr-floor` at
   (1,0,0), so the floor and the horizon were being laid out in FLOW as
   relative boxes: the floor collapsed to 0px tall (an empty div with only
   percentage insets) and never drew a single grid line, and the horizon --
   which has an explicit height -- landed inside the padding box and ran
   straight through the deck column instead of spanning the stage behind it.
   One missing word, two bugs that looked unrelated. */
#screen-crate.active > #cr-room,
#screen-crate.active > #cr-floor,
#screen-crate.active > #cr-horizon { position: absolute; z-index: 0; }

/* ---- the floor ----
   The arcade half of the remaster: a grid running away to a horizon at 74%.
   MASKED to nothing well before the bottom edge -- a grid that reaches the
   viewport floor reads as wallpaper, and the whole illusion is that the
   lines are converging somewhere behind the crate.

   The perspective lives HERE and not on #cr-room: that element carries the
   drifting dust, and a rotateX on a shared parent would lay the dust down
   on the floor with the grid. */
#cr-floor {
  position: absolute;
  left: -30%;
  right: -30%;
  top: 74%;
  bottom: -4%;
  pointer-events: none;
  transform: perspective(520px) rotateX(75deg);
  transform-origin: 50% 0%;
  mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.3) 58%, transparent 100%);
  /* no-repeat makes the mask double as a clip: the grid texture below is a
     pseudo that overflows this box by one period, and a repeating mask
     would tile the fade onto that overflow instead of erasing it. */
  mask-repeat: no-repeat;
}
/* THE FLOOR ROLLS, and the way it rolls is the cheap way: the grid texture
   lives on a pseudo INSIDE the tilted element, translated by EXACTLY one
   horizontal-line period (54px -- the loop law: keyframe and gradient tile
   in the same px on the same box, or the loop jumps once a cycle). A plain
   translateY inside the rotated plane travels along the floor, so the lines
   pour out of the horizon toward the room. Transform on a cached texture =
   compositor work, same bill as the dust above (34s loop, shipped years
   fine); animating background-position instead would repaint the whole
   strip every frame, which is the banned version of this effect. The
   element is bled one period top and bottom so the moving sheet never shows
   an edge; the mask (on the parent, which does not move) hides the rest.
   3.6s a cell on purpose -- a crawl, not a racing game. It dies with the
   ambience: Color Cycle off (sw-no-hue, the switch that already owns the
   store shelf drift) and reduced motion both park it, and parked it is
   pixel-identical to the static floor this replaced. */
#cr-floor::before {
  content: '';
  position: absolute;
  inset: -54px 0;
  background:
    repeating-linear-gradient(90deg, transparent 0 92px, rgba(255, 61, 154, 0.5) 92px 94px),
    repeating-linear-gradient(0deg, transparent 0 52px, rgba(134, 255, 194, 0.34) 52px 54px);
  animation: cr-floor-run 3.6s linear infinite;
}
@keyframes cr-floor-run {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, 54px, 0); }
}
body.sw-no-hue #cr-floor::before { animation: none; }
/* The sign itself. Three shadows, not two: bloom is roughly exponential and
   two radii can only draw a hot edge plus a halo with a seam between them --
   the same rule the frames' throw follows. */
#cr-horizon {
  position: absolute;
  left: 0;
  right: 0;
  top: 74%;
  height: 2px;
  pointer-events: none;
  background: #ff3d9a;
  box-shadow:
    0 0 4px rgba(255, 61, 154, 0.95),
    0 0 16px rgba(255, 61, 154, 0.55),
    0 0 60px rgba(255, 61, 154, 0.3);
}
/* Glow Bloom off: the sign keeps its line and loses its air. It is one
   element, so this costs the switch nothing and keeps the horizon legible. */
body.sw-no-bloom #cr-horizon { box-shadow: 0 0 4px rgba(255, 61, 154, 0.9); }

/* ==========================================================================
   SAMPLE FLIP: FILLING THE CRATE (#screen-sdrop)
   The room BEFORE the crate, and it says so by wearing the same clothes --
   the same lamp, the same three-column header rhythm, the same tokens.
   Redesigned 2026-08 as ONE CENTERED COLUMN under the lamp: pad, manifest,
   status, host control all stacked and vertically centered -- the old
   two-column stage left the landed list's half empty and the controls
   floating in a void. The lamp moved to centre with it. */
#screen-sdrop {
  --cr-green: #86ffc2;
  --cr-green-dim: rgba(134, 255, 194, 0.5);
  --cr-amber: #ffc14d;
  --cr-ink: #eaffe3;
}
body:has(#screen-sdrop.active) { max-width: none; padding: 0; }
#screen-sdrop.active {
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  gap: 26px;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 40px;
  background:
    /* Static shelf grain, the crate room's texture without its dust loop --
       this screen has no #cr-room element and doesn't need one animating. */
    repeating-linear-gradient(90deg, transparent 0 118px, rgba(255, 190, 90, 0.035) 118px 120px),
    repeating-linear-gradient(0deg, transparent 0 60px, rgba(134, 255, 194, 0.018) 60px 61px),
    radial-gradient(ellipse 70% 55% at 50% -6%, rgba(255, 176, 64, 0.16) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #100b06 0%, #070502 58%, #030201 100%);
}
#sd-top {
  display: grid;
  grid-template-columns: minmax(280px, 22%) 1fr minmax(240px, 20%);
  align-items: start;
  gap: 30px;
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
  flex: none;
}
#sd-left { display: flex; flex-direction: column; gap: 2px; }
#sd-mode {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  color: var(--cr-green);
  text-shadow: 0 0 14px rgba(80, 255, 180, 0.55);
  white-space: nowrap;
}
#sd-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(48px, calc(6 * var(--dvw, 1vw)), 96px);
  font-weight: bold;
  line-height: 1;
  color: var(--cr-ink);
  text-shadow: 0 0 26px rgba(255, 200, 120, 0.55);
}
#sd-head { text-align: center; min-width: 0; padding-top: 6px; }
#sd-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(34px, calc(4.4 * var(--dvw, 1vw)), 72px);
  line-height: 1;
  letter-spacing: 6px;
  color: var(--cr-ink);
  text-shadow: 0 0 24px rgba(255, 190, 100, 0.45);
}
#sd-count { justify-self: end; text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
#sd-count-n {
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, calc(3.4 * var(--dvw, 1vw)), 52px);
  line-height: 1;
  color: var(--cr-ink);
  text-shadow: 0 0 18px rgba(120, 255, 180, 0.5);
}
/* ONE column under the lamp. `margin: auto 0` splits the screen's free
   space above and below it, so the whole assembly floats vertically
   centered instead of leaving a dead tail under a top-left pad. */
#sd-stage {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 14px;
  width: min(620px, 100%);
  align-self: center;
  margin: auto 0;
}
/* The pad. An intake bay: solid glass plate, HUD corner brackets, a dashed
   landing strip inset inside it. Same gesture as the production drop zone,
   a third of the ceremony. */
#sd-pad {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  min-height: clamp(200px, 30vh, 300px);
  padding: 34px 30px;
  border: 1px solid rgba(134, 255, 194, 0.28);
  outline: 1px dashed rgba(134, 255, 194, 0.22);
  outline-offset: -12px;
  background: linear-gradient(180deg, rgba(13, 28, 19, 0.78) 0%, rgba(6, 13, 9, 0.88) 100%);
  box-shadow: inset 0 0 46px rgba(0, 0, 0, 0.55);
  cursor: pointer;
  font-family: 'Courier New', monospace;
  letter-spacing: 4px;
  color: var(--cr-green-dim);
  transition: border-color 0.15s, box-shadow 0.15s, outline-color 0.15s;
}
/* Corner brackets -- two opposed L's, the angular-HUD echo without cutting
   the plate itself. */
#sd-pad::before, #sd-pad::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  border: 2px solid var(--cr-green);
  pointer-events: none;
  opacity: 0.75;
  transition: opacity 0.15s;
}
#sd-pad::before { top: -2px; left: -2px; border-right: 0; border-bottom: 0; }
#sd-pad::after { bottom: -2px; right: -2px; border-left: 0; border-top: 0; }
#sd-pad:hover, #sd-pad:focus-visible, #sd-pad.dz-hot {
  border-color: var(--cr-green);
  outline-color: rgba(134, 255, 194, 0.5);
  box-shadow: inset 0 0 46px rgba(0, 0, 0, 0.55), inset 0 0 70px rgba(134, 255, 194, 0.09);
}
#sd-pad:hover::before, #sd-pad:hover::after,
#sd-pad.dz-hot::before, #sd-pad.dz-hot::after { opacity: 1; }
#sd-pad:focus-visible { outline-style: solid; }
#sd-pad.sd-full { cursor: default; outline: none; }
#sd-pad-glyph {
  font-size: 30px;
  color: var(--cr-green);
  text-shadow: 0 0 16px rgba(134, 255, 194, 0.55);
}
#sd-pad-main {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, 2.4vw, 32px);
  color: var(--cr-ink);
  text-shadow: 0 0 18px rgba(255, 190, 100, 0.3);
}
#sd-pad-sub { font-size: 12px; letter-spacing: 3px; }
#sd-pad.sd-full #sd-pad-glyph, #sd-pad.sd-full #sd-pad-sub { display: none; }
/* A COLUMN of staged files now, not one: the host stages a batch (up to
   their six slots) and each row keeps its own X. Full-width slot chips. */
#sd-file { display: flex; flex-direction: column; align-self: stretch; gap: 6px; }
.sd-file-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 12px;
  background: rgba(134, 255, 194, 0.06);
  border: 1px solid rgba(134, 255, 194, 0.2);
}
.sd-file-name {
  color: var(--cr-ink);
  font-size: 14px;
  letter-spacing: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sd-file-x {
  flex: none;
  background: none;
  border: 1px solid rgba(255, 193, 77, 0.5);
  color: var(--cr-amber);
  padding: 2px 8px;
  cursor: pointer;
  font-family: 'Courier New', monospace;
  transition: border-color 0.15s, color 0.15s;
}
.sd-file-x:hover { border-color: var(--cr-amber); color: #ffe0a8; }
/* The server's refusal, hanging off the row it refused. Amber, not red:
   the file is still staged and fixable, not lost. */
.sd-file-why {
  margin: -1px 0 4px;
  padding: 4px 12px 6px;
  border: 1px solid rgba(255, 193, 77, 0.35);
  border-top: none;
  background: rgba(255, 193, 77, 0.05);
  color: var(--cr-amber);
  font-size: 12px;
  letter-spacing: 1px;
}
/* Ghost plates that fill on hover -- reads as controls, not stickers. */
#btn-send-sample, #btn-close-crate {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 5px;
  padding: 13px 34px;
  background: rgba(134, 255, 194, 0.1);
  border: 1px solid var(--cr-green);
  color: var(--cr-green);
  font-weight: bold;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
#btn-send-sample:hover:not(:disabled), #btn-close-crate:hover:not(:disabled) {
  background: rgba(134, 255, 194, 0.9);
  color: #06231a;
  box-shadow: 0 0 22px rgba(134, 255, 194, 0.3);
}
#btn-send-sample:disabled, #btn-close-crate:disabled {
  background: transparent;
  border-color: rgba(134, 255, 194, 0.25);
  color: rgba(234, 255, 227, 0.35);
  cursor: not-allowed;
  box-shadow: none;
}
/* The manifest: what has landed so far, numbered like a tracklist.
   Hidden entirely while empty so the column stays tight. */
#sd-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  counter-reset: sdrow;
}
#sd-list:empty { display: none; }
.sd-row {
  counter-increment: sdrow;
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 10px 14px;
  border: 1px solid rgba(134, 255, 194, 0.12);
  border-left: 2px solid var(--cr-green);
  background: rgba(8, 20, 14, 0.55);
  font-family: 'Courier New', monospace;
  letter-spacing: 2px;
  color: var(--cr-ink);
}
.sd-row::before {
  content: counter(sdrow, decimal-leading-zero);
  flex: none;
  font-size: 12px;
  color: var(--cr-green-dim);
}
.sd-row b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-row i { font-style: normal; font-size: 13px; color: var(--cr-green-dim); }
.sd-row-waiting {
  counter-increment: none;
  border-left-color: var(--cr-amber);
  color: rgba(255, 193, 77, 0.8);
  font-size: 13px;
}
.sd-row-waiting::before { content: '//'; color: rgba(255, 193, 77, 0.6); }
#sd-status {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 3px;
  text-align: center;
  color: rgba(240, 255, 240, 0.7);
  min-height: 18px;
}
#sd-hostbar { display: flex; justify-content: center; margin-top: 4px; }

/* ---- the send curtain ----
   Covers the whole drop-off while the batch uploads and the server flips the
   room to the vote. Fixed, not absolute: it must blanket the viewport, and
   it only ever exists while #screen-sdrop is the active screen. */
#sd-loading {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(2, 8, 4, 0.88);
  backdrop-filter: blur(2px);
}
.sd-loading-box {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}
/* The upload bar. Width comes from real XHR byte counts (sdSend), so the
   only transition is a short linear ease between updates -- an eased fill
   on a progress bar reads as lying about the number it was just told. */
#sd-loading-bar {
  width: min(420px, 64vw);
  height: 7px;
  border: 1px solid rgba(134, 255, 194, 0.3);
  background: rgba(10, 24, 16, 0.85);
  padding: 1px;
}
#sd-loading-bar.hidden { display: none; }
#sd-loading-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, rgba(134, 255, 194, 0.55), var(--cr-green, #86ffc2));
  box-shadow: 0 0 10px rgba(134, 255, 194, 0.45);
  transition: width 0.15s linear;
}
#sd-loading-text {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 0.35em;
  color: var(--cr-ink, #eaffe3);
  text-shadow: 0 0 12px rgba(134, 255, 194, 0.5);
  animation: sd-loading-pulse 1.4s ease-in-out infinite;
}
@keyframes sd-loading-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}

/* ---- the room ----
   Shelf grain + a slow drift of dust through the lamp beam. A real element,
   not a ::before: pseudo-element animation support is patchy and a no-op
   snaps the texture on at full instead of letting it fade in. */
#cr-room {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(90deg, transparent 0 118px, rgba(255, 190, 90, 0.045) 118px 120px),
    repeating-linear-gradient(0deg, transparent 0 60px, rgba(134, 255, 194, 0.022) 60px 61px);
  /* Vignette pulls the corners down so the lamp reads as the only source. */
  mask-image: radial-gradient(ellipse 85% 75% at 38% 10%, #000 0%, rgba(0, 0, 0, 0.35) 70%, transparent 100%);
}
#cr-room::after {
  content: '';
  position: absolute;
  inset: -20%;
  background:
    radial-gradient(circle at 18% 30%, rgba(255, 214, 150, 0.5) 0 1px, transparent 2px),
    radial-gradient(circle at 63% 12%, rgba(255, 214, 150, 0.4) 0 1px, transparent 2px),
    radial-gradient(circle at 41% 71%, rgba(255, 214, 150, 0.35) 0 1px, transparent 2px),
    radial-gradient(circle at 84% 48%, rgba(255, 214, 150, 0.3) 0 1px, transparent 2px),
    radial-gradient(circle at 8% 82%, rgba(255, 214, 150, 0.28) 0 1px, transparent 2px);
  background-size: 340px 300px;
  animation: cr-dust 34s linear infinite;
  opacity: 0.5;
}
@keyframes cr-dust {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-340px, 300px, 0); }
}

/* ---- top strip ----
   Same 3-column rhythm as the showcase's header so the two screens feel
   built by the same hand: [ mode + clock | headline | count ]. */
#cr-top {
  display: grid;
  grid-template-columns: minmax(280px, 22%) 1fr minmax(240px, 20%);
  align-items: start;
  gap: 30px;
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
  flex: none;
}
#cr-left {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-left: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  min-width: 0;
}
/* #cr-mode is gone with the mode label it styled -- the screen no longer
   names the gamemode it is in. .cr-kata stays: the drop-off screen and the
   standings board still wear it. */
.cr-kata {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  color: rgba(255, 208, 140, 0.5);
  letter-spacing: 5px;
}
/* THE LAST TEN SECONDS. The clock is the one number on this screen that
   changes what you should be doing, and for 50 of its 60 seconds it says so
   in exactly the same voice. Under ten it takes the room amber and breathes.
   Colour AND motion, because either alone is a thing somebody can miss. */
#cr-timer.is-low {
  color: var(--cr-amber);
  text-shadow: 0 0 22px rgba(255, 193, 77, 0.75), 0 0 70px rgba(255, 150, 40, 0.35);
  animation: cr-timer-low 1s ease-in-out infinite;
}
@keyframes cr-timer-low {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.045); }
}
#cr-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(58px, calc(7.4 * var(--dvw, 1vw)), 118px);
  font-weight: bold;
  line-height: 1;
  white-space: nowrap;
  color: var(--cr-ink);
  text-shadow: 0 0 26px rgba(255, 200, 120, 0.55), 0 0 80px rgba(255, 170, 60, 0.22);
}
#cr-head { text-align: center; min-width: 0; padding-top: 6px; }
#cr-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(38px, calc(5.2 * var(--dvw, 1vw)), 84px);
  line-height: 1;
  letter-spacing: 6px;
  color: var(--cr-ink);
  /* `clip`, not `hidden` -- hidden guillotines the glow in a flat rectangle
     right at the text edge. clip is the one value in that family that takes
     an overflow-clip-margin, so the bloom gets room to leave the box. */
  overflow: clip;
  overflow-clip-margin: 72px;
  text-shadow:
    0 0 18px rgba(255, 205, 130, 0.6),
    0 0 60px rgba(255, 170, 60, 0.28);
}
/* ---- the pick counter ---- */
#cr-count {
  justify-self: end;
  text-align: right;
  padding-right: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}
.cr-count-k { font-size: 13px; letter-spacing: 5px; color: var(--cr-green-dim); }
#cr-count-n {
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, calc(3.4 * var(--dvw, 1vw)), 52px);
  line-height: 1;
  color: var(--cr-ink);
  text-shadow: 0 0 18px rgba(120, 255, 180, 0.5);
}
#cr-count-n i { font-style: normal; opacity: 0.35; margin: 0 4px; }
#cr-count-n span { opacity: 0.55; }
#cr-count-pips { display: flex; gap: 5px; }
#cr-count-pips i {
  width: 15px;
  height: 5px;
  background: rgba(134, 255, 194, 0.16);
  transition: background 0.3s ease-out, box-shadow 0.3s ease-out;
}
#cr-count-pips i.cr-pip-on {
  background: var(--cr-green);
  box-shadow: 0 0 9px rgba(134, 255, 194, 0.8);
}

/* ---- the stage: shelf on the left, deck on the right ---- */
#cr-stage {
  /* Positioned so #cr-tie's `inset: 0` and the wheel hub's coordinates are
     measured against the STAGE. crTieStart computes the ring centre from
     this element's box, so the two have to agree on what the origin is. */
  position: relative;
  flex: 1;
  min-height: 0;          /* lets the wheel own a finite box */
  /* DECK on the left, WHEEL on the right, with the commit bar tucked under
     the wheel. The deck column is capped rather than fluid: it holds one
     plate at a readable size and a short board, and letting it grow on an
     ultrawide just pushes the wheel off the side it turns toward. */
  display: grid;
  grid-template-columns: minmax(320px, 30%) 1fr;
  /* minmax(0, 1fr), NOT 1fr. A grid track sized 1fr still floors at its
     items' auto min-size, so the deck column -- whose natural height is a
     plate plus a board -- grew the row past the stage's own box and shoved
     the wheel off the bottom of a 720px window. Every child below repeats
     min-height: 0 for the same reason one level down. */
  grid-template-rows: minmax(0, 1fr) auto;
  align-items: start;
  gap: clamp(18px, calc(2.4 * var(--dvw, 1vw)), 54px);
  margin-top: clamp(14px, calc(2 * var(--dvh, 1vh)), 30px);
}
#cr-deck {
  grid-row: 1 / span 2;
  align-self: stretch;
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
  min-height: 0;
}
/* The plate is the fixed part of the column and the board is the elastic one
   -- when the room runs out of height it is the standings that give, never
   the transport. */
#cr-deck-plate { flex: none; }

/* ==========================================================================
   THE LOCKED VIEW (#screen-crate.is-locked)
   Locking in is not a highlight, it is a CHANGE OF SUBJECT: the wheel
   dismisses itself, the sleeve flattens into a bar low across the stage,
   and the maker's panel re-centres above it as the thing you are looking at.

   NOTHING REFLOWS. The first build changed the stage's grid for this, and a
   grid change is a teleport -- the shelf's box jumped, so even transitioned
   seat offsets snapped because their containing block had moved, and the
   whole thing read as a cut with a fade over it. Now the grid never changes:
   the shelf keeps its column and the BAR reaches across the stage through
   its own seat variables (crSeatShelf hands it negative insets), so the
   plate travels on the same transitions every turn of the wheel uses. The
   deck is the one element that genuinely moves house, and it goes ABSOLUTE
   (the stage is its positioning box) so its move is a transform, not a
   reflow.
   ========================================================================== */
/* The deck TRAVELS BY RELATIVE OFFSET, SCALES BY TRANSFORM. Two bugs paid
   for this shape, one each way. (1) It must stay IN FLOW: the first locked
   view went position: absolute, which emptied its grid column -- and an
   empty minmax(320px, 30%) track collapses to its 320px minimum, so the
   SHELF'S BOX jumped left and every plate's seat jumped with it. Relative
   left/top move the panel without touching layout, so nothing else on the
   stage can move because of it. (2) The travel must NOT be a transform:
   translate() was tried and on real glass (owner's recording, window at
   Windows 150% scaling) the composited transition ran the translate WITHOUT
   the root zoom -- the deck flew 1/zoom (~1.5x) past its seat, parked
   there, and snapped back the instant the 0.45s transition ended. Nothing
   scripted could see it: every DOM read, screenshot and screencast showed
   the correct maths, because the wrongness existed only at display-composite
   time (the checkerboarding lesson's shape). Layout offsets animate on the
   main thread where zoom is applied correctly -- the bar's seats already
   travel that way and land true in the same recording. scale() stays a
   transform because it is dimensionless: there is no px in it for a zoomed
   compositor to mis-scale. left/top 0 here so the lock has a real value to
   interpolate FROM (auto would jump). */
#cr-deck {
  position: relative;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  transition: left 0.45s cubic-bezier(0.16, 1, 0.3, 1),
              top 0.45s cubic-bezier(0.16, 1, 0.3, 1),
              transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
/* align-self: start, because the deck normally STRETCHES to the stage's full
   height -- and crSeatShelf places the bar under the deck's measured box, so
   a stretched box put "under the deck" inside the deck. Hugging content
   makes offsetHeight mean what the maths thinks it means. Visually free:
   the deck paints no chrome of its own and its children already sat at the
   top of the stretched box. */
#screen-crate.is-locked #cr-deck { position: relative; z-index: 5; align-self: start; }
/* The artwork gives up its square: height is the scarce thing here, and the
   credit stays the loudest line on the panel. */
#screen-crate.is-locked #cr-deck-art { aspect-ratio: auto; height: clamp(90px, calc(15 * var(--dvh, 1vh)), 190px); }
/* Capped so the deck can NEVER reach the bar: the bar sits at a fixed shelf
   fraction now (the double-settle fix), so the tally's growth has to be
   bounded instead of the bar dodging it. Scrolls past the cap. */
#screen-crate.is-locked #cr-tally { max-height: 132px; }
/* THE HEADLINE GOES WITH THE WHEEL (owner call 2026-08-22). CHOOSE YOUR
   SAMPLE is an instruction, and in the locked view the choosing is done --
   an instruction still glowing over a finished decision reads as the screen
   not noticing. The clock and the PICKED counter stay: those are live facts
   either way. */
#screen-crate.is-locked #cr-head {
  opacity: 0;
  transition: opacity 0.35s ease-out;
}
/* ONE badge. The deck already says YOUR PICK over the artwork; the bar
   carrying a second copy plus its vote strip plus CHANGE made its corner a
   pile of small loud things. The bar's job is the record; the deck's job is
   the claim. */
#screen-crate.is-locked .cr-slab-mine::after { display: none; }
/* The bar is the record you pulled: it stops being clickable as a seat. */
#screen-crate.is-locked .cr-slab { cursor: default; }
#screen-crate.is-locked .cr-slab:not(.cr-slab-live) { pointer-events: none; }

/* ---- THE WHEEL ----
   The rack was a grid of equal cards; it is a rolodex now. THE PLATE YOU ARE
   HEARING HOLDS THE CENTRE LINE AND NEVER MOVES -- choosing another sleeve
   turns the whole crate through it. Seats are CYCLIC (see crSeatShelf), so a
   crate of three and a crate of nine both fill the wheel and nothing is ever
   off the end of a list: the requirement that a players crate is shown WHOLE
   survives a control that only reads one plate at a time.

   NO SCROLLING. The wheel is the scroll -- an overflow container round a set
   of perspective-transformed children would clip them at the box and give
   two competing ways to move the same list. */
#cr-shelf {
  list-style: none;
  margin: 0;
  padding: 0;
  position: relative;
  min-height: 0;
  align-self: stretch;
  /* CAPPED, and CENTRED in its column rather than pinned to the right edge.
     Pinned, the wheel hugged the side of the screen -- the centred plate's
     breakout ran past the edge and got clipped, while a dead gap sat between
     it and the deck. Centred, the breakout has somewhere to go and the wheel
     reads as the middle of the room instead of something stuck to the wall.
     The right padding is the breakout's room: absolutely positioned children
     resolve against the PADDING box, so this insets every plate rather than
     just adding air beside them. */
  /* AS FAR RIGHT AS IT GOES WITHOUT TOUCHING THE GLASS. Pinned to the end of
     its column, with just enough padding for the pick's breakout to live in
     -- absolutely positioned children resolve against the PADDING box, so
     this is the plates' own right margin rather than air beside them. */
  width: 100%;
  max-width: clamp(560px, calc(54 * var(--dvw, 1vw)), 1720px);
  justify-self: end;
  /* MARGIN, not padding. An absolutely positioned child resolves against the
     PADDING EDGE -- the outer edge of the padding -- so padding-right shrinks
     the content box and moves the plates not at all; measured, 34px and 52px
     of padding both left the same 3px gap. Margin moves the whole box.
     Sized for the PICK's overhang rather than for taste: the centred plate is
     150px toward the reader, so perspective magnifies it ~5% past its own
     layout box on each side, and that lands on top of its -20px breakout. */
  margin-right: 26px;
  box-sizing: border-box;
  /* The vanishing point sits off to the RIGHT of the plates and slightly
     above centre, so the wheel turns away toward the corner of the room
     rather than rotating about the middle of the screen. */
  perspective: 1500px;
  /* Pulled in from 84%: with the plates hinged on their right edge, a
     far-right vanishing point compounded the lean. Now that they scale about
     their own middle it only needs enough bias to keep the turn reading as a
     turn -- past that it just drags the whole stack sideways. */
  perspective-origin: 60% 46%;
  transform-style: preserve-3d;
  outline: none;
}
/* Keyboard focus lands on the wheel, not on a plate -- the plates are seats,
   and tabbing between six of them to reach the one already under the centre
   line is not navigation. Up/Down turn it (see crWireWheel). */
#cr-shelf:focus-visible { outline: 1px solid rgba(134, 255, 194, 0.45); outline-offset: 8px; }

/* A sleeve standing in the rack. Angular plate, spine number down the left,
   vote bar along the bottom edge. */
/* A sleeve in its SEAT on the wheel. Every geometric fact about a plate is a
   custom property written by crSeatShelf -- position, stagger, depth, fade --
   and never a plain inline `top`/`left`/`transform`. That is not tidiness:
   the tie roll pins cards inline (position/left/top/width/height) to swing
   them into its ring and strips those properties again on teardown, so a
   wheel that expressed itself the same way would be wiped by a roll and come
   back stacked at the origin. Custom props survive that untouched, and a
   re-seat after the roll costs one pass. */
.cr-slab {
  position: absolute;
  left: var(--sl, 0);
  right: var(--sr, 0);
  top: var(--sy, 0);
  height: var(--sh, 92px);
  z-index: var(--sz, 1);
  opacity: var(--so, 1);
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 0 20px 0 10px;
  cursor: pointer;
  background:
    linear-gradient(120deg, rgba(20, 30, 22, 0.94) 0%, rgba(9, 15, 11, 0.97) 100%);
  border: 1px solid rgba(134, 255, 194, 0.2);
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
  /* The plate is turned away from the reader and pushed back by however many
     seats it sits from the centre line -- the whole depth effect, in one
     property the compositor can carry. transform-origin is the RIGHT edge so
     the wheel hinges on the side it vanishes toward. */
  /* THE PLATES SHARE A CENTRE AXIS. This was 100% 50% -- hinged on the right
     edge -- which meant every scale happened leftward only: the centred
     plate's perspective magnification grew it entirely to the left, and the
     receding plates lost their width entirely from the left. Measured, the
     right edges clustered within 56px while the left edges fanned across
     350px, and the pick's centre sat 130px off the stack's. The pick read as
     escaping sideways instead of coming forward, and the whole wheel looked
     pinned to the right of its own box.
     Centred, growth and shrink are symmetrical about each plate's middle, so
     the stack and the pick line up on one axis and coming forward reads as
     coming FORWARD. */
  transform-origin: 50% 50%;
  transform: var(--t, none);
  /* Staggered arrival, so the crate fills in like records being set down
     rather than all six appearing at once. */
  animation: cr-slab-in 0.42s cubic-bezier(0.16, 1, 0.3, 1) backwards;
  animation-delay: calc(var(--slab-i, 0) * 60ms);
  /* top/left/right are animated as well as the transform: turning the wheel
     moves every plate to a NEW SEAT, and a seat is a position as much as an
     angle. Without these the plates would teleport up the column and only
     their rotation would ease. */
  transition:
    top 0.42s cubic-bezier(0.22, 1, 0.36, 1),
    left 0.42s cubic-bezier(0.22, 1, 0.36, 1),
    right 0.42s cubic-bezier(0.22, 1, 0.36, 1),
    transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),
    opacity 0.3s ease-out,
    border-color 0.2s ease-out,
    filter 0.26s cubic-bezier(0.34, 1.56, 0.64, 1),
    background 0.24s ease-out;
}
@keyframes cr-slab-in {
  from { opacity: 0; transform: translateY(14px) scale(0.97); }
  to   { opacity: 1; transform: none; }
}
/* The house hover: springy pop toward the viewer with the depth faked as a
   stack of hard shadows. drop-shadow on the element, not box-shadow -- the
   clip-path would clip a box-shadow away entirely.
   TRANSLATE ONLY, no scale -- a % scale's travel grows with the slab, and
   these slabs are 1fr rows: a small crate on a big screen makes them
   ~550px tall, so scale(1.035) lifted the top edge ~15px into the shelf's
   12px of padding and the scroll container GUILLOTINED the top border off
   every top-row sleeve for as long as it was hovered (caught on video).
   A fixed 6px of travel fits the padding at every slab size forever; the
   shadow stack and the border ignition carry the depth. */
/* Hover ADDS travel to the seat rather than replacing it -- the seat is the
   plate's whole position now, so the old `transform: translate(...)` would
   have thrown every hovered plate back to the origin, unturned. Toward the
   reader in Z, which on a wheel is the honest reading of "this one". */
.cr-slab:hover,
.cr-slab:focus-visible {
  transform: var(--t, none) translateZ(44px);
  border-color: rgba(134, 255, 194, 0.85);
  outline: none;
}
.cr-slab:active { transition-duration: 0.08s; transform: var(--t, none) translateZ(18px); }
/* THE CENTRED PLATE DOES NOT MOVE, for hover or for press. It is already on
   the line and already 150px toward the reader -- a lift on top of that is
   movement with nothing left to say, and it actively fought the transport:
   moving onto the play button LIFTED the sleeve and pressing it DROPPED the
   sleeve, so operating the controls made the thing under them jump twice.
   The dim plates keep both, because there a lift genuinely means "pressing
   me selects me". Declared after the two rules above so it wins at equal
   specificity. */
.cr-slab-live:hover,
.cr-slab-live:focus-visible,
.cr-slab-live:active {
  transform: var(--t, none);
  transition-duration: 0.42s;
}

.cr-slab-n {
  align-self: stretch;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 1px;
  color: rgba(134, 255, 194, 0.45);
  border-right: 1px solid rgba(134, 255, 194, 0.14);
  transition: color 0.2s ease-out;
}
/* The identity block does NOT grow any more -- the waveform does. A name and
   a handle stretched across a wide plate just pushed the two facts apart. */
/* THE PLATE STACKS (owner call 2026-08-21). It used to be one flex ROW --
   number, name, waveform, BPM/KEY, tally -- and on a plate that is wide and
   only ~120px tall the title was competing with the waveform for the same
   horizontal run and losing it: `beauty and the beast` came out as `b...`
   because the wave was flex-grow: 1 and the name was not.

   Wide and short wants TWO BANDS, not five columns: the title and its
   numbers get the whole width across the top, the shape runs underneath.
   Nothing is fighting anything for room any more. */
.cr-slab-main {
  flex: 1 1 auto;
  min-width: 0;
  height: 100%;
  padding: 12px 0;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 7px;
}
.cr-slab-top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  min-width: 0;
}
/* Takes the row, and is the thing allowed to grow -- the title is the
   identity of the sleeve and everything beside it is a fixed-width fact. */
.cr-slab-body { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 3px; }

/* THE SLEEVE'S SHAPE, on the sleeve. Every plate carries its own waveform
   again (owner call): the middle of a wide plate was dead space, and the
   shape is the one thing that tells two samples apart at a glance before you
   have heard either. Cheap -- a few dozen rects per canvas, redrawn only on
   build, on a turn and on resize. The DECK's rail keeps the scrubbing; this
   one is identity, not transport, so it takes no pointer events. */
/* The bottom band: transport on the left, shape filling the rest. The button
   sits INSIDE the band rather than out at the plate's edge so it reads as
   belonging to the waveform it drives. */
.cr-slab-deck {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 20px;
  display: flex;
  align-items: center;
  gap: 14px;
}
.cr-slab-play {
  flex: none;
  width: 34px;
  height: 34px;
  padding: 0;
  font-size: 11px;
  line-height: 1;
  color: rgba(28, 14, 0, 0.75);
  background: rgba(28, 14, 0, 0.1);
  border: 1px solid rgba(28, 14, 0, 0.4);
  cursor: pointer;
  /* Inert on a plate that is not on the deck -- the click falls through to
     the wheel, which centres that sleeve and starts it. Pressing play on a
     turned-away plate meaning "play this one" is the right outcome, and it
     costs no special case. */
  pointer-events: none;
  transition: background 0.18s ease-out, border-color 0.18s ease-out;
}
.cr-slab-live .cr-slab-play { pointer-events: auto; }
.cr-slab-live .cr-slab-play:hover { background: rgba(28, 14, 0, 0.2); border-color: rgba(28, 14, 0, 0.7); }
/* Off the lit plate the ink inverts back to the room's green card stock. */
.cr-slab:not(.cr-slab-live) .cr-slab-play {
  color: rgba(134, 255, 194, 0.5);
  background: rgba(134, 255, 194, 0.06);
  border-color: rgba(134, 255, 194, 0.28);
}
/* THE COMMIT, ON THE THING IT COMMITS. Only the centred plate carries a live
   one -- on any other sleeve there is nothing to pull, and six PULL IT
   buttons would be six ways to ask the same question. The dim plates keep the
   SPACE (visibility, not display) so the waveform does not change width as
   the wheel turns. */
.cr-slab-pull {
  flex: none;
  align-self: center;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 19px;
  letter-spacing: 3px;
  white-space: nowrap;
  color: #06210f;
  background: linear-gradient(180deg, #b6ffd8 0%, #86ffc2 100%);
  border: 0;
  padding: 9px 22px 10px;
  cursor: pointer;
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
  visibility: hidden;
  pointer-events: none;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.18s ease-out;
}
.cr-slab-live .cr-slab-pull { visibility: visible; pointer-events: auto; }
.cr-slab-live .cr-slab-pull:hover { transform: translate(-2px, -2px); filter: brightness(1.08); }
.cr-slab-live .cr-slab-pull:active { transform: none; transition-duration: 0.06s; }
/* Already yours: it stops advertising an action that would be a no-op, but
   still says what it is rather than vanishing out from under the cursor. */
.cr-slab-mine .cr-slab-pull {
  background: none;
  box-shadow: inset 0 0 0 1px rgba(6, 33, 15, 0.5);
  cursor: default;
}
.cr-slab-mine .cr-slab-pull:hover { transform: none; filter: none; }

.cr-slab-wave-wrap {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 20px;
  align-self: stretch;
  /* Inert on the plates you are not listening to: there is nothing to scrub
     on a sleeve that is not on the deck, and a wave that swallowed the click
     would stop the plate being a seat. */
  pointer-events: none;
}
/* THE CENTRED SLEEVE'S WAVE IS THE SCRUBBER. The shape is already the map of
   the sound, so there is no reason to aim at a separate rail -- you click the
   part you want to hear. Only the lit plate takes the pointer. */
.cr-slab-live .cr-slab-wave-wrap {
  pointer-events: auto;
  cursor: pointer;
  touch-action: none;   /* pointer-capture drag, not a page scroll */
}
.cr-slab-wave {
  display: block;
  width: 100%;
  height: 100%;
  opacity: 0.55;
  transition: opacity 0.25s ease-out;
}
.cr-slab:hover .cr-slab-wave { opacity: 0.8; }
.cr-slab-live .cr-slab-wave { opacity: 1; }
.cr-slab-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(17px, calc(1.5 * var(--dvw, 1vw)), 23px);
  line-height: 1.1;
  color: var(--cr-ink);
  max-width: 100%;
  /* clip + margin, not hidden: see #cr-title. The name lights up on hover
     and hidden would slice the glow off square. The padding/negative-margin
     pair is the working half on real glass: the clip edge sits at the
     PADDING box, and the clip-margin is not reliably honoured once
     text-overflow is in play -- the glow sheared flat right at the glyph
     tops (reported 2026-08-20). Layout unchanged; the clip edge moves out. */
  overflow: clip;
  overflow-clip-margin: 28px;
  padding: 0.4em 0.3em;
  margin: -0.4em -0.3em;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-shadow: 0 0 12px rgba(160, 255, 200, 0.28);
}
/* The credit. Not a footnote -- this is why the mode exists. */
.cr-slab-cred {
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 2px;
  color: var(--cr-green);
  max-width: 100%;
  overflow: clip;
  overflow-clip-margin: 20px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* No handle in the source filename. Says so plainly and stays visually
   quieter than a real credit, without pretending to be one. */
.cr-slab-cred-none { color: rgba(220, 235, 225, 0.35); font-style: italic; letter-spacing: 3px; }
/* A ROW on the title band now, not a stacked column at the far edge: two
   short facts side by side read as a caption to the title, and stacking
   them made the band as tall as the name for no gain. */
.cr-slab-meta {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: 20px;
  font-family: 'Courier New', monospace;
  white-space: nowrap;
}
.cr-slab-meta span { display: flex; align-items: baseline; gap: 5px; }
.cr-slab-meta b { font-size: 15px; color: rgba(234, 255, 227, 0.85); font-weight: normal; }
.cr-slab-meta i { font-style: normal; font-size: 9px; letter-spacing: 2px; color: rgba(134, 255, 194, 0.38); }

/* Vote tally: a bar growing along the sleeve's bottom edge, with the count
   at its end. Empty sleeves show neither -- a row of zeroes reads as noise
   for the first half of the phase. */
/* The tally rides at the END of the row now, not along the plate's bottom
   edge: a bar spanning a turned-away plate is foreshortened to nothing, and
   the number beside it is the part that still reads at the far seats. The
   board in the deck column carries the full standings -- see #cr-tally. */
/* On the TITLE BAND now, beside BPM and KEY -- the count is a fact about the
   sleeve like the other two, and down on the bottom edge it was competing
   with the waveform for the eye. `align-self: center` because the band is
   baseline-aligned for the text and a 5px bar has no useful baseline. */
.cr-slab-tally {
  flex: none;
  align-self: center;
  width: 96px;
  height: 5px;
  display: flex;
  align-items: center;
  gap: 8px;
  opacity: 0;
  transition: opacity 0.25s ease-out;
}
.cr-slab-hasvotes .cr-slab-tally { opacity: 1; }
.cr-slab-bar {
  flex: 1;
  height: 100%;
  background: rgba(134, 255, 194, 0.1);
  overflow: hidden;
}
.cr-slab-bar::after {
  content: '';
  display: block;
  height: 100%;
  width: var(--fill, 0%);
  background: var(--cr-green);
  box-shadow: 0 0 10px rgba(134, 255, 194, 0.75);
  /* Grows rather than jumps -- a vote landing should be something you SEE
     happen, not a value that was suddenly always there. */
  transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.cr-slab-votes {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  line-height: 1;
  color: var(--cr-green);
  min-width: 1ch;
}
/* Out in front on the room's count. */
.cr-slab-lead { border-color: rgba(134, 255, 194, 0.55); }
.cr-slab-lead .cr-slab-bar::after { box-shadow: 0 0 16px rgba(134, 255, 194, 0.95); }

/* Your pick. Green, and stated in words -- the border alone is too close to
   the hover and the leading states to carry a fact this important. */
/* LOCKED IN. Not just labelled -- clamped. A green ring drawn INSIDE the
   plate (the clip eats a border on its 100% sides, so the ring is a
   background paint) plus a green throw, so the chosen sleeve reads as held
   rather than merely marked. It works on the gold face and the green card
   stock alike, which matters because your pick and the sleeve you are
   auditioning are allowed to be different plates. */
.cr-slab-mine {
  border-color: var(--cr-green);
  background: linear-gradient(150deg, rgba(26, 56, 40, 0.95) 0%, rgba(10, 22, 15, 0.97) 100%);
  box-shadow:
    inset 0 0 0 2px var(--cr-green),
    inset 0 0 26px rgba(134, 255, 194, 0.28);
}
/* On the lit plate the ring goes dark instead: green on gold would be the
   room's two loudest colours arguing, and the badge already carries green. */
.cr-slab-live.cr-slab-mine {
  box-shadow:
    inset 0 0 0 2px rgba(6, 33, 15, 0.75),
    inset 0 0 30px rgba(90, 45, 0, 0.25);
}
body.sw-no-bloom .cr-slab-mine { box-shadow: inset 0 0 0 2px var(--cr-green); }
body.sw-no-bloom .cr-slab-live.cr-slab-mine { box-shadow: inset 0 0 0 2px rgba(6, 33, 15, 0.75); }
/* The moment of locking gets one beat of motion -- the ring snaps in. It runs
   once on the plate that just became yours and never again, so it reads as
   the ACT rather than as a state that keeps announcing itself.
   cr-slab-in RIDES ALONG IN THIS LIST, and that is the fix for the locked
   view's ghost: an animation-name leaving the computed list is cancelled,
   and coming back it STARTS OVER. With the mine rule declaring only
   cr-lock-in, the plate you un-picked (lock B after a CHANGE) fell back to
   the base rule's list and REPLAYED its entry animation -- whose keyframes
   override the seat vars, so a plate the locked view had just sent to
   opacity 0 faded back IN to full, flat and huge over the stage, for the
   0.42s the replay ran (measured: computed opacity climbed 0 to 0.98
   against --so: 0, then popped off at animation end). Keeping cr-slab-in
   in BOTH lists means it is never cancelled and never replays; long
   finished with no forwards fill, it applies nothing. The delays must be
   restated per-item because the shorthand resets the base rule's stagger. */
.cr-slab-mine {
  animation: cr-lock-in 0.32s cubic-bezier(0.16, 1, 0.3, 1),
             cr-slab-in 0.42s cubic-bezier(0.16, 1, 0.3, 1) backwards;
  animation-delay: 0s, calc(var(--slab-i, 0) * 60ms);
}
@keyframes cr-lock-in {
  0%   { box-shadow: inset 0 0 0 14px rgba(134, 255, 194, 0), inset 0 0 40px rgba(134, 255, 194, 0.5); }
  100% { box-shadow: inset 0 0 0 2px var(--cr-green), inset 0 0 26px rgba(134, 255, 194, 0.28); }
}
.cr-slab-mine::after {
  content: 'YOUR PICK';
  position: absolute;
  top: 0;
  right: 0;
  padding: 3px 9px 4px;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 2px;
  color: #06210f;
  background: var(--cr-green);
  clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%);
}

/* On the deck. AMBER -- the one warm state in the room, so "playing" can
   never be confused with "chosen". */
/* ON THE CENTRE LINE. This is the one plate the wheel is presenting, and it
   is lit rather than merely outlined: dark ink on a hot amber face, so it
   reads as standing under a different light from the card stock behind it.
   That inversion is the depth cue that survives at any size -- an outline
   can only ever be a slightly brighter version of six other outlines.

   AMBER, not green: the room's colour law is that gold is what is playing
   right now and green is what has been chosen, never the same signal twice.
   Centring a sleeve IS auditioning it, so the centre plate is gold, and
   .cr-slab-mine keeps the green for the sleeve you actually pulled. */
.cr-slab-live {
  border-color: #ffe0a0;
  background: linear-gradient(120deg, #ffe9c0 0%, #ffc14d 58%, #e39a25 100%);
  /* The lift, as a stack of hard shadows falling onto the plates it covers
     plus one coloured throw. drop-shadow, not box-shadow: the clip-path
     would erase a box-shadow entirely (see the frames' note). */
  filter:
    drop-shadow(6px 7px 0 rgba(var(--cr-deep), 0.98))
    drop-shadow(11px 13px 0 rgba(var(--cr-deep), 0.8))
    drop-shadow(17px 20px 0 rgba(var(--cr-deep), 0.55))
    drop-shadow(24px 28px 0 rgba(var(--cr-deep), 0.3))
    drop-shadow(-16px 0 40px rgba(255, 193, 77, 0.4));
}
body.sw-no-bloom .cr-slab-live {
  filter:
    drop-shadow(6px 7px 0 rgba(var(--cr-deep), 0.98))
    drop-shadow(14px 16px 0 rgba(var(--cr-deep), 0.6));
}
/* Ink inversion. Each of these is the same value the plate uses elsewhere,
   restated against a lit face -- the alternative is a `filter: invert` on the
   subtree, which would also invert the vote bar and the green YOUR PICK tag
   into colours that mean other things. */
.cr-slab-live .cr-slab-n { color: rgba(28, 14, 0, 0.55); border-right-color: rgba(28, 14, 0, 0.2); }
.cr-slab-live .cr-slab-name { color: #241100; text-shadow: none; }
.cr-slab-live .cr-slab-cred { color: rgba(32, 16, 0, 0.78); }
.cr-slab-live .cr-slab-cred-none { color: rgba(32, 16, 0, 0.45); }
.cr-slab-live .cr-slab-meta b { color: rgba(28, 14, 0, 0.88); }
.cr-slab-live .cr-slab-meta i { color: rgba(28, 14, 0, 0.5); }
.cr-slab-live .cr-slab-bar { background: rgba(28, 14, 0, 0.2); }
.cr-slab-live .cr-slab-bar::after { background: #2a1400; box-shadow: none; }
.cr-slab-live .cr-slab-votes { color: #2a1400; }
/* Hover must not repaint the centred sleeve green: gold is "on the deck
   right now", and .cr-slab:hover's generic green border outranked it
   ((0,2,0) vs (0,1,0)), so hovering the one card you're most likely to
   hover erased the one signal that is never allowed to blur. The lit face
   needs its pale rim back too, not the token amber -- on a gold plate a
   gold border is no border at all. */
.cr-slab-live:hover,
.cr-slab-live:focus-visible { border-color: #ffe0a0; }

/* ==========================================================================
   THE DECK (#cr-deck)
   The transport used to ride on every card, which was right for a grid where
   six cards were equally readable. On a wheel it is not: five of the six are
   turned away, and a 34px play button on a plate rotated 22 degrees is a
   target nobody can hit. So the controls moved to the one place the room is
   always looking, and they serve whichever sleeve holds the centre line.
   Every value below is the one the card's own deck used -- this is the same
   transport, rehoused.
   ========================================================================== */
#cr-deck-plate {
  position: relative;
  padding: 22px 24px 20px;
  border: 1px solid rgba(255, 193, 77, 0.45);
  background: linear-gradient(150deg, rgba(46, 34, 14, 0.94) 0%, rgba(14, 10, 5, 0.97) 100%);
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  /* THE CLIP EATS THE BORDER on its 100% sides -- the ring is painted as an
     inset shadow (background paint, entirely inside the mask) and the border
     stays transparent so nothing moves. See #prod-sample-box. */
  border-color: transparent;
  box-shadow: inset 0 0 0 1px rgba(255, 193, 77, 0.45);
}
#cr-deck-top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  font-family: 'Courier New', monospace;
}
#cr-deck-n { font-size: 14px; letter-spacing: 3px; color: rgba(255, 193, 77, 0.5); }
/* Shown only when the plate on the centre line is also the one you pulled --
   the two are allowed to differ, which is the whole reason the commit moved
   off the plates. */
#cr-deck-tag {
  font-size: 9px;
  letter-spacing: 2px;
  color: #06210f;
  background: var(--cr-green);
  padding: 3px 9px 4px;
  visibility: hidden;
}
#cr-deck.is-mine #cr-deck-tag { visibility: visible; }
/* THE ARTWORK SLOT. Honest placeholder: no sample record carries an image,
   so it paints the sleeve's initial on a plate of the room's own materials
   rather than faking cover art. The box is the shape a real picture would
   take, so the day samples can carry one this becomes an <img> and nothing
   around it moves. */
#cr-deck-art {
  position: relative;
  margin-top: 16px;
  width: 100%;
  aspect-ratio: 1 / 1;
  max-height: 34vh;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background:
    repeating-linear-gradient(135deg, rgba(255, 193, 77, 0.05) 0 8px, transparent 8px 16px),
    linear-gradient(150deg, rgba(60, 44, 18, 0.9) 0%, rgba(16, 11, 5, 0.95) 100%);
  box-shadow: inset 0 0 0 1px rgba(255, 193, 77, 0.28);
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
}
#cr-deck-mark {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(48px, calc(7 * var(--dvw, 1vw)), 128px);
  line-height: 1;
  color: rgba(255, 208, 140, 0.32);
  text-shadow: 0 0 24px rgba(255, 170, 60, 0.25);
  user-select: none;
}
/* The label over the credit. Small, spaced, and quiet -- the NAME under it is
   the thing being celebrated. */
#cr-deck-by {
  margin-top: 16px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 5px;
  color: rgba(134, 255, 194, 0.6);
}
#cr-deck.is-uncredited #cr-deck-by { color: rgba(220, 235, 225, 0.28); }

#cr-deck-name {
  margin-top: 12px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(24px, calc(2.1 * var(--dvw, 1vw)), 38px);
  line-height: 1.05;
  color: var(--cr-ink);
  /* clip + margin, not hidden: the name lights up and `hidden` would slice
     the glow off square. Same pairing as .cr-slab-name. */
  overflow: clip;
  overflow-clip-margin: 24px;
  padding: 0.3em 0.25em;
  margin-left: -0.25em;
  margin-right: -0.25em;
  text-shadow: 0 0 16px rgba(255, 208, 140, 0.3);
}
#cr-deck-cred {
  margin-top: 6px;
  font-family: 'Courier New', monospace;
  /* The loudest thing on the panel after the title. This whole column exists
     to name the maker, and at 16px it was reading as a caption to the artwork
     rather than as the point. */
  font-size: clamp(22px, calc(1.9 * var(--dvw, 1vw)), 34px);
  letter-spacing: 2px;
  color: var(--cr-green);
  text-shadow: 0 0 14px rgba(134, 255, 194, 0.3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#cr-deck.is-uncredited #cr-deck-cred {
  color: rgba(220, 235, 225, 0.35);
  font-style: italic;
  letter-spacing: 3px;
}

/* ---- the standings board ----
   On a grid every card's bar was readable at once; on a wheel most plates
   are turned away, so the room's count needs somewhere flat to live. Only
   sleeves with votes appear -- a column of zeroes reads as noise for the
   first half of the phase, the same reason the plate bars stay hidden. */
#cr-tally {
  /* HUGS ITS CONTENT, capped. It was flex: 1 so it would absorb whatever
     height the column had left, which on a 1080 screen drew a 673px empty
     green box around 19px of standings. It grows with the votes and stops
     before it becomes the largest thing in the room. */
  /* AND IT ARRIVES WITH THE FIRST PICK. Empty, it was a header over a grey
     stub -- which reads as a broken panel rather than an empty one, and there
     is genuinely nothing to report until somebody has chosen. */
  opacity: 0;
  transform: translateY(10px);
  pointer-events: none;
  transition: opacity 0.5s ease-out, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
  flex: 0 1 auto;
  max-height: 46%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 14px 18px 16px;
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 194, 0.22);
  background: linear-gradient(150deg, rgba(20, 30, 22, 0.9) 0%, rgba(9, 15, 11, 0.95) 100%);
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
  font-family: 'Courier New', monospace;
}
#cr-tally.is-live { opacity: 1; transform: none; pointer-events: auto; }
#cr-tally-k { font-size: 11px; letter-spacing: 5px; color: var(--cr-green-dim); }
#cr-tally-rows {
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 9px;
  /* A crate can hold more sleeves than the board has room for on a short
     screen; the standings scroll rather than growing the column and pushing
     the wheel out of the stage. */
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: rgba(134, 255, 194, 0.3) transparent;
}
#cr-tally-rows::-webkit-scrollbar { width: 6px; }
#cr-tally-rows::-webkit-scrollbar-thumb { background: rgba(134, 255, 194, 0.28); }
#cr-tally-rows::-webkit-scrollbar-track { background: transparent; }
.cr-tally-row { display: flex; align-items: center; gap: 12px; }
.cr-tally-n { flex: none; width: 22px; font-size: 12px; color: rgba(134, 255, 194, 0.45); }
.cr-tally-mid { flex-grow: 1; min-width: 0; }
.cr-tally-name {
  font-size: 12px;
  letter-spacing: 2px;
  color: rgba(234, 255, 227, 0.85);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cr-tally-track { margin-top: 5px; height: 5px; background: rgba(134, 255, 194, 0.1); }
.cr-tally-fill {
  height: 100%;
  width: var(--fill, 0%);
  background: var(--cr-green);
  box-shadow: 0 0 10px rgba(134, 255, 194, 0.75);
  transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.cr-tally-v { flex: none; font-size: 13px; color: var(--cr-green); min-width: 1ch; }


/* Playing indicator: four bars dancing in the sleeve's top-left. Only ever
   present while audio is actually running. */
.cr-slab-eq {
  position: absolute;
  top: 10px;
  left: 44px;
  display: none;
  align-items: flex-end;
  gap: 2px;
  height: 12px;
}
.cr-slab-playing .cr-slab-eq { display: flex; }
.cr-slab-eq i {
  width: 3px;
  background: var(--cr-amber);
  box-shadow: 0 0 6px rgba(255, 193, 77, 0.8);
  animation: cr-eq 0.86s ease-in-out infinite;
}
.cr-slab-eq i:nth-child(1) { animation-delay: 0s;    height: 55%; }
.cr-slab-eq i:nth-child(2) { animation-delay: 0.19s; height: 100%; }
.cr-slab-eq i:nth-child(3) { animation-delay: 0.38s; height: 40%; }
.cr-slab-eq i:nth-child(4) { animation-delay: 0.11s; height: 78%; }
@keyframes cr-eq {
  0%, 100% { transform: scaleY(0.35); }
  50%      { transform: scaleY(1); }
}




/* animation-play-state, not `animation: none` -- pausing holds the platter
   at the angle it stopped at, which is what a real deck does. Restarting
   from 0deg on every play/pause read as a jump. */






/* ---- short screens ----
   Prefixed with #screen-crate so these can't lose an equal-specificity race
   with the rules above depending on source order. */
@media (max-height: 540px) {
  #screen-crate.active { padding-top: 68px; padding-bottom: 20px; }
  #screen-crate #cr-timer { font-size: clamp(44px, calc(5.4 * var(--dvw, 1vw)), 78px); }
  #screen-crate #cr-title { font-size: clamp(30px, calc(3.8 * var(--dvw, 1vw)), 56px); }
  /* The wheel sizes itself from the box it is given (crSeatShelf measures),
     so a short screen needs nothing here -- the seats simply pack tighter.
     The DECK is what runs out of room first. */
  #screen-crate #cr-deck-plate { padding: 14px 18px 14px; }
  #screen-crate #cr-deck-name { margin-top: 6px; }
}
/* Narrow: the deck stops being a column beside the wheel and becomes a
   header above it. Below this width a 320px deck plus a plate wide enough to
   read a filename do not both fit, and the wheel is the part that has to
   stay legible -- it is the control. */
@media (max-width: 1100px) {
  #cr-stage { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
  #cr-deck { grid-row: auto; flex-direction: row; align-items: stretch; gap: 14px; }
  #cr-deck-plate { flex: 1; min-width: 0; }
  #cr-tally { flex: none; width: 240px; }
  #cr-deck-name { font-size: 24px; }
}

/* Reduced motion: the room stops drifting and the cards stop dancing.
   Everything still says exactly what it said -- these are all ambience,
   never the carrier of a fact. The WHEEL's transitions are exempt on
   purpose: turning it is not ambience, it is the answer to the press, and a
   wheel that teleports between seats is unreadable rather than calmer. */
@media (prefers-reduced-motion: reduce) {
  /* The low-time clock keeps its COLOUR and loses its pulse -- the colour is
     the fact, the breathing is only emphasis. */
  #cr-timer.is-low,
  #cr-room::after,
  #cr-floor::before,
  .cr-slab-eq i,
  .cr-slab { animation: none; }
}



/* =========================================================================
   THE BOOTH -- Song Wars' beat vote -- HAS NO CSS OF ITS OWN ANY MORE.
   =========================================================================

   The beat vote stands in #screen-crate and wears the wheel wholesale --
   see THE BOOTH IS THE CRATE'S WHEEL in main.js. The old blue rack (the
   crate's "deliberate twin, same bones on purpose") retired the day the
   two twins started teaching different controls. */

/* =========================================================================
   THE PAD -- Song Wars' word submit
   =========================================================================

   The booth's room again, one phase later: same header rhythm, same blue and
   gold, so the artist side reads as one place you move through rather than
   three screens. What changes is the furniture. The booth is a rack of
   things to choose between; the pad is the ROOM -- one big card per player,
   face down, flipping over as people lock in.

   Gold is still "happening right now", which here means locked in. Blue is
   still the room's own colour, which here is you and everything unresolved.

   TRUTH BOUNDARY: a flipped card shows a REDACTED bar, not a word. See the
   note in index.html; do not "finish" it by rendering the word. */

body:has(#screen-wordsubmit.active) { max-width: none; padding: 0; }

#screen-wordsubmit {
  --ws-blue: #6e96ff;
  --ws-blue-dim: rgba(110, 150, 255, 0.5);
  --ws-gold: #ffd257;
  --ws-ink: #eaf0ff;
  --ws-deep: 2, 6, 20;
}

#screen-wordsubmit.active {
  height: calc(100 * var(--dvh, 1vh));
  max-height: calc(100 * var(--dvh, 1vh));
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 26px;
  overflow: hidden;
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(110, 150, 255, 0.17) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #080b18 0%, #04060f 58%, #010204 100%);
}
#screen-wordsubmit.active > * { position: relative; z-index: 1; }
#screen-wordsubmit.active > #ws-room { z-index: 0; }

/* Ruled paper, faint, drifting. A pad. */
#ws-room {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(0deg, transparent 0 46px, rgba(110, 150, 255, 0.05) 46px 47px),
    repeating-linear-gradient(90deg, transparent 0 300px, rgba(110, 150, 255, 0.035) 300px 302px);
  mask-image: radial-gradient(ellipse 88% 78% at 50% 12%, #000 0%, rgba(0, 0, 0, 0.3) 66%, transparent 100%);
}
#ws-room::after {
  content: '';
  position: absolute;
  inset: -20%;
  background:
    radial-gradient(circle at 26% 32%, rgba(180, 210, 255, 0.4) 0 1px, transparent 2px),
    radial-gradient(circle at 72% 18%, rgba(180, 210, 255, 0.32) 0 1px, transparent 2px),
    radial-gradient(circle at 48% 76%, rgba(180, 210, 255, 0.28) 0 1px, transparent 2px);
  background-size: 340px 300px;
  animation: ws-drift 46s linear infinite;
  opacity: 0.4;
}
@keyframes ws-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, 300px, 0); }
}

/* ---- top strip: the booth's grid, unchanged ---- */
#ws-top {
  display: grid;
  grid-template-columns: minmax(280px, 22%) 1fr minmax(240px, 20%);
  align-items: start;
  gap: 30px;
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
  flex: none;
}
#ws-left {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-left: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  min-width: 0;
}
#ws-mode {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  color: var(--ws-blue);
  text-shadow: 0 0 14px rgba(110, 150, 255, 0.6);
  white-space: nowrap;
}
.ws-kata {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  color: rgba(160, 190, 255, 0.5);
  letter-spacing: 5px;
}
#wordsubmit-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(58px, calc(7.4 * var(--dvw, 1vw)), 118px);
  font-weight: bold;
  line-height: 1;
  white-space: nowrap;
  color: var(--ws-ink);
  text-shadow: 0 0 26px rgba(140, 180, 255, 0.55), 0 0 80px rgba(80, 130, 255, 0.24);
}
#ws-head { text-align: center; min-width: 0; padding-top: 6px; }
#ws-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(38px, calc(5.2 * var(--dvw, 1vw)), 84px);
  line-height: 1;
  letter-spacing: 6px;
  color: var(--ws-ink);
  /* clip + margin, never hidden -- see #cr-title. */
  overflow: clip;
  overflow-clip-margin: 72px;
  text-shadow: 0 0 18px rgba(150, 185, 255, 0.62), 0 0 60px rgba(80, 130, 255, 0.3);
}
#ws-sub {
  margin-top: 10px;
  font-size: clamp(12px, calc(1.1 * var(--dvw, 1vw)), 16px);
  letter-spacing: 5px;
  color: rgba(226, 236, 255, 0.62);
}
#ws-count {
  justify-self: end;
  text-align: right;
  padding-right: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}
.ws-count-k { font-size: 13px; letter-spacing: 5px; color: var(--ws-blue-dim); }
#ws-count-n {
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, calc(3.4 * var(--dvw, 1vw)), 52px);
  line-height: 1;
  color: var(--ws-ink);
  text-shadow: 0 0 18px rgba(130, 170, 255, 0.5);
}
#ws-count-n i { font-style: normal; opacity: 0.35; margin: 0 4px; }
#ws-count-n span { opacity: 0.55; }
#ws-count-pips { display: flex; gap: 5px; }
#ws-count-pips i {
  width: 15px;
  height: 5px;
  background: rgba(110, 150, 255, 0.16);
  transition: background 0.3s ease-out, box-shadow 0.3s ease-out;
}
#ws-count-pips i.ws-pip-on {
  background: var(--ws-gold);
  box-shadow: 0 0 9px rgba(255, 210, 87, 0.85);
}

/* ---- the brief ---- */
#ws-brief {
  flex: none;
  margin-top: clamp(10px, calc(1.6 * var(--dvh, 1vh)), 22px);
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 14px;
  font-family: 'Courier New', monospace;
  white-space: nowrap;
  overflow: clip;
  overflow-clip-margin: 24px;
}
.ws-brief-k { font-size: 13px; letter-spacing: 5px; color: var(--ws-blue-dim); }
#ws-brief-beat {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(20px, calc(1.9 * var(--dvw, 1vw)), 30px);
  letter-spacing: 3px;
  color: var(--ws-gold);
  text-shadow: 0 0 16px rgba(255, 210, 87, 0.45);
}
#ws-brief-genre { font-size: 14px; letter-spacing: 4px; color: rgba(226, 236, 255, 0.45); }

/* ---- the room of cards ---- */
#ws-stage {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  margin-top: clamp(12px, calc(2 * var(--dvh, 1vh)), 26px);
}
#ws-slots {
  list-style: none;
  margin: 0;
  /* Room for the hover pop inside the scroll container -- measured on the
     old booth rack before it retired into the crate's wheel. */
  padding: 14px;
  min-height: 0;
  overflow-y: auto;
  flex: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* safe, so a short window drops to start instead of pushing the first row
     above the scroll origin where it can't be reached. */
  align-content: safe center;
  justify-content: safe center;
  gap: clamp(14px, calc(1.8 * var(--dvw, 1vw)), 30px);
  scrollbar-width: thin;
  scrollbar-color: rgba(110, 150, 255, 0.3) transparent;
}
#ws-slots::-webkit-scrollbar { width: 8px; }
#ws-slots::-webkit-scrollbar-thumb { background: rgba(110, 150, 255, 0.28); }
#ws-slots::-webkit-scrollbar-track { background: transparent; }

.ws-slot {
  flex: 0 0 auto;
  width: clamp(200px, calc(17 * var(--dvw, 1vw)), 268px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  animation: ws-slot-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
  animation-delay: calc(var(--slot-i, 0) * 80ms);
}
@keyframes ws-slot-in {
  from { opacity: 0; transform: translateY(18px) scale(0.96); }
  to   { opacity: 1; transform: none; }
}

/* ---- the card ----
   A real 3D flip: two faces back to back, the whole card rotated. The
   perspective lives on the slot so each card turns on its own axis rather
   than sharing one vanishing point across the row. */
.ws-slot { perspective: 1200px; }
.ws-card {
  position: relative;
  width: 100%;
  height: clamp(212px, calc(25 * var(--dvh, 1vh)), 290px);
  transform-style: preserve-3d;
  /* Slow into the turn, land hard -- a card being SET DOWN. */
  transition: transform 0.72s cubic-bezier(0.66, 0, 0.2, 1);
}
.ws-slot-in .ws-card { transform: rotateY(180deg); }
/* Editing pulls your own card back to the pen while your old word stays in. */
.ws-slot-you.ws-editing .ws-card { transform: rotateY(0deg); }

.ws-face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 18px;
  backface-visibility: hidden;
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
}
.ws-face-q {
  background: linear-gradient(150deg, rgba(20, 26, 44, 0.92) 0%, rgba(8, 11, 22, 0.96) 100%);
  border: 1px solid rgba(110, 150, 255, 0.22);
}
.ws-face-in {
  transform: rotateY(180deg);
  background: linear-gradient(150deg, rgba(52, 42, 16, 0.95) 0%, rgba(18, 13, 5, 0.97) 100%);
  border: 1px solid var(--ws-gold);
}
/* BACKFACE CULLING ALONE DOES NOT HIDE THE PEN. Every other flip card in
   here is pure text on a gradient and culls cleanly; this one carries an
   <input>, and a face that also has a clip-path is flattened into its own
   rendering context -- the input's placeholder then paints THROUGH the
   gold back face, MIRRORED, sitting right behind the CHANGE button (owner
   screenshot). So the turned-away face is taken out of the paint
   altogether. visibility, not opacity or display: display would kill the
   flip's layout mid-turn and opacity leaves the layer being composited at
   all. The 0.36s delay is HALF the 0.72s flip, applied on the base rule so
   it holds in BOTH directions -- each face stays drawn for exactly the
   half of the turn it is facing you, and vanishes at the edge-on frame
   where there is nothing to see anyway. CHANGE turns the card back, which
   is why the back face gets the same treatment rather than a one-way
   rule. */
.ws-face { transition: visibility 0s linear 0.36s; }
.ws-slot-in:not(.ws-editing) .ws-face-q { visibility: hidden; }
.ws-slot:not(.ws-slot-in) .ws-face-in,
.ws-slot-you.ws-editing .ws-face-in { visibility: hidden; }

/* The question mark. Big, breathing, and the whole point of the screen --
   this is what everyone is staring at while they think. */
.ws-q {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(74px, calc(8 * var(--dvw, 1vw)), 118px);
  line-height: 1;
  color: rgba(110, 150, 255, 0.55);
  text-shadow: 0 0 24px rgba(110, 150, 255, 0.4);
  animation: ws-breathe 3.4s ease-in-out infinite;
}
@keyframes ws-breathe {
  0%, 100% { opacity: 0.5;  transform: scale(0.97); }
  50%      { opacity: 0.95; transform: scale(1.03); }
}
/* Your own card has the pen on its front instead. */
.ws-slot-you .ws-q { display: none; }

/* The redacted bar: there IS a word in there and you cannot see it. Three
   blocks so it reads as language rather than a progress bar. */
.ws-redact { display: flex; gap: 7px; }
.ws-redact i {
  display: block;
  width: 34px;
  height: 17px;
  background: rgba(255, 210, 87, 0.85);
  box-shadow: 0 0 12px rgba(255, 210, 87, 0.5);
  animation: ws-redact-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.ws-redact i:nth-child(1) { animation-delay: 0.42s; }
.ws-redact i:nth-child(2) { animation-delay: 0.5s; width: 46px; }
.ws-redact i:nth-child(3) { animation-delay: 0.58s; width: 26px; }
@keyframes ws-redact-in {
  from { transform: scaleX(0); transform-origin: left center; }
  to   { transform: scaleX(1); }
}
.ws-in-k {
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 5px;
  color: rgba(255, 210, 87, 0.75);
}
/* Only YOUR back face carries a real word -- the truth boundary in one
   selector. Everyone else's stays the redacted bar. */
.ws-word { display: none; }
.ws-slot-you .ws-word {
  display: block;
  max-width: 100%;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(26px, calc(2.6 * var(--dvw, 1vw)), 40px);
  line-height: 1.1;
  text-align: center;
  color: var(--ws-gold);
  overflow: clip;
  overflow-clip-margin: 24px;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-shadow: 0 0 20px rgba(255, 210, 87, 0.5);
}
.ws-slot-you .ws-redact { display: none; }

.ws-change {
  display: none;
  padding: 9px 18px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 3px;
  color: rgba(255, 210, 87, 0.8);
  background: transparent;
  border: 1px solid rgba(255, 210, 87, 0.4);
  cursor: pointer;
  transition: background 0.18s ease-out, color 0.18s ease-out,
              transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ws-slot-you .ws-change { display: block; }
.ws-change:hover,
.ws-change:focus-visible {
  color: #1a1204;
  background: var(--ws-gold);
  outline: none;
  transform: translate(-2px, -2px) scale(1.05);
}

/* ---- the pen ---- */
#ws-pen {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
}
/* Parked outside a card (before the roster builds, and after teardown) it
   must not draw -- it would sit loose at the bottom of the screen. */
#screen-wordsubmit > #ws-pen { display: none; }
#input-word {
  width: 100%;
  padding: 14px 12px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(20px, calc(1.9 * var(--dvw, 1vw)), 28px);
  text-align: center;
  letter-spacing: 2px;
  color: var(--ws-ink);
  background: rgba(110, 150, 255, 0.08);
  border: 1px solid rgba(110, 150, 255, 0.35);
  border-radius: 0;
  outline: none;
  transition: border-color 0.18s ease-out, background 0.18s ease-out,
              box-shadow 0.18s ease-out;
}
#input-word::placeholder { color: rgba(160, 190, 255, 0.35); letter-spacing: 4px; }
#input-word:focus {
  border-color: var(--ws-blue);
  background: rgba(110, 150, 255, 0.16);
  box-shadow: 0 0 22px rgba(110, 150, 255, 0.35);
}
/* ---- HOST WRITES: the ballot rail ----
   Only the host of a host-source round sees this (wsApplySource gates it on
   the server's own door), so it sits under the pen rather than beside it --
   the pad's column is already the width of a card and a second control on
   that line would squeeze the pen it belongs to.

   The button borrows #btn-submit-word's construction rather than restating
   it: same chamfer, same inset ring for the same reason (THE CLIP EATS THE
   BORDER -- clip-path erases half the ink on every 100% side), same house
   pop on hover via drop-shadow, since a box-shadow under that clip is
   simply gone. */
#ws-host-rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
}
#ws-host-words {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 3px;
  color: var(--ws-ink);
  opacity: 0.72;
}
#btn-close-word-pad {
  padding: 11px 22px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 4px;
  white-space: nowrap;
  color: var(--ws-ink);
  background: rgba(110, 150, 255, 0.1);
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--ws-blue);
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  cursor: pointer;
  transition: background 0.18s ease-out, color 0.18s ease-out,
              transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#btn-close-word-pad:hover,
#btn-close-word-pad:focus-visible {
  color: #060d21;
  background: var(--ws-blue);
  outline: none;
  transform: translate(-2px, -2px) scale(1.03);
}

#btn-submit-word {
  padding: 13px 16px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 4px;
  white-space: nowrap;
  color: var(--ws-ink);
  background: rgba(110, 150, 255, 0.16);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--ws-blue);
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  cursor: pointer;
  transition: background 0.18s ease-out, color 0.18s ease-out,
              transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
              filter 0.24s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* The house pop (the spring the old booth's PICK plate established).
   drop-shadow, not box-shadow: the clip-path above would clip a
   box-shadow away entirely. */
#btn-submit-word:hover,
#btn-submit-word:focus-visible {
  color: #060d21;
  background: var(--ws-blue);
  outline: none;
  transform: translate(-3px, -3px) scale(1.04);
  filter:
    drop-shadow(2px 2px 0 rgba(var(--ws-deep), 0.98))
    drop-shadow(2px 2px 0 rgba(var(--ws-deep), 0.86))
    drop-shadow(2px 2px 0 rgba(var(--ws-deep), 0.7))
    drop-shadow(2px 2px 0 rgba(var(--ws-deep), 0.5))
    drop-shadow(0 0 16px rgba(110, 150, 255, 0.75));
}
#btn-submit-word:active {
  transform: translate(0, 0) scale(0.97);
  transition-duration: 0.06s;
}

/* Empty submit: the card refuses rather than a message appearing somewhere
   else on the screen. */
.ws-nudge { animation: ws-nudge 0.34s ease-in-out; }
@keyframes ws-nudge {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-9px); }
  45% { transform: translateX(7px); }
  70% { transform: translateX(-4px); }
}

/* ---- who the card belongs to ---- */
.ws-who {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  max-width: 100%;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 3px;
  color: rgba(226, 236, 255, 0.7);
}
.ws-who-name {
  max-width: 100%;
  overflow: clip;
  overflow-clip-margin: 18px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ws-slot-you .ws-who-name { color: var(--ws-blue); text-shadow: 0 0 12px rgba(110, 150, 255, 0.5); }
.ws-slot-in .ws-who-name { color: var(--ws-gold); text-shadow: 0 0 12px rgba(255, 210, 87, 0.45); }
/* Disconnected: still on the wall, visibly not here. */
.ws-slot-away { opacity: 0.42; filter: saturate(0.4); }

/* Hover pop on someone else's card -- it opens their profile, so it has to
   answer the mouse. Yours holds the pen and must not move under a click
   aimed at the input. */
.ws-slot:not(.ws-slot-you) { cursor: pointer; }
.ws-slot:not(.ws-slot-you):hover .ws-card { transform: translateY(-6px) scale(1.03); }
.ws-slot:not(.ws-slot-you).ws-slot-in:hover .ws-card { transform: rotateY(180deg) translateZ(14px); }

/* ---- footer line ---- */
#wordsubmit-status {
  flex: none;
  margin: 12px 0 0;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 5px;
  color: rgba(160, 190, 255, 0.5);
}

/* ---- short screens ---- */
@media (max-height: 540px) {
  #screen-wordsubmit.active { padding-top: 68px; padding-bottom: 18px; }
  #screen-wordsubmit #wordsubmit-timer { font-size: clamp(44px, calc(5.4 * var(--dvw, 1vw)), 78px); }
  #screen-wordsubmit #ws-title { font-size: clamp(30px, calc(3.8 * var(--dvw, 1vw)), 56px); }
  #screen-wordsubmit #ws-sub { margin-top: 6px; }
  #screen-wordsubmit .ws-card { height: clamp(176px, calc(21 * var(--dvh, 1vh)), 232px); }
  #screen-wordsubmit .ws-slot { width: clamp(176px, calc(15 * var(--dvw, 1vw)), 232px); }
}

/* Reduced motion: the flip becomes a cut and the room stops drifting. The
   card still turns over -- that IS the fact -- it just doesn't animate. */
@media (prefers-reduced-motion: reduce) {
  #ws-room::after,
  .ws-slot,
  .ws-q,
  .ws-redact i { animation: none; }
  .ws-card { transition: none; }
  /* No flip to be half-way through, so the faces swap on the spot. */
  .ws-face { transition: none; }
  #btn-submit-word:hover,
  #btn-submit-word:focus-visible,
  .ws-change:hover,
  .ws-change:focus-visible { transform: none; }
}


/* =========================================================================
   THE CUT -- Song Wars' word vote
   =========================================================================

   The pad's cards coming back up. Same card, same turn, same blue and gold --
   but detached from their owners now, shuffled by the server, and laid out as
   a ballot. The reveal IS the transition: the phase opens with every card
   face down and they turn over on a stagger.

   Gold means picked. Nothing on this screen may attach a word to a person --
   see the note in index.html. */

body:has(#screen-wordvote.active) { max-width: none; padding: 0; }

#screen-wordvote {
  --wv-blue: #6e96ff;
  --wv-blue-dim: rgba(110, 150, 255, 0.5);
  --wv-gold: #ffd257;
  --wv-ink: #eaf0ff;
  --wv-deep: 2, 6, 20;
}

#screen-wordvote.active {
  height: calc(100 * var(--dvh, 1vh));
  max-height: calc(100 * var(--dvh, 1vh));
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  padding: 84px clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 26px;
  overflow: hidden;
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(110, 150, 255, 0.17) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #080b18 0%, #04060f 58%, #010204 100%);
}
#screen-wordvote.active > * { position: relative; z-index: 1; }
#screen-wordvote.active > #wv-room { z-index: 0; }

/* Felt: the table the cards get dealt on. */
#wv-room {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    repeating-linear-gradient(45deg, transparent 0 14px, rgba(110, 150, 255, 0.022) 14px 15px),
    repeating-linear-gradient(-45deg, transparent 0 14px, rgba(110, 150, 255, 0.022) 14px 15px);
  mask-image: radial-gradient(ellipse 88% 78% at 50% 14%, #000 0%, rgba(0, 0, 0, 0.3) 66%, transparent 100%);
}

/* ---- top strip: the pad's grid, unchanged ---- */
#wv-top {
  display: grid;
  grid-template-columns: minmax(280px, 22%) 1fr minmax(240px, 20%);
  align-items: start;
  gap: 30px;
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
  flex: none;
}
#wv-left {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-left: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  min-width: 0;
}
#wv-mode {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 26px;
  color: var(--wv-blue);
  text-shadow: 0 0 14px rgba(110, 150, 255, 0.6);
  white-space: nowrap;
}
.wv-kata {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  color: rgba(160, 190, 255, 0.5);
  letter-spacing: 5px;
}
#wordvote-timer {
  font-family: 'Courier New', monospace;
  font-size: clamp(58px, calc(7.4 * var(--dvw, 1vw)), 118px);
  font-weight: bold;
  line-height: 1;
  white-space: nowrap;
  color: var(--wv-ink);
  text-shadow: 0 0 26px rgba(140, 180, 255, 0.55), 0 0 80px rgba(80, 130, 255, 0.24);
}
#wv-head { text-align: center; min-width: 0; padding-top: 6px; }
#wv-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(38px, calc(5.2 * var(--dvw, 1vw)), 84px);
  line-height: 1;
  letter-spacing: 6px;
  color: var(--wv-ink);
  overflow: clip;
  overflow-clip-margin: 72px;
  text-shadow: 0 0 18px rgba(150, 185, 255, 0.62), 0 0 60px rgba(80, 130, 255, 0.3);
}
#wv-sub {
  margin-top: 10px;
  font-size: clamp(12px, calc(1.1 * var(--dvw, 1vw)), 16px);
  letter-spacing: 5px;
  color: rgba(226, 236, 255, 0.62);
}
#wv-count {
  justify-self: end;
  text-align: right;
  padding-right: clamp(10px, calc(2.2 * var(--dvw, 1vw)), 46px);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}
.wv-count-k { font-size: 13px; letter-spacing: 5px; color: var(--wv-blue-dim); }
#wv-count-n {
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, calc(3.4 * var(--dvw, 1vw)), 52px);
  line-height: 1;
  color: var(--wv-ink);
  text-shadow: 0 0 18px rgba(130, 170, 255, 0.5);
}
#wv-count-n i { font-style: normal; opacity: 0.35; margin: 0 4px; }
#wv-count-n span { opacity: 0.55; }
#wv-count-pips { display: flex; gap: 5px; }
#wv-count-pips i {
  width: 15px;
  height: 5px;
  background: rgba(110, 150, 255, 0.16);
  transition: background 0.3s ease-out, box-shadow 0.3s ease-out;
}
#wv-count-pips i.wv-pip-on {
  background: var(--wv-gold);
  box-shadow: 0 0 9px rgba(255, 210, 87, 0.85);
}

/* ---- the ballot ---- */
#wv-stage {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  margin-top: clamp(12px, calc(2 * var(--dvh, 1vh)), 26px);
}
#word-options {
  list-style: none;
  margin: 0;
  padding: 16px;
  min-height: 0;
  overflow-y: auto;
  flex: 1;
  display: flex;
  flex-direction: row;      /* restated: see the stale-rule note at the top */
  flex-wrap: wrap;
  align-items: center;
  /* safe, so a short window drops to start rather than pushing the first row
     above the scroll origin where it can't be reached. */
  align-content: safe center;
  justify-content: safe center;
  gap: clamp(14px, calc(1.6 * var(--dvw, 1vw)), 26px);
  perspective: 1400px;
  scrollbar-width: thin;
  scrollbar-color: rgba(110, 150, 255, 0.3) transparent;
}
#word-options::-webkit-scrollbar { width: 8px; }
#word-options::-webkit-scrollbar-thumb { background: rgba(110, 150, 255, 0.28); }
#word-options::-webkit-scrollbar-track { background: transparent; }

.wv-card {
  position: relative;
  flex: 0 0 auto;
  width: clamp(190px, calc(16 * var(--dvw, 1vw)), 250px);
  height: clamp(150px, calc(18 * var(--dvh, 1vh)), 200px);
  cursor: pointer;
  transform-style: preserve-3d;
  transition: transform 0.26s cubic-bezier(0.34, 1.56, 0.64, 1),
              filter 0.26s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.wv-face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 16px;
  backface-visibility: hidden;
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
}
.wv-face-front {
  background: linear-gradient(150deg, rgba(20, 26, 44, 0.92) 0%, rgba(8, 11, 22, 0.96) 100%);
  border: 1px solid rgba(110, 150, 255, 0.24);
  transition: border-color 0.2s ease-out, background 0.2s ease-out;
}
/* The sealed back: the pad's card, still face down. */
.wv-face-back {
  transform: rotateY(180deg);
  background:
    repeating-linear-gradient(45deg, rgba(110, 150, 255, 0.12) 0 6px, transparent 6px 12px),
    linear-gradient(150deg, rgba(18, 24, 42, 0.96) 0%, rgba(7, 10, 20, 0.98) 100%);
  border: 1px solid rgba(110, 150, 255, 0.3);
}

/* ---- the reveal ----
   `backwards` fill is what makes this work: the card sits at rotateY(180)
   -- face down -- from the moment it exists until its own delay elapses,
   then turns. No second class, no timers per card.

   --reveal-shift is how far into the phase this client already is, so a late
   or reloading tab seeks into the same reveal everyone else is watching
   rather than starting a private one. Cards whose delay has already passed
   land face up immediately, which is correct. */
#word-options.wv-revealing .wv-card {
  animation: wv-turn 720ms cubic-bezier(0.66, 0, 0.2, 1) backwards;
  animation-delay: calc(var(--i, 0) * 110ms - var(--reveal-shift, 0ms));
}
@keyframes wv-turn {
  from { transform: rotateY(180deg); }
  to   { transform: rotateY(0deg); }
}

.wv-word {
  max-width: 100%;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(24px, calc(2.3 * var(--dvw, 1vw)), 36px);
  line-height: 1.1;
  text-align: center;
  color: var(--wv-ink);
  overflow: clip;
  overflow-clip-margin: 26px;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-shadow: 0 0 14px rgba(170, 200, 255, 0.3);
}
.wv-rank {
  position: absolute;
  top: 0;
  right: 0;
  min-width: 34px;
  padding: 5px 11px 6px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  text-align: center;
  color: #1a1204;
  background: var(--wv-gold);
  clip-path: polygon(13px 0, 100% 0, 100% 100%, 0 100%);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.2s ease-out, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Hover: the house pop. drop-shadow on the card, not box-shadow -- the faces
   are clipped and a box-shadow would be clipped away with them. */
.wv-card:hover,
.wv-card:focus-visible {
  outline: none;
  transform: translate(-5px, -6px) scale(1.04);
  filter:
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.98))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.86))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.7))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.5))
    drop-shadow(0 0 22px rgba(110, 150, 255, 0.42));
}
.wv-card:hover .wv-face-front,
.wv-card:focus-visible .wv-face-front { border-color: rgba(110, 150, 255, 0.85); }
.wv-card:active { transform: translate(0, 0) scale(0.985); transition-duration: 0.07s; }

/* Picked. Gold, and the numeral says which of your three it is -- the order
   you chose, not a weighting; every vote counts the same. */
.wv-card-picked .wv-face-front {
  border-color: var(--wv-gold);
  background: linear-gradient(150deg, rgba(52, 42, 16, 0.95) 0%, rgba(18, 13, 5, 0.97) 100%);
}
.wv-card-picked .wv-word { color: var(--wv-gold); text-shadow: 0 0 20px rgba(255, 210, 87, 0.5); }
.wv-card-picked .wv-rank { opacity: 1; transform: translateY(0); }
.wv-card-picked {
  filter: drop-shadow(0 0 20px rgba(255, 210, 87, 0.3));
}
.wv-card-picked:hover,
.wv-card-picked:focus-visible {
  filter:
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.98))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.86))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.7))
    drop-shadow(3px 3px 0 rgba(var(--wv-deep), 0.5))
    drop-shadow(0 0 24px rgba(255, 210, 87, 0.7));
}

/* A fourth pick is refused, not silently swapped -- the card says no. */
.wv-nudge { animation: wv-nudge 0.34s ease-in-out; }
@keyframes wv-nudge {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-9px); }
  45% { transform: translateX(7px); }
  70% { transform: translateX(-4px); }
}

/* ---- the pick strip + confirm ---- */
#wv-bar {
  flex: none;
  margin-top: clamp(10px, calc(1.6 * var(--dvh, 1vh)), 20px);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, calc(1.8 * var(--dvw, 1vw)), 30px);
  flex-wrap: wrap;
}
#wv-picks { display: flex; gap: 10px; }
.wv-pick-slot {
  min-width: clamp(110px, calc(11 * var(--dvw, 1vw)), 168px);
  padding: 11px 14px;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 3px;
  text-align: center;
  color: rgba(160, 190, 255, 0.3);
  border: 1px dashed rgba(110, 150, 255, 0.28);
  overflow: clip;
  overflow-clip-margin: 18px;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.2s ease-out, border-color 0.2s ease-out, background 0.2s ease-out;
}
.wv-pick-full {
  cursor: pointer;
  color: var(--wv-gold);
  border-style: solid;
  border-color: rgba(255, 210, 87, 0.55);
  background: rgba(255, 210, 87, 0.08);
}
.wv-pick-full:hover { background: rgba(255, 210, 87, 0.2); }

#btn-submit-wordvotes {
  padding: 15px 34px;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 5px;
  white-space: nowrap;
  color: var(--wv-ink);
  background: rgba(110, 150, 255, 0.16);
  border: 1px solid var(--wv-blue);
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
  cursor: pointer;
  transition: background 0.18s ease-out, color 0.18s ease-out,
              transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
              filter 0.24s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#btn-submit-wordvotes:disabled {
  cursor: default;
  opacity: 0.4;
  color: rgba(160, 190, 255, 0.5);
  background: transparent;
  border-color: rgba(110, 150, 255, 0.25);
}
#btn-submit-wordvotes:not(:disabled):hover,
#btn-submit-wordvotes:not(:disabled):focus-visible {
  color: #060d21;
  background: var(--wv-blue);
  outline: none;
  transform: translate(-3px, -3px) scale(1.04);
  filter:
    drop-shadow(2px 2px 0 rgba(var(--wv-deep), 0.98))
    drop-shadow(2px 2px 0 rgba(var(--wv-deep), 0.86))
    drop-shadow(2px 2px 0 rgba(var(--wv-deep), 0.7))
    drop-shadow(2px 2px 0 rgba(var(--wv-deep), 0.5))
    drop-shadow(0 0 16px rgba(110, 150, 255, 0.75));
}
#btn-submit-wordvotes:not(:disabled):active {
  transform: translate(0, 0) scale(0.97);
  transition-duration: 0.06s;
}
/* Already in. Still clickable -- the server takes a re-vote right up to the
   clock -- it just stops shouting for attention. */
.wv-locked #btn-submit-wordvotes {
  color: var(--wv-gold);
  background: rgba(255, 210, 87, 0.1);
  border-color: rgba(255, 210, 87, 0.55);
}

#wordvote-status {
  flex: none;
  margin: 12px 0 0;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 5px;
  color: rgba(160, 190, 255, 0.5);
}

/* ---- short screens ---- */
@media (max-height: 540px) {
  #screen-wordvote.active { padding-top: 68px; padding-bottom: 18px; }
  #screen-wordvote #wordvote-timer { font-size: clamp(44px, calc(5.4 * var(--dvw, 1vw)), 78px); }
  #screen-wordvote #wv-title { font-size: clamp(30px, calc(3.8 * var(--dvw, 1vw)), 56px); }
  #screen-wordvote #wv-sub { margin-top: 6px; }
  #screen-wordvote .wv-card { height: clamp(124px, calc(15 * var(--dvh, 1vh)), 164px); width: clamp(168px, calc(14 * var(--dvw, 1vw)), 220px); }
}

/* Reduced motion: the cards are simply face up. The reveal is drama, not
   information -- the ballot says the same thing either way. */
@media (prefers-reduced-motion: reduce) {
  #word-options.wv-revealing .wv-card { animation: none; }
  .wv-card:hover,
  .wv-card:focus-visible,
  #btn-submit-wordvotes:not(:disabled):hover { transform: none; }
}


/* ---------- THE RECORD on the card: the erase wipe ----------
   #pv-stats sits over the card's info column (name stays; socials, bio and
   showcase get erased under it). Arming sweeps it in from the left like a
   redaction bar drawing across the page; disarming sweeps it back out. The
   clip-path is the whole animation -- the pane never moves, it is REVEALED,
   which is what makes it read as erasing rather than sliding. */
/* The wipe's home is the BODY -- everything under the name plate. The name
   and level stand outside it, so they stay lit while the rest is erased. It
   restates pv-left's column-and-gap so wrapping changed no layout. */
#pv-body {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
#pv-stats {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 16px;
  padding: 18px 24px;
  background: linear-gradient(150deg, rgba(10, 24, 12, 0.985) 0%, rgba(4, 12, 6, 0.995) 100%);
  border-left: 3px solid #86ff66;
  clip-path: inset(0 100% 0 0);
  transition: clip-path 0.55s cubic-bezier(0.66, 0, 0.2, 1);
  pointer-events: none;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
/* Arming is TWO beats (house rule: nothing is instant -- effects build,
   then ignite). Beat one: the pane clipped to its own 3px lit edge, rising
   from the bottom point -- the beam IS the border, growing. A held breath
   at full height, then beat two: the sweep right. The class rule's final
   clip matches the animation's last frame, so it lands without a snap. */
.pv-stats-on #pv-stats {
  clip-path: inset(0 0 0 0);
  pointer-events: auto;
  animation: pv-ignite 0.95s both;
}
@keyframes pv-ignite {
  0% {
    clip-path: inset(100% calc(100% - 3px) 0 0);
    animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);   /* the rise */
  }
  40% { clip-path: inset(0 calc(100% - 3px) 0 0); }             /* full height */
  50% {
    clip-path: inset(0 calc(100% - 3px) 0 0);                    /* charged */
    animation-timing-function: cubic-bezier(0.66, 0, 0.2, 1);   /* the sweep */
  }
  100% { clip-path: inset(0 0 0 0); }
}
/* The plate's frame: notched-corner border on its own layer, so the wipe's
   clip-path (which owns the element itself) reveals the frame along with
   everything inside it. Same hand as the identity/bio plates. */
#pv-stats::after {
  content: '';
  position: absolute;
  inset: 6px;
  border: 1px solid rgba(134, 255, 102, 0.45);
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
  pointer-events: none;
}

/* The info underneath dims as it is erased -- it is being written OVER. */
#pv-socials, #pv-bio, #pv-showcase { transition: opacity 0.4s ease-out 0.1s; }
.pv-stats-on #pv-socials,
.pv-stats-on #pv-bio,
.pv-stats-on #pv-showcase { opacity: 0; }
#pv-st-top {
  display: flex;
  align-items: baseline;
  gap: 12px;
  font-family: 'Courier New', monospace;
}
#pv-st-lvl-k { font-size: 12px; letter-spacing: 4px; color: rgba(134, 255, 102, 0.6); }
#pv-st-lvl {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(40px, calc(4 * var(--dvw, 1vw)), 62px);
  line-height: 1;
  font-weight: normal;
  color: #86ff66;
  text-shadow: 0 0 18px rgba(134, 255, 102, 0.55);
}
#pv-st-cert {
  padding: 3px 8px;
  font-size: 10px;
  letter-spacing: 2px;
  color: #1a1204;
  background: #ffd257;
  clip-path: polygon(7px 0, 100% 0, 100% 100%, 0 100%);
}
#pv-st-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 8px;
}
#pv-st-grid li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  padding: 10px 4px;
  background: rgba(134, 255, 102, 0.06);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.25);
  clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
#pv-st-grid b {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(16px, calc(1.6 * var(--dvw, 1vw)), 24px);
  line-height: 1;
  font-weight: normal;
  color: #eaffe3;
}
#pv-st-grid span {
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.55);
}
#pv-st-foot {
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  text-align: center;
  color: rgba(157, 255, 176, 0.5);
}
@media (prefers-reduced-motion: reduce) {
  #pv-stats { transition: none; animation: none; }
  .pv-stats-on #pv-stats { animation: none; }
  #pv-socials, #pv-bio, #pv-showcase { transition: none; }
}

/* ---------- SHOWCASE: music on the profile card ----------
   Three rows at most. Quiet furniture until a track is playing -- the card
   is somebody's face, not a media library. */
#pv-showcase {
  margin-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.sv-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  background: rgba(134, 255, 102, 0.05);
  border: 1px solid rgba(134, 255, 102, 0.18);
  transition: border-color 0.18s ease-out, background 0.18s ease-out;
}
.sv-row:hover { border-color: rgba(134, 255, 102, 0.5); }
.sv-row.sv-playing {
  border-color: #ffd257;
  background: rgba(255, 210, 87, 0.08);
}
.sv-play {
  flex: none;
  width: 26px;
  height: 26px;
  padding: 0;
  font-size: 10px;
  line-height: 1;
  color: #9dffb0;
  background: rgba(134, 255, 102, 0.1);
  border: 1px solid rgba(134, 255, 102, 0.35);
  cursor: pointer;
}
.sv-playing .sv-play { color: #ffd257; border-color: rgba(255, 210, 87, 0.6); }
.sv-title {
  flex: 1;
  min-width: 0;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 1px;
  color: #eaffe3;
  overflow: clip;
  overflow-clip-margin: 12px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sv-playing .sv-title { color: #ffd257; }
.sv-del {
  flex: none;
  width: 22px;
  height: 22px;
  padding: 0;
  font-size: 11px;
  line-height: 1;
  color: rgba(255, 120, 120, 0.7);
  background: transparent;
  border: 1px solid rgba(255, 120, 120, 0.3);
  cursor: pointer;
}
.sv-del:hover { color: #ff7878; border-color: #ff7878; }
/* The empty slot IS the door: dashed, and only the owner ever sees it. */
.sv-add {
  padding: 8px 10px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(157, 255, 176, 0.6);
  background: transparent;
  border: 1px dashed rgba(134, 255, 102, 0.35);
  cursor: pointer;
  transition: color 0.18s ease-out, border-color 0.18s ease-out;
}
.sv-add:hover { color: #9dffb0; border-color: rgba(134, 255, 102, 0.7); }
/* a file hovering over the slot: the door opens */
.sv-add.sv-dragging {
  color: #eaffe3;
  border-style: solid;
  border-color: #86ff66;
  background: rgba(134, 255, 102, 0.14);
  box-shadow: 0 0 18px rgba(134, 255, 102, 0.5) inset, 0 0 14px rgba(134, 255, 102, 0.4);
}

/* The editor's copy of the shelf: same rows, managed where your own things
   live. Width-capped to the form column so it reads as one of its sections.
   #prof-showcase-sec is now a .jp-panel like its neighbours -- its own
   margin override is gone so it picks up that class's spacing instead of
   sitting closer to the panel above it than the other panels do. */
#prof-showcase {
  margin-top: 10px;
  max-width: 720px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
/* BOTH shelves ride the app-wide hue drift, exactly like .jp-panel's frame
   and every jp-btn around them -- a fixed green here sat frozen while the
   rest of the profile cycled to cyan, which is why it never blended. The
   canvas waveform is inside, so it drifts with its own plate. */
#prof-showcase, #pv-showcase { filter: hue-rotate(var(--ui-hue, 0deg)); }
#prof-showcase .sv-row { padding: 13px 17px; gap: 16px; }
#prof-showcase .sv-title { font-size: 20px; letter-spacing: 1px; }
#prof-showcase .sv-play { width: 38px; height: 38px; font-size: 15px; }
#prof-showcase .sv-del { width: 30px; height: 30px; font-size: 16px; }
#prof-showcase .sv-add { padding: 15px 18px; font-size: 16px; letter-spacing: 3px; }
#prof-showcase .sv-vol-k { font-size: 15px; }
/* Bigger track (190->300px) and a real height -- a native range input's
   thumb scales with its own box height in every engine this project
   targets, so this alone makes the whole control chunkier, not just wider. */
#prof-showcase .sv-vol { width: 300px; height: 20px; }
#prof-showcase .sv-vol-n { font-size: 19px; }
/* The buttons already rotate themselves by --ui-hue; inside an already-
   rotated container that would double the drift, so zero out their copy. */
#prof-showcase .jp-btn, #pv-showcase .jp-btn { --ui-hue: 0deg; }

/* The trim stage: the drop-off zone's instrument in the editor's plate
   language. A real waveform on canvas; the CROP IS LIGHT -- everything
   outside the window is shaded down, the handles are neon posts. Buttons
   are the editor's own .jp-btn, so the stage belongs to the form around it. */
.sv-trim {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 14px;
  background: rgba(6, 18, 8, 0.55);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(134, 255, 102, 0.4);
  /* the house corner notch, same hand as the identity/bio plates */
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
}
.sv-trim-head {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 2px;
  color: #9dffb0;
}
.sv-trim-head b {
  font-weight: normal;
  color: #eaffe3;
  white-space: nowrap;
  text-shadow: 0 0 10px rgba(134, 255, 102, 0.5);
}
.sv-trim-name { overflow: clip; overflow-clip-margin: 10px; text-overflow: ellipsis; white-space: nowrap; }
.sv-trim-rail {
  position: relative;
  height: 92px;
  background: rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(134, 255, 102, 0.25);
  touch-action: none;
  overflow: hidden;
}
.sv-trim-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* What the crop cuts away: shaded, desaturated, clearly not going. */
.sv-shade {
  position: absolute;
  top: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.74);
  pointer-events: none;
}
.sv-shade-l { left: 0; }
.sv-trim-h {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 5px;
  margin-left: -2.5px;
  background: #86ff66;
  box-shadow: 0 0 12px rgba(134, 255, 102, 0.9);
  cursor: ew-resize;
  touch-action: none;
}
/* A fatter invisible grab area than the 5px post itself. */
.sv-trim-h::after { content: ''; position: absolute; inset: 0 -8px; }
/* ---- the drop-off-format stage's chrome ---- */
.sv-trim { position: relative; }
/* The X, top-right, same red square as the cargo card's eject. */
.sv-x {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  padding: 0;
  font-size: 12px;
  line-height: 1;
  color: #ff7878;
  background: rgba(120, 10, 10, 0.55);
  border: 1px solid rgba(255, 120, 120, 0.6);
  cursor: pointer;
  z-index: 2;
}
.sv-x:hover { background: rgba(200, 30, 30, 0.7); color: #ffd3d3; }
.sv-stage { margin-top: 8px; }
/* the crop ceiling, centre of the meta row -- the pad speaks gold here too */
.sv-max {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 3px;
  color: #ffd257;
  text-shadow: 0 0 10px rgba(255, 210, 87, 0.5);
}
/* Play + VOL row, the cargo pad's transport line. */
.sv-volrow {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: 'Courier New', monospace;
}
.sv-hear {
  width: 32px;
  height: 28px;
  padding: 0;
  font-size: 11px;
  line-height: 1;
  color: #86ff66;
  background: rgba(134, 255, 102, 0.1);
  border: 1px solid rgba(134, 255, 102, 0.45);
  cursor: pointer;
}
.sv-hear:hover { background: rgba(134, 255, 102, 0.25); }
/* breathing while its preview runs */
.sv-hear.sv-on {
  background: rgba(134, 255, 102, 0.3);
  animation: sv-hear-pulse 1.1s ease-in-out infinite;
}
@keyframes sv-hear-pulse {
  0%, 100% { box-shadow: 0 0 4px rgba(134, 255, 102, 0.4); }
  50%      { box-shadow: 0 0 14px rgba(134, 255, 102, 0.9); }
}
.sv-vol-k { font-size: 11px; letter-spacing: 3px; color: rgba(134, 255, 102, 0.6); }
.sv-vol { width: 150px; accent-color: #86ff66; }
.sv-vol-n { font-size: 12px; font-weight: normal; color: #eaffe3; min-width: 4ch; }
/* One big slab, centred -- the SUBMIT of this stage. */
.sv-trim-row { display: flex; justify-content: center; }
.sv-trim-row .jp-btn { flex: none; min-width: 46%; }

/* =========================================================================
   THE RECORD -- your stats
   =========================================================================

   A menu screen, not a game room: it opens off the home badge, so it wears
   the home chassis -- neon green under the global hue cycle -- rather than a
   mode's colours. The level plate is the badge's XP bar grown up; the grid
   is the Discord bot's /stats embed given walls. Everything here is read
   from the same rows the match-end pipeline writes. */

body:has(#screen-stats.active) { max-width: none; padding: 0; }

#screen-stats {
  --st-neon: #39ff14;
  --st-ink: #eaffe3;
  --st-dim: rgba(157, 255, 176, 0.55);
  --st-line: rgba(57, 255, 20, 0.28);
  --st-deep: 3, 12, 4;
}

#screen-stats.active {
  min-height: calc(100 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: clamp(88px, calc(10 * var(--dvh, 1vh)), 112px) clamp(20px, calc(5 * var(--dvw, 1vw)), 72px) 40px;
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(57, 255, 20, 0.1) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #071008 0%, #030704 58%, #010201 100%);
}

#st-head { text-align: center; flex: none; }
#st-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(44px, calc(5.6 * var(--dvw, 1vw)), 92px);
  line-height: 1;
  letter-spacing: 6px;
  color: var(--st-ink);
  overflow: clip;
  overflow-clip-margin: 72px;
  text-shadow: 0 0 18px rgba(120, 255, 140, 0.6), 0 0 60px rgba(57, 255, 20, 0.28);
}
.st-kata {
  font-style: normal;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 5px;
  color: var(--st-dim);
}
#st-sub {
  margin-top: 10px;
  font-family: 'Courier New', monospace;
  font-size: clamp(12px, calc(1.1 * var(--dvw, 1vw)), 16px);
  letter-spacing: 5px;
  color: rgba(234, 255, 227, 0.55);
}

#st-body {
  width: min(1060px, 100%);
  display: flex;
  flex-direction: column;
  gap: clamp(18px, calc(3 * var(--dvh, 1vh)), 34px);
  margin-top: clamp(20px, calc(4 * var(--dvh, 1vh)), 48px);
}

/* ---- the level plate ---- */
#st-level-plate {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: clamp(24px, calc(3.5 * var(--dvw, 1vw)), 56px);
  padding: clamp(22px, calc(3 * var(--dvh, 1vh)), 36px) clamp(26px, calc(3.5 * var(--dvw, 1vw)), 52px);
  background: linear-gradient(150deg, rgba(14, 34, 16, 0.92) 0%, rgba(5, 13, 6, 0.96) 100%);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--st-line);
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
}
#st-level-k {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 6px;
  color: var(--st-dim);
}
#st-level-n {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(84px, calc(9 * var(--dvw, 1vw)), 148px);
  line-height: 0.95;
  color: var(--st-neon);
  text-shadow: 0 0 22px rgba(57, 255, 20, 0.55), 0 0 70px rgba(57, 255, 20, 0.25);
}
/* GOLD / PLATINUM / DIAMOND -- only exists once certified. */
#st-cert {
  margin-top: 8px;
  padding: 4px 10px;
  display: inline-block;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: #1a1204;
  background: #ffd257;
  clip-path: polygon(8px 0, 100% 0, 100% 100%, 0 100%);
}
#st-level-right { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
#st-xp-row {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  font-family: 'Courier New', monospace;
  font-size: clamp(14px, calc(1.2 * var(--dvw, 1vw)), 18px);
  letter-spacing: 2px;
  color: var(--st-ink);
}
#st-xp-row #st-xp-need { color: var(--st-dim); }
#st-xp-bar {
  height: 14px;
  background: rgba(57, 255, 20, 0.12);
  border: 1px solid var(--st-line);
}
#st-xp-fill {
  display: block;
  height: 100%;
  width: 0;
  background: var(--st-neon);
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.8);
  /* Grows on arrival -- a bar that was suddenly always at 62% teaches
     nothing; one that moves there says "this is progress". */
  transition: width 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}

#st-creds-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(120, 255, 170, 0.14);
  font-family: 'Courier New', monospace;
}
#st-creds {
  font-size: clamp(26px, calc(2.6 * var(--dvw, 1vw)), 40px);
  line-height: 1;
  font-weight: normal;
  /* Deliberately NOT the plaque gold. Plaques are a score; creds are
     money, and the two must never be misread for each other at a glance. */
  color: #7dffb0;
  text-shadow: 0 0 16px rgba(125, 255, 176, 0.5);
}
/* Escrow tag -- rendered only while a wager is actually holding money. */
#st-creds-held {
  font-family: 'Courier New', monospace;
  font-style: normal;
  font-size: 11px;
  font-weight: bold;
  letter-spacing: 2px;
  color: #06120c;
  background: #7dffb0;
  border-radius: 3px;
  padding: 3px 7px;
  margin-left: auto;
  white-space: nowrap;
}

#st-wager-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  margin-top: 8px;
  font-family: 'Courier New', monospace;
}
#st-wager-state {
  font-size: 14px;
  letter-spacing: 2px;
  color: var(--st-dim);
}
/* Unlocked is the exception, so it is the only state that gets colour. */
#st-wager-state.open { color: #7dffb0; text-shadow: 0 0 12px rgba(125,255,176,.45); }

/* Season tag on the HUD counter. Small and dim on purpose -- it is context
   for the number, not a second number competing with it. */
#cred-hud-season {
  font-size: 9px;
  letter-spacing: 2px;
  color: #86ff66;
  opacity: 0.55;
  border-left: 1px solid rgba(57, 255, 20, 0.3);
  padding-left: 8px;
}
#cred-hud-season.hidden { display: none; }

/* ---- the record grid ---- */
#st-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: clamp(10px, calc(1.3 * var(--dvw, 1vw)), 18px);
}
.st-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: clamp(18px, calc(2.6 * var(--dvh, 1vh)), 30px) 10px;
  background: linear-gradient(150deg, rgba(14, 34, 16, 0.85) 0%, rgba(5, 13, 6, 0.94) 100%);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--st-line);
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
}
.st-cell b {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(30px, calc(3.2 * var(--dvw, 1vw)), 52px);
  line-height: 1;
  font-weight: normal;
  color: var(--st-ink);
  text-shadow: 0 0 14px rgba(120, 255, 140, 0.4);
}
.st-cell span {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 4px;
  color: var(--st-dim);
  text-align: center;
}

#st-foot {
  display: flex;
  justify-content: center;
  gap: clamp(20px, calc(4 * var(--dvw, 1vw)), 60px);
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 3px;
  color: rgba(234, 255, 227, 0.5);
}
#st-note {
  margin: 0;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 4px;
  color: var(--st-dim);
}

@media (max-width: 1000px) {
  #screen-stats #st-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
  #screen-stats #st-level-plate { grid-template-columns: 1fr; text-align: center; }
  #screen-stats #st-grid { grid-template-columns: repeat(2, 1fr); }
}


/* =========================================================================
   SAMPLE FLIP -- the drop-off zone
   ========================================================================= */

/* A twenty-minute window is a working session, not a handoff, so the clock
   goes up a size: it has to still register from across the room when you're
   head-down in a DAW on the other monitor. */
#screen-production.dz-sample #prod-timer {
  font-size: clamp(78px, calc(10.5 * var(--dvw, 1vw)), 152px);
  letter-spacing: 4px;
}
#screen-production.dz-sample #dz-clock { gap: 30px; }

/* ---- the chosen record ---- */
#prod-sample-box {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px 22px 16px;
  background: linear-gradient(150deg, rgba(10, 40, 68, 0.62) 0%, rgba(6, 20, 34, 0.72) 100%);
  /* THE CLIP EATS THE BORDER -- the canonical note, referenced from every
     other plate that had to do this. clip-path's mask runs exactly along the
     border's OUTER edge, so on the sides the polygon states as 100% (right,
     bottom, both diagonals) it shaves that edge and on the 0 sides (left,
     top) it covers the border whole. MEASURED, by rasterising the same box
     three ways and reading the pixels: 1px border alone renders 255/255
     left/right, the same border under this chamfer renders 255/127. Half
     the ink on one side and not the other -- and the whole GUI rides a
     fractional root zoom (swApplyZoom), so that border is already only
     0.49-0.89 DEVICE px before the mask halves it. At the wrong window size
     the right edge disappears outright and the plate reads as having no
     right side while the other three look fine.
     The fix is to paint the ring as an INSET box-shadow, which is background
     paint and therefore sits entirely INSIDE the mask: 255/254, all four
     edges equal. The border stays declared at 1px TRANSPARENT so nothing
     moves -- dropping it would resize every one of these plates by 2px.
     An OUTER box-shadow would be clipped away completely (see the glow
     rule); the transparent 1px is filled by the element's own background,
     which paints under the border box. */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 210, 255, 0.32);
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
}
#prod-sample-box.hidden { display: none; }
#ps-main {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 26px;
}
/* flex: 1 1 0 so the name column CLAIMS the row's spare width and can also
   shrink below its content -- as a content-sized item, a long filename
   pushed the whole row wider than the plate instead of ellipsizing. */
#ps-id { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
#ps-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, calc(2.2 * var(--dvw, 1vw)), 34px);
  line-height: 1.1;
  color: #eaf7ff;
  /* A big filename must ELLIPSIZE, not overflow: without nowrap a long
     single-token name (every bounce filename) couldn't wrap, ran past the
     card and got sheared at both edges -- worst in dz-stage-get where the
     font doubles. min-width:0 on #ps-id is what lets this actually bite. */
  max-width: 100%;
  white-space: nowrap;
  text-overflow: ellipsis;
  /* overflow: HIDDEN, never `clip` -- that swap is the whole bug, and it
     bites twice. A `clip` box is NOT a scroll container, and a box that is
     not a scroll container (a) is not obliged to honour text-overflow and
     (b) reports scrollWidth === clientWidth -- which is exactly what
     psFitName measures to decide whether to shrink the font. Lose both and
     a long bounce filename renders at the full 62px, runs the width of the
     WINDOW, and the plate's border slices straight through it. `hidden` is
     a scroll container in every engine, so the fit measures and the
     ellipsis lands.
     The glow is still kept off the clip edge the old way -- padding out,
     negative margin back in, layout unchanged -- but the blur is stated in
     em so it stays INSIDE that padding at every stage's font size; the old
     fixed 16px was wider than the padding the moment the drop stage took
     the name down to 17px, which is the shear the clip-margin was there to
     hide. */
  overflow: hidden;
  padding: 0.35em 0.3em;
  margin: -0.35em -0.3em;
  text-shadow: 0 0 0.26em rgba(120, 210, 255, 0.45);
}
/* The credit travels with the record: whoever made it should be named on
   the screen where you actually flip it, not only in the crate. */
#ps-cred { display: flex; align-items: center; gap: 8px; }
#ps-cred-av {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 13px;
  color: #052033;
  background: #6ad1ff;
}
#ps-cred-handle {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: #8fd8ff;
}
/* Uncredited: neutral, never dressed as a named person. */
.ps-uncredited #ps-cred-av { background: rgba(220, 235, 245, 0.3); color: #10181f; }
.ps-uncredited #ps-cred-handle { color: rgba(225, 240, 250, 0.5); font-style: italic; }

#ps-meta { display: flex; gap: 22px; font-family: 'Courier New', monospace; }
.ps-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.ps-cell b { font-size: 22px; font-weight: normal; color: #eaf7ff; }
.ps-cell i { font-style: normal; font-size: 9px; letter-spacing: 3px; color: rgba(150, 215, 255, 0.5); }

#ps-actions { display: flex; align-items: center; gap: 16px; margin-left: auto; flex-wrap: wrap; }
/* DOWNLOAD is the first thing you do here -- you can't flip what you
   haven't got -- so it's a plate, not a text link. */
#ps-dl {
  display: inline-block;
  padding: 12px 26px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 4px;
  text-decoration: none;
  color: #052033;
  background: #6ad1ff;
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
  transition:
    transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
    filter 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
    background 0.2s ease-out;
}
#ps-dl:hover {
  transform: translate(-4px, -4px) scale(1.04);
  background: #a2e4ff;
  /* drop-shadow, not box-shadow: the clip-path would clip a box-shadow. */
  filter:
    drop-shadow(3px 3px 0 rgba(3, 16, 26, 0.98))
    drop-shadow(3px 3px 0 rgba(3, 16, 26, 0.86))
    drop-shadow(3px 3px 0 rgba(3, 16, 26, 0.7))
    drop-shadow(3px 3px 0 rgba(3, 16, 26, 0.5))
    drop-shadow(0 0 24px rgba(106, 209, 255, 0.6));
}
#ps-dl:active { transform: none; transition-duration: 0.06s; }

@media (max-width: 900px) {
  #ps-actions { margin-left: 0; }
  #screen-production.dz-sample #prod-timer { font-size: clamp(56px, calc(13 * var(--dvw, 1vw)), 92px); }
}
@media (prefers-reduced-motion: reduce) {
  #ps-dl { transition: none; }
}



/* =========================================================================
   DROP OFF ZONE, Sample Flip: two stages
   =========================================================================
   You can't flip what you haven't got, so the screen makes you take the
   record before it shows you anywhere to put a track. Stage one is the
   sample, big and alone. Stage two is a clean drop pad under a massive
   clock -- which is all the screen has to say once you're actually working. */

/* ---- stage one: take the record ---- */
#screen-production.dz-stage-get #dz-pad { display: none; }
#screen-production.dz-stage-get #prod-sample-box {
  margin: auto;
  max-width: 760px;
  width: 100%;
  align-items: center;
  text-align: center;
  gap: 18px;
  padding: 40px 46px 44px;
}
/* width: 100% down the WHOLE chain, deliberately: a centered column sizes
   its children to their own content, so without these the name's
   max-width/ellipsis had nothing real to measure against -- #ps-main took
   the name's width, poked out of the plate symmetrically on both sides,
   and the border sliced through it ("the panel is bugged"). */
#screen-production.dz-stage-get #ps-main {
  flex-direction: column;
  align-items: center;
  gap: 20px;
  width: 100%;
  min-width: 0;
}
#screen-production.dz-stage-get #ps-id { align-items: center; width: 100%; }
#screen-production.dz-stage-get #ps-name { font-size: clamp(34px, calc(4.4 * var(--dvw, 1vw)), 62px); }
#screen-production.dz-stage-get #ps-actions { margin-left: 0; flex-direction: column; gap: 18px; }
#screen-production.dz-stage-get #ps-dl {
  padding: 20px 56px;
  font-size: clamp(20px, calc(2.2 * var(--dvw, 1vw)), 30px);
  letter-spacing: 6px;
  /* The one thing to do on this screen. It breathes so the eye lands on it
     without anything else having to shout. */
  animation: ps-dl-breathe 2.4s ease-in-out infinite;
}
@keyframes ps-dl-breathe {
  0%, 100% { box-shadow: 0 0 0 rgba(106, 209, 255, 0); }
  50%      { box-shadow: 0 0 34px rgba(106, 209, 255, 0.5); }
}
/* The instruction, only in stage one. */

/* ---- stage two: the pad, and the clock ----
   The plate collapses to a single line at the top: the record is no longer
   the job, but losing the file and having no way back would be worse than
   the clutter of one strip. */
#screen-production.dz-stage-drop #prod-sample-box {
  flex-direction: row;
  align-items: center;
  gap: 16px;
  padding: 8px 18px;
  align-self: flex-start;
}
#screen-production.dz-stage-drop #ps-main { gap: 16px; align-items: center; }
#screen-production.dz-stage-drop #ps-id { flex-direction: row; align-items: center; gap: 12px; }
#screen-production.dz-stage-drop #ps-name { font-size: 17px; }
#screen-production.dz-stage-drop #ps-meta { display: none; }
#screen-production.dz-stage-drop #ps-player { display: none; }
#screen-production.dz-stage-drop #ps-dl {
  padding: 6px 14px;
  font-size: 12px;
  letter-spacing: 2px;
  animation: none;
}
/* The handoff: the big plate shrinks into the strip rather than cutting. */
#prod-sample-box {
  transition:
    padding 0.42s cubic-bezier(0.16, 1, 0.3, 1),
    max-width 0.42s cubic-bezier(0.16, 1, 0.3, 1),
    gap 0.3s ease-out;
}
/* ...and the pad arrives under it. */
#screen-production.dz-stage-drop #dz-pad {
  animation: dz-pad-arrive 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
  animation-delay: 0.12s;
}
@keyframes dz-pad-arrive {
  from { opacity: 0; transform: translateY(26px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  #screen-production.dz-stage-get #ps-dl { animation: none; }
  #screen-production.dz-stage-drop #dz-pad { animation: none; }
  #prod-sample-box { transition: none; }
}


/* ---- the sample's transport ----
   Built, not <audio controls>. The native widget carries its own rounded
   chrome and system font and was the only thing on this screen that looked
   like a web page rather than a machine. Same layout as the crate's deck, in
   the dock's blue, over the same peaks the crate cards draw. */
#ps-player {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: min(460px, 100%);
}
#ps-player audio { display: none; }   /* the element is the engine, not the UI */
#ps-wave {
  display: block;
  width: 100%;
  height: 52px;
}
#ps-transport {
  display: flex;
  align-items: center;
  gap: 12px;
  font-family: 'Courier New', monospace;
}
#ps-play {
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  font-size: 15px;
  line-height: 1;
  color: #6ad1ff;
  background: rgba(106, 209, 255, 0.1);
  border: 1px solid rgba(106, 209, 255, 0.45);
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.18s ease-out;
}
#ps-play:hover { transform: translate(-2px, -2px) scale(1.06); background: rgba(106, 209, 255, 0.22); }
#ps-play:active { transform: none; transition-duration: 0.06s; }
#ps-seek {
  flex: 1;
  position: relative;
  height: 20px;            /* generous hit area; the rail itself is 4px */
  display: flex;
  align-items: center;
  cursor: pointer;
  touch-action: none;      /* pointer-capture drag, not a page scroll */
}
#ps-seek::before {
  content: '';
  position: absolute;
  left: 0; right: 0;
  height: 4px;
  background: rgba(106, 209, 255, 0.16);
}
#ps-seek-fill {
  position: absolute;
  left: 0;
  height: 4px;
  background: #6ad1ff;
  box-shadow: 0 0 10px rgba(106, 209, 255, 0.7);
}
#ps-seek-head {
  position: absolute;
  top: 50%;
  width: 3px;
  height: 16px;
  margin-top: -8px;
  margin-left: -1.5px;
  background: #eaf7ff;
  box-shadow: 0 0 10px rgba(180, 232, 255, 0.9);
}
#ps-time { flex: none; font-size: 11px; letter-spacing: 1px; color: rgba(150, 215, 255, 0.7); }

/* Stage two keeps the strip minimal -- the transport is for auditioning the
   record before you take it, and by then you have the file. */
#screen-production.dz-stage-drop #ps-player { display: none; }

@media (prefers-reduced-motion: reduce) {
  #ps-play { transition: none; }
}




/* ---- gamemode swap ----
   SELECT MODE changes the headline, and the headline is the only thing that
   moves. The old title compresses out horizontally, the new one springs open
   from the pinch -- the text is swapped at the narrowest frame, so the two
   names are never both legible.

   transform / opacity / filter ONLY. The panel this replaced animated
   max-height and padding, which are layout properties: every frame reflowed
   the whole menu column, and the panel shoved everything below it down the
   screen for as long as it was open. Nothing here can trigger a reflow, so
   it runs on the compositor and the rest of the menu never moves. */
#mp-head-main { transform-origin: 0% 50%; }
.mp-title-swap { animation: mp-title-swap 0.54s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes mp-title-swap {
  0% {
    transform: scaleX(1) skewX(0deg);
    opacity: 1;
    filter: blur(0) brightness(1);
  }
  /* the pinch -- squeezed to a slit, where the text is exchanged */
  30% {
    transform: scaleX(0.04) skewX(-14deg);
    opacity: 0.55;
    filter: blur(1.5px) brightness(2.4);
  }
  /* ...and it snaps back out, overshooting once */
  62% {
    transform: scaleX(1.06) skewX(4deg);
    opacity: 1;
    filter: blur(0) brightness(1.5);
  }
  100% {
    transform: scaleX(1) skewX(0deg);
    opacity: 1;
    filter: blur(0) brightness(1);
  }
}
/* The katakana runs the same beat a touch behind, so the two lines read as
   one object being re-cut rather than two separate effects. */
.mp-title-swap ~ #mp-mode-kata,
#mp-head:has(.mp-title-swap) #mp-mode-kata {
  animation: mp-kata-swap 0.54s cubic-bezier(0.16, 1, 0.3, 1) 0.05s;
}
@keyframes mp-kata-swap {
  0%, 100% { opacity: 1; transform: translateX(0); }
  30%      { opacity: 0; transform: translateX(-14px); }
  62%      { opacity: 1; transform: translateX(3px); }
}

@media (prefers-reduced-motion: reduce) {
  .mp-title-swap,
  #mp-head:has(.mp-title-swap) #mp-mode-kata { animation: none; }
}


/* =========================================================================
   MODE PICKER
   =========================================================================
   Click CHOOSE YOUR MODE and the tiles come up over the menu. A fixed
   overlay, deliberately: the drop-down this replaced sat in normal flow and
   shoved the whole column down the screen every time it opened. Nothing
   behind `position: fixed` can move, however big this panel gets. */
#mp-modal {
  position: fixed;
  inset: 0;
  z-index: 720;          /* over the CRT glass (700), under the glitch burst */
  display: grid;
  place-items: center;
  /* .hidden is `display:none !important` and would kill the animation dead,
     so it is only applied AFTER the close finishes (see mpModalClose). */
}
#mp-modal.hidden { display: none; }
#mp-modal-scrim {
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 90% 80% at 50% 45%, rgba(2, 10, 4, 0.86) 0%, rgba(0, 0, 0, 0.94) 100%);
  opacity: 0;
  transition: opacity 0.22s ease-out;
}
#mp-modal.mp-modal-in #mp-modal-scrim { opacity: 1; }

#mp-modal-box {
  position: relative;
  width: min(1180px, calc(92 * var(--dvw, 1vw)));
  max-height: calc(86 * var(--dvh, 1vh));
  overflow-y: auto;
  padding: 30px clamp(22px, calc(3 * var(--dvw, 1vw)), 46px) 26px;
  background: linear-gradient(165deg, rgba(10, 24, 12, 0.96) 0%, rgba(3, 9, 4, 0.98) 100%);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--mp-accent-dim);
  clip-path: polygon(0 0, calc(100% - 26px) 0, 100% 26px, 100% 100%, 26px 100%, 0 calc(100% - 26px));
  /* transform + opacity only -- the compositor does the whole thing. */
  opacity: 0;
  transform: translateY(18px) scale(0.97);
  transition:
    opacity 0.2s ease-out,
    transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
#mp-modal.mp-modal-in #mp-modal-box { opacity: 1; transform: none; }

#mp-modal-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding-bottom: 16px;
  margin-bottom: 22px;
  border-bottom: 1px solid rgba(120, 255, 160, 0.16);
}
#mp-modal-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: clamp(24px, calc(2.6 * var(--dvw, 1vw)), 40px);
  letter-spacing: 5px;
  color: var(--mp-accent);
  text-shadow: 0 0 14px var(--mp-accent-soft);
}
.mp-modal-kata {
  font-family: 'Courier New', monospace;
  font-size: clamp(12px, calc(1 * var(--dvw, 1vw)), 16px);
  letter-spacing: 5px;
  color: #5a8f50;
}
#mp-modal-close {
  flex: none;
  width: 40px;
  height: 40px;
  padding: 0;
  font-size: 16px;
  line-height: 1;
  color: #ff8f8f;
  background: rgba(255, 90, 90, 0.08);
  border: 1px solid rgba(255, 90, 90, 0.4);
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.18s ease-out;
}
#mp-modal-close:hover { transform: translate(-2px, -2px) scale(1.08); background: rgba(255, 90, 90, 0.2); }
#mp-modal-close:active { transform: none; transition-duration: 0.06s; }

/* ---- the tiles ---- */
#mp-modal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(clamp(240px, calc(26 * var(--dvw, 1vw)), 340px), 1fr));
  gap: clamp(14px, calc(1.8 * var(--dvw, 1vw)), 24px);
}
.mp-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding: 0 0 20px;
  text-align: left;
  background: linear-gradient(165deg, rgba(18, 34, 20, 0.9) 0%, rgba(7, 14, 8, 0.95) 100%);
  border: 1px solid rgba(120, 255, 160, 0.22);
  cursor: pointer;
  overflow: hidden;
  clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
  /* Staggered arrival, so the tiles deal in rather than all appearing. */
  animation: mp-tile-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards;
  animation-delay: calc(0.06s + var(--tile-i, 0) * 70ms);
  transition:
    transform 0.26s cubic-bezier(0.34, 1.56, 0.64, 1),
    border-color 0.2s ease-out,
    filter 0.26s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes mp-tile-in {
  from { opacity: 0; transform: translateY(20px) scale(0.96); }
  to   { opacity: 1; transform: none; }
}
/* The house hover: springy pop with the depth as a stack of hard shadows.
   drop-shadow, not box-shadow -- the clip-path would clip a box-shadow. */
.mp-tile:hover,
.mp-tile:focus-visible {
  transform: translate(-5px, -5px) scale(1.03);
  border-color: var(--mp-accent);
  outline: none;
  filter:
    drop-shadow(3px 3px 0 rgba(2, 12, 5, 0.98))
    drop-shadow(3px 3px 0 rgba(2, 12, 5, 0.88))
    drop-shadow(3px 3px 0 rgba(2, 12, 5, 0.74))
    drop-shadow(3px 3px 0 rgba(2, 12, 5, 0.54))
    drop-shadow(0 0 24px rgba(120, 255, 160, 0.42));
}
.mp-tile:active { transform: translate(0, 0) scale(0.985); transition-duration: 0.07s; }

/* Art panel: a glyph over the circuit grid. Stands in for real mode art;
   swap the background for a still whenever there is one. */
.mp-tile-art {
  display: grid;
  place-items: center;
  height: clamp(96px, calc(11 * var(--dvw, 1vw)), 140px);
  margin-bottom: 16px;
  background:
    repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    linear-gradient(150deg, rgba(24, 60, 34, 0.75) 0%, rgba(6, 16, 9, 0.9) 100%);
  border-bottom: 1px solid rgba(120, 255, 160, 0.18);
}
.mp-tile-art i {
  font-style: normal;
  font-size: clamp(38px, calc(4.4 * var(--dvw, 1vw)), 60px);
  line-height: 1;
  color: var(--mp-accent);
  text-shadow: 0 0 20px var(--mp-accent-soft), 0 0 60px var(--mp-accent-dim);
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mp-tile:hover .mp-tile-art i { transform: scale(1.14) rotate(8deg); }

.mp-tile-body { display: flex; flex-direction: column; gap: 5px; padding: 0 18px; min-width: 0; }
.mp-tile-label {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(19px, calc(1.7 * var(--dvw, 1vw)), 27px);
  letter-spacing: 3px;
  color: #eaffe3;
  /* clip + margin, not hidden: hidden squares the glow off at the text edge */
  overflow: clip;
  overflow-clip-margin: 30px;
  white-space: nowrap;
  text-overflow: ellipsis;
  text-shadow: 0 0 14px rgba(120, 255, 160, 0.3);
}
.mp-tile-kata {
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 4px;
  color: #5a8f50;
}
.mp-tile-blurb {
  margin-top: 6px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  line-height: 1.6;
  letter-spacing: 1.5px;
  color: rgba(215, 240, 220, 0.6);
}

/* The one you're on. */
.mp-tile-flag {
  position: absolute;
  top: 0;
  right: 0;
  padding: 5px 12px 6px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 3px;
  color: #06210f;
  background: var(--mp-accent);
  clip-path: polygon(12px 0, 100% 0, 100% 100%, 0 100%);
  opacity: 0;
  transform: translateY(-100%);
  transition: opacity 0.2s ease-out, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mp-tile-on .mp-tile-flag { opacity: 1; transform: none; }
.mp-tile-on {
  border-color: var(--mp-accent);
  background: linear-gradient(165deg, rgba(30, 66, 38, 0.92) 0%, rgba(9, 20, 11, 0.96) 100%);
}
.mp-tile-on .mp-tile-art { background:
  repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
  repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
  linear-gradient(150deg, rgba(38, 90, 50, 0.8) 0%, rgba(8, 22, 12, 0.92) 100%); }

#mp-modal-foot {
  margin-top: 20px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 4px;
  color: rgba(160, 210, 170, 0.3);
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  #mp-modal-box, #mp-modal-scrim { transition: opacity 0.2s ease-out; transform: none; }
  .mp-tile { animation: none; }
  .mp-tile:hover, .mp-tile:focus-visible { transform: none; }
  .mp-tile:hover .mp-tile-art i { transform: none; }
  .mp-tile-flag { transition: opacity 0.2s ease-out; transform: none; }
}


/* =========================================================================
   MENU HIERARCHY
   =========================================================================
   Three ranks, so the eye lands on the thing you actually came here to do:

     QUICK PLAY          neon green, glowing        -- the action
     CHOOSE YOUR MODE    muted, no glow             -- supporting
     SERVER BROWSER      muted, no glow             -- supporting
     LEAVE               red, no glow               -- the way out

   Every one of them lights to full green on hover, so nothing reads as
   disabled -- they're quiet, not dead. */

/* Supporting actions: readable, but deliberately not competing with QUICK
   PLAY. A grey-green rather than a dark green, so it reads as "secondary"
   and not as "greyed out because it doesn't work". */
.mp-item-2nd .mp-item-label {
  color: #86a381;
  text-shadow: none;
}
/* ...and they come up to full strength under the cursor. The .mp-item:hover
   rule already paints the green and the extrusion; this only has to not get
   in its way, which it doesn't -- :hover is a whole specificity level above
   the resting rule above it. */

/* LEAVE: red the whole way through.
   .mp-item-label sets a GREEN text-shadow on every item, and the danger
   rule below it only ever overrode `color` -- so LEAVE sat there as red
   lettering wearing a green halo. The glow has to be overridden too, not
   just the ink. */
#btn-mp-leave .mp-item-label,
.mp-item-danger .mp-item-label {
  text-shadow: none;
}


/* =========================================================================
   GAMEMODE BANNER
   =========================================================================
   The gamemode is what the whole screen is about, so it stops being loose
   text and becomes a plate you could hang on a wall: hazard rail down the
   left edge, angular slab behind the name, slot number above it, tagline as
   a chip below, and a light that rakes across the whole thing.

   HARD CONSTRAINT: the banner's height must not depend on WHICH mode is
   showing. Everything here is fixed padding and a nowrap title, because a
   title that grows by a line shoves the entire menu underneath it down the
   screen -- which is exactly the bug the drop-down had. */

#mp-head-main {
  display: flex;
  align-items: stretch;
  gap: 0;
  /* The swap animation squeezes this whole group horizontally; anchoring at
     the left edge means the rail stays put while the plate concertinas. */
  transform-origin: 0% 50%;
}

/* ---- hazard rail ----
   Diagonal accent stripes down the left edge. Reads as machine signage from
   across the room and costs one gradient. */
#mp-head-rail {
  flex: none;
  width: 16px;
  background:
    repeating-linear-gradient(-45deg,
      var(--mp-accent) 0 6px,
      transparent 6px 13px);
  opacity: 0.85;
  box-shadow: 0 0 18px var(--mp-accent-dim);
}

/* ---- the plate ---- */
#mp-head-plate {
  position: relative;
  flex: 1;
  min-width: 0;
  padding: 12px clamp(20px, calc(2.4 * var(--dvw, 1vw)), 38px) 14px;
  background:
    /* faint scanlines, so the slab reads as a screen and not a card */
    repeating-linear-gradient(0deg, transparent 0 3px, rgba(0, 0, 0, 0.34) 3px 4px),
    linear-gradient(115deg,
      rgba(255, 255, 255, 0.055) 0%,
      rgba(255, 255, 255, 0.012) 42%,
      transparent 72%),
    linear-gradient(160deg, rgba(10, 14, 10, 0.94) 0%, rgba(3, 5, 3, 0.97) 100%);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--mp-accent-dim);
  border-left: none;
  /* Notched top-right and bottom-left -- the opposite pair to the rail, so
     the silhouette reads as one object with a cut corner rather than a box. */
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 0 100%);
  /* clip-path clips a box-shadow away entirely, so the plate's own glow is a
     drop-shadow on the element instead. */
  filter: drop-shadow(0 0 26px rgba(0, 0, 0, 0.8));
  overflow: hidden;
}

/* A light rakes across the plate on a slow loop. Same recipe as the corner
   buttons' hover sweep, just always running and much slower. */
#mp-head-sweep {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(105deg,
    transparent 40%,
    rgba(255, 255, 255, 0.07) 50%,
    transparent 60%);
  transform: translateX(-130%);
  animation: mp-head-sweep 7s ease-in-out infinite;
}
@keyframes mp-head-sweep {
  0%, 62% { transform: translateX(-130%); }
  100%    { transform: translateX(130%); }
}

/* ---- slot number ---- */
#mp-head-idx {
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 6px;
  line-height: 1;
  color: var(--mp-accent-soft);
  opacity: 0.7;
  margin-bottom: 8px;
}
#mp-head-idx b {
  font-weight: normal;
  color: var(--mp-accent);
  text-shadow: 0 0 10px var(--mp-accent-dim);
}

/* ---- the name ----
   Hard extrusion rather than only a glow: the neon alone had no weight at
   this size, and weight is what makes it read as a title instead of a label.
   The steps are the house language (same ramp as the menu items' hover), but
   permanent -- this element IS the emphasis, it doesn't wait to be pointed
   at. */
#mp-mode-name {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: clamp(38px, calc(4.4 * var(--dvw, 1vw)), 82px);
  letter-spacing: 6px;
  line-height: 1.02;
  white-space: nowrap;
  overflow: clip;
  overflow-clip-margin: 52px;
  color: #fff;
  text-shadow:
    2px 2px 0 var(--mp-accent-deep),
    4px 4px 0 var(--mp-accent-deeper),
    0 0 14px var(--mp-accent-soft),
    0 0 44px var(--mp-accent-dim);
}

/* ---- tagline, as a chip ---- */
#mp-mode-tagline {
  display: inline-block;
  margin-top: 12px;
  padding: 9px 20px 10px;
  font-family: 'Courier New', monospace;
  /* Was clamp(11px, calc(.95 * var(--dvw, 1vw)), 15px) with 5px of tracking, in dark ink on a
     saturated plate -- small, widely spaced and low contrast all at once,
     which is three different reasons to be unreadable. This is also where the
     LOBBY NAME lands once you are in one (see the WAITING case), so it is a
     real label rather than a decorative tag. */
  font-size: clamp(15px, calc(1.35 * var(--dvw, 1vw)), 22px);
  font-weight: bold;
  /* Tracking scales with the type now instead of staying at a fixed 5px,
     which at 11px was pushing the letters further apart than they were tall. */
  letter-spacing: 0.16em;
  line-height: 1;
  /* Near-black rather than the dark GREEN it was: on the producer accent
     (red) a green-black ink muddied every stroke. */
  color: #0a0a0a;
  background: var(--mp-accent);
  clip-path: polygon(0 0, calc(100% - 11px) 0, 100% 11px, 100% 100%, 11px 100%, 0 calc(100% - 11px));
  max-width: 100%;
  /* clip, not hidden -- see #cr-title. A long server name truncates instead
     of running out past the banner. */
  overflow: clip;
  overflow-clip-margin: 8px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Katakana rides the slot row, right-aligned. It used to be absolutely
   positioned at the header's right edge -- which the plate now occupies, so
   it was sitting on top of the title. Static and in the flow, it simply
   can't collide with anything. */
#mp-head-idx {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
}
#screen-mpmenu #mp-mode-kata {
  position: static;
  flex: none;
  font-size: clamp(11px, calc(0.95 * var(--dvw, 1vw)), 15px);
  letter-spacing: 5px;
  color: var(--mp-accent-soft);
  opacity: 0.75;
  white-space: nowrap;
}

/* Accent ramp for the extrusion, per top-level mode. Producer runs red,
   artist green -- the banner just reads whichever is set. */
#screen-mpmenu.mp-producer { --mp-accent-deep: rgba(120, 10, 10, 0.95); --mp-accent-deeper: rgba(70, 4, 4, 0.85); }
#screen-mpmenu.mp-artist   { --mp-accent-deep: rgba(18, 110, 8, 0.95);  --mp-accent-deeper: rgba(10, 62, 4, 0.85); }

@media (max-height: 540px) {
  #screen-mpmenu #mp-head-plate { padding-top: 9px; padding-bottom: 10px; }
  #screen-mpmenu #mp-head-idx { margin-bottom: 5px; }
  #screen-mpmenu #mp-mode-tagline { margin-top: 7px; }
  #screen-mpmenu #mp-head-rail { width: 12px; }
}
@media (prefers-reduced-motion: reduce) {
  #mp-head-sweep { animation: none; opacity: 0; }
}



/* The room dims and the shelf stops taking clicks -- the vote is over, and
   a click that looks live but does nothing is worse than no click.
   THE BOOTH IS LITERALLY THIS ROOM now (both votes stand in #screen-crate),
   so the crate's ids are the only ids there are to name. */
body.cr-tie-live #cr-shelf { pointer-events: none; }
body.cr-tie-live #cr-tie-scrim { display: block; }
body.cr-tie-live #cr-top { transition: opacity 0.5s ease-out; }


/* The hit: one ring leaves the wheel. */
#cr-tie-shock {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 10px;
  height: 10px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  border: 2px solid var(--cr-green);
  opacity: 0;
  pointer-events: none;
}
#cr-tie-shock.cr-shock-go { animation: cr-shock 0.85s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes cr-shock {
  0%   { opacity: 0.9; transform: scale(1); border-width: 3px; }
  100% { opacity: 0; transform: scale(52); border-width: 0.5px; }
}

/* ---- the word ----
   Beat one it owns the screen; from beat two it steps up to the top so the
   wheel has the middle. */
/* #cr-tie ITSELF MUST STAY UNPOSITIONED, and that is load bearing now that
   the machine is body-level. Its absolutely-positioned children -- the
   roll, the banner, the shockwave -- resolve against the initial containing
   block, which IS the viewport: exactly the box they want, and the box they
   used to get from the crate stage. Giving it position: fixed; inset: 0
   looks tidier and breaks it, because a fixed element makes its own
   stacking context: the banner s z-index 10 would then stack inside the tie
   rather than over the page, landing it BEHIND the z-index 5 scrim. */
#cr-tie { position: static; }

/* THE MACHINE CARRIES ITS OWN PALETTE now that it is body-level. It used
   to sit inside #screen-crate and inherit --cr-green / --cr-amber from
   it; out here those resolve to nothing, and an undefined var inside a
   gradient invalidates the WHOLE declaration -- which is why THE MARKER
   LINE simply stopped being painted on the beat vote (owner report),
   while the tiles, whose colour is an inline --seg-ink, looked fine. The
   values are the crate's, and deliberately NOT scoped to a room: the
   roll looks the same wherever it runs, which is the whole point of
   there being one of it. */
#cr-tie,
#cr-tie-scrim {
  --cr-green: #86ffc2;
  --cr-green-dim: rgba(134, 255, 194, 0.5);
  --cr-amber: #ffc14d;
  --cr-ink: #eaffe3;
  --cr-deep: 3, 12, 8;
}

#cr-tie-banner {
  position: absolute;
  left: 50%;
  top: clamp(6px, calc(2.4 * var(--dvh, 1vh)), 28px);
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  text-align: center;
  white-space: nowrap;
  z-index: 10;      /* over the cards -- it is the thing being announced */
  transition: top 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
#cr-tie.cr-tie-announce #cr-tie-banner {
  top: 50%;
  transform: translate(-50%, -50%) scale(1.32);
}
#cr-tie-headline { display: flex; align-items: baseline; gap: 14px; }
#cr-tie-word {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(30px, calc(4 * var(--dvw, 1vw)), 66px);
  letter-spacing: 8px;
  color: var(--cr-amber);
  /* clip + margin, never hidden -- hidden squares the glow off flat. */
  overflow: clip;
  overflow-clip-margin: 70px;
  text-shadow: 0 0 24px rgba(255, 193, 77, 0.75), 0 0 80px rgba(255, 150, 40, 0.45);
  animation: cr-tie-pulse 1.3s ease-in-out infinite;
}
#cr-tie-sub {
  font-family: 'Courier New', monospace;
  font-size: clamp(11px, calc(1 * var(--dvw, 1vw)), 15px);
  letter-spacing: 6px;
  color: rgba(255, 208, 140, 0.6);
}
body.cr-tie-done #cr-tie-word {
  color: var(--cr-green);
  text-shadow: 0 0 24px rgba(134, 255, 194, 0.85), 0 0 80px rgba(80, 255, 170, 0.5);
  animation: none;
}
body.cr-tie-done #cr-tie-sub { color: rgba(134, 255, 194, 0.6); }
@keyframes cr-tie-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.6; }
}
#cr-tie-kata {
  font-family: 'Courier New', monospace;
  font-size: clamp(12px, calc(1.1 * var(--dvw, 1vw)), 17px);
  letter-spacing: 6px;
  color: rgba(255, 208, 140, 0.5);
}

@media (prefers-reduced-motion: reduce) {
  /* The needle still turns -- it is the thing that tells you who won, and
     removing it would remove the information. Everything else stops. */
  #cr-tie-word { animation: none; }
  #cr-tie-shock.cr-shock-go { animation: none; }
}


/* ---- the room goes out ----
   During the announcement the crate has to stop competing: six cards, six
   waveforms and a running clock behind DEAD HEAT is exactly as busy as it
   looks. This kills all of it.

   `fixed`, not `absolute`: #cr-tie only spans the stage, and the header, the
   timer and the pick counter live outside it. Fixed anchors to the viewport
   instead, so the whole screen goes down together. (Safe here -- nothing
   between this and the viewport carries a transform once the screen-enter
   animation has finished, and that runs long before any tie.) */
#cr-tie-scrim {
  /* Below the tied cards (z-index 8) so they stay readable, above every
     other card so they do not. */
  z-index: 5;
  display: none;
  position: fixed;
  /* OVERSCANNED, not pinned to 0. This is a blackout: if the box comes up
     even a few pixels short for any reason -- a scrollbar appearing, stale
     viewport metrics, a transformed ancestor re-anchoring it mid-transition
     -- the failure mode is a bright unblurred strip down one edge, which is
     far worse than bleeding harmlessly off-screen. Cannot cause a
     scrollbar: fixed elements are out of flow and never contribute to the
     document's scrollable overflow.
     SIZED BY --vpw/--vph, NOT --dvw/--dvh: this is a surface that must
     COVER THE WINDOW, and the design-grid units are FIXED at 2560x1440 by
     doctrine -- sized off them, the blackout stopped at the grid's right
     edge and every wider-than-16:9 window (any windowed run with a
     taskbar) kept a bright unblurred strip of wheel past it. Same lesson,
     same fix as the GANG-OS glass; the old "measured 15px narrow" mystery
     in this comment was this bug at a milder aspect. */
  inset: -24px;
  width: calc(var(--vpw, 100vw) + 48px);
  height: calc(var(--vph, 100vh) + 48px);
  pointer-events: none;
  background: radial-gradient(ellipse 80% 70% at 50% 50%,
    rgba(2, 4, 2, 0.82) 0%,
    rgba(0, 0, 0, 0.93) 60%,
    rgba(0, 0, 0, 0.97) 100%);
  /* Blur what's left, so the cards read as texture rather than as six things
     you could still be trying to choose between. */
  backdrop-filter: blur(7px) saturate(0.5);
  -webkit-backdrop-filter: blur(7px) saturate(0.5);
  opacity: 0;
  transition: opacity 0.55s ease-out, backdrop-filter 0.55s ease-out;
}
/* Beat one: full black-out. */
body.cr-tie-announce #cr-tie-scrim { opacity: 1; }
/* Beats two and three: eased back, because now there are two cards on the
   wheel that DO need to be readable -- they sit above this at z-index 8. */
body.cr-tie-live:not(.cr-tie-announce) #cr-tie-scrim {
  opacity: 0.94;
  backdrop-filter: blur(3px) saturate(0.7);
  -webkit-backdrop-filter: blur(3px) saturate(0.7);
}

/* With the scrim doing the work, the per-element dimming can go back to
   normal -- it was fighting a losing battle against six lit cards anyway. */
body.cr-tie-live #cr-room { opacity: 0.12; }
/* THE DRAW SHOWS THE ROOM, AND NOTHING ELSE FROM THE SCENE IT INTERRUPTED
   (owner call 2026-08-21). The crate's header was ghosted rather than gone,
   so CHOOSE YOUR SAMPLE sat behind the roll -- an instruction still being
   given while the room is being told the answer is decided. The FLOOR and
   the horizon stay: the draw is standing in the same room, and that is the
   only part of the scene that is scenery rather than state. */
body.cr-tie-live #cr-top {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease-out;
}
/* THE ROLL OWNS THE ROOM. The crate's furniture arrived after this list was
   written, so the deck, the standings and PULL IT sat at full strength in
   the top-left through a dead heat -- lit panels beside a scrim whose whole
   job is to take the room away. Hidden outright rather than dimmed: unlike
   the header, which is a title you can leave ghosted, these are CONTROLS,
   and a half-visible button during a roll invites a click that decides
   nothing. Kept clear of the floor grid, which stays -- the roll is still
   standing in the same room. */
body.cr-tie-live #cr-deck {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease-out;
}

/* The announcement owns the screen while it's up. */
#cr-tie.cr-tie-announce #cr-tie-banner { transform: translate(-50%, -50%) scale(1.5); }
#cr-tie.cr-tie-announce #cr-tie-word {
  text-shadow:
    0 0 30px rgba(255, 193, 77, 0.9),
    0 0 90px rgba(255, 150, 40, 0.6),
    0 0 160px rgba(255, 120, 20, 0.35);
}

@media (prefers-reduced-motion: reduce) {
  #cr-tie-scrim { transition: opacity 0.3s ease-out; }
}


@media (prefers-reduced-motion: reduce) {
}


/* Seated cards wear their wedge's colour. With the cards no longer orbiting,
   this is the ONLY thing tying one to its segment on the disc -- see
   CR_TIE_INKS / CR_TIE_WEDGES, which have to stay in step. */
/* A tab in the card's own colour, so the eye can pair it with the wedge
   without having to compare two thin borders. */
/* The winner overrides all of it -- green beats whatever colour it drew. */


/* ---- the roll ----
   One long strip of tiles sliding right to left past a fixed marker,
   decelerating onto the winner. crRollPlace drives the translate frame by
   frame; the rules here only supply the look.

   The crate's own cards are NOT on screen for this (they get .cr-slab-out).
   Keeping them is what made every earlier version collide with something:
   anything big enough to read was big enough to hit the banner or the
   marker. A strip only needs its tiles legible as they pass one point. */
#cr-tie-roll {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: clamp(150px, calc(20 * var(--dvh, 1vh)), 210px);
  transform: translateY(-50%);
  overflow: hidden;
  border-top: 1px solid rgba(255, 193, 77, 0.35);
  border-bottom: 1px solid rgba(255, 193, 77, 0.35);
  background: linear-gradient(180deg,
    rgba(255, 150, 40, 0.05) 0%,
    rgba(8, 6, 3, 0.55) 45%,
    rgba(255, 150, 40, 0.05) 100%);
  box-shadow: 0 0 60px rgba(255, 150, 40, 0.18), inset 0 0 50px rgba(0, 0, 0, 0.6);
  opacity: 0;
  transition: opacity 0.5s ease-out;
}
body.cr-tie-live #cr-tie:not(.cr-tie-announce) #cr-tie-roll { opacity: 1; }

#cr-tie-strip {
  display: flex;
  align-items: center;
  gap: 14px;
  height: 100%;
  padding: 0 12px;
  /* No CSS transition: crRollPlace owns the transform every frame, and a
     transition on top of that would fight it. */
  will-change: transform;
}

/* One item. Name and creator only -- at speed there is no time to read more
   than two lines, and by the reveal the credit is the thing that matters. */
.cr-tile {
  flex: none;
  width: clamp(150px, calc(13 * var(--dvw, 1vw)), 210px);
  height: calc(100% - 26px);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 4px;
  padding: 12px 14px 14px;
  /* LIT. The room behind this is blacked out, so the strip is the only thing
     on screen -- at the old 12%-ink-over-near-black the tiles were dimmer
     than the crate they replaced and the whole roll read as switched off. */
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--seg-ink, #ffc14d) 42%, transparent) 0%,
    color-mix(in srgb, var(--seg-ink, #ffc14d) 14%, transparent) 38%,
    rgba(14, 20, 16, 0.92) 100%);
  border: 1px solid color-mix(in srgb, var(--seg-ink, #ffc14d) 85%, transparent);
  box-shadow:
    0 0 22px color-mix(in srgb, var(--seg-ink, #ffc14d) 30%, transparent),
    inset 0 1px 0 color-mix(in srgb, var(--seg-ink, #ffc14d) 60%, transparent);
  /* The colour band along the bottom is the CS:GO tell -- it is what your
     eye actually tracks while the names are still a blur. */
  border-bottom: 4px solid var(--seg-ink, #ffc14d);
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
}
.cr-tile-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(15px, calc(1.3 * var(--dvw, 1vw)), 21px);
  line-height: 1.1;
  color: #fff6e2;
  white-space: nowrap;
  /* clip + margin, never hidden -- hidden squares the glow off flat. */
  overflow: clip;
  overflow-clip-margin: 22px;
  text-overflow: ellipsis;
}
.cr-tile-cred {
  font-family: 'Courier New', monospace;
  font-size: clamp(10px, calc(0.85 * var(--dvw, 1vw)), 13px);
  letter-spacing: 2px;
  color: var(--seg-ink, #ffc14d);
  white-space: nowrap;
  overflow: clip;
  overflow-clip-margin: 16px;
  text-overflow: ellipsis;
}
.cr-tile-uncred .cr-tile-cred { color: rgba(225, 240, 235, 0.45); font-style: italic; }

/* The winner, once the strip has stopped. */
.cr-tile-won {
  border-color: var(--cr-green);
  border-bottom-color: var(--cr-green);
  background: linear-gradient(180deg, rgba(134, 255, 194, 0.22) 0%, rgba(8, 24, 14, 0.92) 62%);
  animation: cr-tile-won 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}
.cr-tile-won .cr-tile-cred { color: var(--cr-green); }
@keyframes cr-tile-won {
  0%   { transform: scale(1); filter: brightness(1); }
  28%  { transform: scale(1.09); filter: brightness(2.2); }
  100% { transform: scale(1.04); filter: brightness(1.15); }
}
.cr-tile-won { transform: scale(1.04); }

/* Edges fall off into the dark, so the strip reads as running past rather
   than as a row that starts and stops. Click-through. */
#cr-tie-fade {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(90deg,
    rgba(2, 4, 2, 0.97) 0%, rgba(2, 4, 2, 0) 22%,
    rgba(2, 4, 2, 0) 78%, rgba(2, 4, 2, 0.97) 100%);
}

/* The marker. Dead centre, never moves -- this is what reads the result. */
#cr-tie-marker {
  position: absolute;
  left: 50%;
  top: -6px;
  bottom: -6px;
  width: 3px;
  margin-left: -1.5px;
  background: linear-gradient(180deg, #fff8e6 0%, var(--cr-amber) 50%, #fff8e6 100%);
  box-shadow: 0 0 18px rgba(255, 193, 77, 0.95), 0 0 46px rgba(255, 150, 40, 0.6);
  pointer-events: none;
}
/* Arrowheads top and bottom, so it reads as a marker and not as a seam. */
#cr-tie-marker::before,
#cr-tie-marker::after {
  content: '';
  position: absolute;
  left: 50%;
  width: 20px;
  height: 14px;
  margin-left: -10px;
  background: var(--cr-amber);
  filter: drop-shadow(0 0 10px rgba(255, 193, 77, 0.95));
}
#cr-tie-marker::before { top: -12px; clip-path: polygon(50% 100%, 0 0, 100% 0); }
#cr-tie-marker::after { bottom: -12px; clip-path: polygon(50% 0, 0 100%, 100% 100%); }
/* Kicks each time a tile edge crosses it. */
#cr-tie-marker.cr-tick { animation: cr-tick 0.1s ease-out; }
@keyframes cr-tick {
  0%, 100% { transform: scaleX(1); filter: none; }
  50%      { transform: scaleX(2.4); filter: brightness(1.9); }
}
body.cr-tie-done #cr-tie-marker {
  background: linear-gradient(180deg, #ffffff 0%, var(--cr-green) 50%, #ffffff 100%);
  box-shadow: 0 0 22px rgba(134, 255, 194, 1), 0 0 56px rgba(80, 255, 170, 0.7);
}
body.cr-tie-done #cr-tie-marker::before,
body.cr-tie-done #cr-tie-marker::after { background: var(--cr-green); }

@media (prefers-reduced-motion: reduce) {
  #cr-tie-marker.cr-tick { animation: none; }
  .cr-tile-won { animation: none; }
}


/* ---- the roll, lit ----
   The room behind this is blacked out, so the strip is the only thing on
   screen and it has to carry the whole frame. Everything below overrides the
   first pass, where the tiles ended up dimmer than the crate they replaced
   and the roll read as switched off. */

/* The rail itself: brighter walls and a real glow, so the strip sits IN
   something rather than floating in the dark. */
#cr-tie-roll {
  border-top: 2px solid rgba(255, 193, 77, 0.75);
  border-bottom: 2px solid rgba(255, 193, 77, 0.75);
  background: linear-gradient(180deg,
    rgba(255, 165, 55, 0.18) 0%,
    rgba(20, 15, 8, 0.82) 42%,
    rgba(20, 15, 8, 0.82) 58%,
    rgba(255, 165, 55, 0.18) 100%);
  box-shadow:
    0 0 90px rgba(255, 150, 40, 0.4),
    0 0 200px rgba(255, 120, 20, 0.22),
    inset 0 0 60px rgba(0, 0, 0, 0.5);
}

/* Type has to be readable at speed, which means bright and heavy -- not a
   tint of the accent over a dark plate. */
.cr-tile-name {
  color: #ffffff;
  font-size: clamp(16px, calc(1.45 * var(--dvw, 1vw)), 23px);
  text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 2px 3px rgba(0, 0, 0, 0.85);
}
.cr-tile-cred {
  color: color-mix(in srgb, var(--seg-ink, #ffc14d) 88%, white);
  font-size: clamp(11px, calc(0.9 * var(--dvw, 1vw)), 14px);
  text-shadow: 0 0 10px rgba(0, 0, 0, 0.9);
}
.cr-tile-uncred .cr-tile-cred { color: rgba(240, 248, 245, 0.7); }
/* The band along the bottom is what the eye tracks while the names are still
   a blur, so it is the brightest thing on the tile. */
.cr-tile {
  border-bottom-width: 5px;
  border-bottom-color: var(--seg-ink, #ffc14d);
}

/* The edge fade was strong enough to eat most of the strip. Pulled back so it
   only softens the last stretch. */
#cr-tie-fade {
  background: linear-gradient(90deg,
    rgba(2, 4, 2, 0.96) 0%, rgba(2, 4, 2, 0) 13%,
    rgba(2, 4, 2, 0) 87%, rgba(2, 4, 2, 0.96) 100%);
}

/* The scrim can ease off once the roll is up: the roll now supplies its own
   contrast, and 0.94 over a blur was flattening it too. The blackout stays
   full for the announcement, which is when it is doing real work. */
body.cr-tie-live:not(.cr-tie-announce) #cr-tie-scrim {
  opacity: 0.88;
  backdrop-filter: blur(6px) saturate(0.45) brightness(0.4);
  -webkit-backdrop-filter: blur(6px) saturate(0.45) brightness(0.4);
}

/* The crate is GONE for this, not merely transparent. Opacity alone left the
   cards ghosting through the blur behind the strip. Serves both votes --
   the beat vote's plates are the same .cr-slab now. */
body.cr-tie-live .cr-slab-out { visibility: hidden; }


/* ---- the roll, actually lit ----
   The previous pass raised the numbers but not the brightness, because
   `color-mix(in srgb, X, transparent)` does not lighten anything -- it makes
   X TRANSLUCENT. At 42% "ink" the tiles were 42%-alpha colour over a black
   room, which is a dark tile with extra steps. Mixing against an opaque dark
   instead produces an opaque, genuinely brighter colour, which is what was
   wanted all along. */
.cr-tile {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--seg-ink, #ffc14d) 70%, #0c1216) 0%,
    color-mix(in srgb, var(--seg-ink, #ffc14d) 30%, #0c1216) 46%,
    color-mix(in srgb, var(--seg-ink, #ffc14d) 12%, #0c1216) 100%);
  border: 1px solid var(--seg-ink, #ffc14d);
  border-bottom: 5px solid var(--seg-ink, #ffc14d);
  box-shadow:
    0 0 26px color-mix(in srgb, var(--seg-ink, #ffc14d) 45%, transparent),
    inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
/* Dark ink on the lit half, so the name reads against it rather than
   fighting it. */
.cr-tile-name {
  color: #0a0f12;
  text-shadow: 0 1px 0 color-mix(in srgb, var(--seg-ink, #ffc14d) 75%, white);
}
.cr-tile-cred {
  color: color-mix(in srgb, var(--seg-ink, #ffc14d) 55%, white);
  text-shadow: 0 0 10px rgba(0, 0, 0, 0.85);
}
.cr-tile-uncred .cr-tile-cred { color: rgba(255, 255, 255, 0.8); }
/* The name sits on the DARK half of the tile, so it goes back to white --
   the gradient runs light at the top and dark at the bottom, and the text is
   bottom-aligned. */
.cr-tile-name { color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.95); }

/* The winner keeps the same treatment in green. */
.cr-tile-won {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--cr-green) 78%, #0c1216) 0%,
    color-mix(in srgb, var(--cr-green) 34%, #0c1216) 46%,
    color-mix(in srgb, var(--cr-green) 14%, #0c1216) 100%);
  border-color: var(--cr-green);
  border-bottom-color: var(--cr-green);
  box-shadow: 0 0 50px rgba(134, 255, 194, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.45);
}
.cr-tile-won .cr-tile-cred { color: #ffffff; }

/* The rail's walls read as part of the strip now, so they can go full. */
#cr-tie-roll {
  border-top-color: var(--cr-amber);
  border-bottom-color: var(--cr-amber);
  background: linear-gradient(180deg,
    rgba(255, 175, 60, 0.3) 0%,
    rgba(26, 20, 12, 0.9) 40%,
    rgba(26, 20, 12, 0.9) 60%,
    rgba(255, 175, 60, 0.3) 100%);
}

/* And the blackout stops fighting the strip. It exists to kill the CRATE,
   which is already visibility:hidden -- the remaining job is atmosphere, so
   the brightness knock goes and the veil thins. */
body.cr-tie-live:not(.cr-tie-announce) #cr-tie-scrim {
  opacity: 0.8;
  backdrop-filter: blur(6px) saturate(0.5);
  -webkit-backdrop-filter: blur(6px) saturate(0.5);
}


/* ---- the roll, final lighting ----
   The tile was bright at its TOP edge and fell to 0.15 by the bottom -- which
   is exactly where the text sits, so the half anyone actually looks at was
   the dark half. Inverted: the tile is one bright block of its own colour all
   the way down, with a dark scrim ONLY under the two lines of text so white
   still reads on it.

   (Four separate rules had accumulated on .cr-tile-name across the lighting
   passes, all fighting over the same property. Consolidated here.) */
.cr-tile {
  background:
    /* text scrim, drawn over the colour */
    linear-gradient(180deg,
      rgba(6, 10, 12, 0) 46%,
      rgba(6, 10, 12, 0.62) 72%,
      rgba(6, 10, 12, 0.88) 100%),
    /* the colour block itself -- opaque, and the same all the way down */
    color-mix(in srgb, var(--seg-ink, #ffc14d) 72%, #0d1417);
  border: 1px solid color-mix(in srgb, var(--seg-ink, #ffc14d) 60%, white);
  border-bottom: 5px solid color-mix(in srgb, var(--seg-ink, #ffc14d) 55%, white);
  box-shadow:
    0 0 30px color-mix(in srgb, var(--seg-ink, #ffc14d) 55%, transparent),
    inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.cr-tile-name {
  color: #ffffff;
  text-shadow: 0 2px 5px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.8);
}
.cr-tile-cred {
  color: color-mix(in srgb, var(--seg-ink, #ffc14d) 30%, white);
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95);
}
.cr-tile-uncred .cr-tile-cred { color: rgba(255, 255, 255, 0.82); }

.cr-tile-won {
  background:
    linear-gradient(180deg,
      rgba(4, 14, 9, 0) 46%,
      rgba(4, 14, 9, 0.6) 72%,
      rgba(4, 14, 9, 0.88) 100%),
    color-mix(in srgb, var(--cr-green) 78%, #0d1417);
  border-color: #ffffff;
  border-bottom-color: #ffffff;
  box-shadow: 0 0 60px rgba(134, 255, 194, 0.85), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.cr-tile-won .cr-tile-cred { color: #ffffff; }


/* DEV ONLY -- the layer switchboard. Same deliberately-ugly treatment as the
   other dev controls so it can never be mistaken for real UI, and sits under
   them in the same corner. STRIP with the rest. */
#dev-layers {
  position: fixed;
  top: 78px;
  right: 14px;
  z-index: 900;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px dashed rgba(140, 200, 255, 0.4);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #7d94a6;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-layers:hover { color: #cfe6ff; border-color: rgba(140, 200, 255, 0.85); }
#dev-layers.hidden { display: none; }
/* Same chip, one slot lower. z 901: one above the onboarding gate (900),
   so it stays clickable ON TOP of the very screen it opens -- that's the
   whole point of the toggle. */
#dev-onboard {
  position: fixed;
  top: 116px;
  right: 14px;
  /* Above the PRESS START cover (9500) and the dev-peeked gate (9600):
     the chip has to stay clickable from the boot screen AND on top of
     the very screen it opens. */
  z-index: 9601;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px dashed rgba(140, 200, 255, 0.4);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #7d94a6;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-onboard:hover { color: #cfe6ff; border-color: rgba(140, 200, 255, 0.85); }
#dev-onboard.hidden { display: none; }

/* Same chip, one slot further down the dev stack. Same 9601 z as
   #dev-onboard and for the same reason: it must stay clickable from the
   PRESS START cover (9500) it exists to skip. */
#dev-skip-intro {
  position: fixed;
  top: 154px;
  right: 14px;
  z-index: 9601;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px dashed rgba(140, 200, 255, 0.4);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #7d94a6;
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-skip-intro:hover { color: #cfe6ff; border-color: rgba(140, 200, 255, 0.85); }
#dev-skip-intro.hidden { display: none; }

/* Same chip, one slot further down: the notification sampler. */
#dev-notifs {
  position: fixed;
  top: 192px;
  right: 14px;
  z-index: 9601;
  padding: 5px 10px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 2px;
  color: #8fb4d4;
  background: rgba(0, 0, 0, 0.75);
  border: 1px dashed rgba(140, 200, 255, 0.55);
  cursor: pointer;
  transition: color 0.12s, border-color 0.12s;
}
#dev-notifs:hover { color: #cfe6ff; border-color: rgba(140, 200, 255, 0.85); }
#dev-notifs.hidden { display: none; }

/* The sampler panel: same switchboard chrome as #dev-layer-panel, same
   corner -- the two chips close each other's panel so they never stack. */
#dev-notif-panel {
  position: fixed;
  top: 226px;
  right: 14px;
  z-index: 9601;
  width: 268px;
  max-height: calc(60 * var(--dvh, 1vh));
  overflow-y: auto;
  padding: 10px 12px 12px;
  background: rgba(0, 0, 0, 0.92);
  border: 1px dashed rgba(140, 200, 255, 0.45);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  color: #9fb4c4;
}
#dev-notif-panel.hidden { display: none; }
#dev-notif-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  letter-spacing: 2px;
  color: #cfe6ff;
  padding-bottom: 8px;
  margin-bottom: 8px;
  border-bottom: 1px dashed rgba(140, 200, 255, 0.28);
}
#dev-notif-all {
  padding: 3px 8px;
  font-family: inherit;
  font-size: 10px;
  color: #7d94a6;
  background: transparent;
  border: 1px dashed rgba(140, 200, 255, 0.4);
  cursor: pointer;
}
#dev-notif-all:hover { color: #cfe6ff; }
.dev-notif-btn {
  display: block;
  width: 100%;
  text-align: left;
  margin: 2px 0;
  padding: 4px 8px;
  font-family: inherit;
  font-size: 11px;
  color: #9fb4c4;
  background: transparent;
  border: 1px dashed rgba(140, 200, 255, 0.25);
  cursor: pointer;
}
.dev-notif-btn:hover { color: #dff0ff; border-color: rgba(140, 200, 255, 0.6); }
#dev-notif-note {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px dashed rgba(140, 200, 255, 0.22);
  font-size: 9px;
  color: #5d7488;
}

#dev-layer-panel {
  position: fixed;
  top: 110px;
  right: 14px;
  z-index: 901;
  width: 268px;
  max-height: calc(72 * var(--dvh, 1vh));
  overflow-y: auto;
  padding: 10px 12px 12px;
  background: rgba(0, 0, 0, 0.92);
  border: 1px dashed rgba(140, 200, 255, 0.45);
  font-family: 'Courier New', monospace;
  font-size: 11px;
  color: #9fb4c4;
}
#dev-layer-panel.hidden { display: none; }
#dev-layer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  letter-spacing: 2px;
  color: #cfe6ff;
  padding-bottom: 8px;
  margin-bottom: 8px;
  border-bottom: 1px dashed rgba(140, 200, 255, 0.28);
}
#dev-layer-all {
  padding: 3px 8px;
  font-family: inherit;
  font-size: 10px;
  color: #7d94a6;
  background: transparent;
  border: 1px dashed rgba(140, 200, 255, 0.4);
  cursor: pointer;
}
#dev-layer-all:hover { color: #cfe6ff; }
.dev-layer-group {
  margin: 10px 0 4px;
  letter-spacing: 3px;
  font-size: 9px;
  color: #5d7488;
}
.dev-layer-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 0;
  cursor: pointer;
  line-height: 1.35;
}
.dev-layer-row:hover { color: #dff0ff; }
.dev-layer-row input { margin: 0; cursor: pointer; }
/* Not in the DOM right now -- most layers only exist during their own phase,
   and a dead row that looks live is how you toggle something and wrongly
   conclude it does nothing. */
.dev-layer-absent { opacity: 0.4; }
.dev-layer-absent span::after { content: ' (not on screen)'; font-size: 9px; opacity: 0.7; }
#dev-layer-note {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px dashed rgba(140, 200, 255, 0.22);
  font-size: 9px;
  letter-spacing: 1px;
  color: #56697a;
}


/* ---- the roll, vivid ----
   Measured the muddiness rather than guessing at it: mixing the ink with the
   dark base barely touched SATURATION (0.698 -> 0.668) but took the peak
   channel from 255 down to 187. It was never desaturated, it was just dark --
   which is why raising the mix percentage kept doing nothing.

   So the tile IS the ink now, at the exact hex the headline uses, with black
   laid over only the bottom third where the two lines of text sit. Same
   colour, same value, no mixing to dull it -- which is also what makes the
   strip and DEAD HEAT read as the same object. */
.cr-tile {
  background:
    linear-gradient(180deg,
      rgba(0, 0, 0, 0) 38%,
      rgba(0, 0, 0, 0.55) 66%,
      rgba(0, 0, 0, 0.86) 100%),
    var(--seg-ink, #ffc14d);
  border: 1px solid #ffffff;
  border-bottom: 5px solid #ffffff;
  /* The headline's glow, in the tile's own colour, so the two carry the same
     light. */
  box-shadow:
    0 0 26px color-mix(in srgb, var(--seg-ink, #ffc14d) 70%, transparent),
    0 0 70px color-mix(in srgb, var(--seg-ink, #ffc14d) 30%, transparent);
}
.cr-tile-name {
  color: #ffffff;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 1), 0 0 3px rgba(0, 0, 0, 0.9);
}
.cr-tile-cred {
  color: #ffffff;
  opacity: 0.9;
  text-shadow: 0 1px 5px rgba(0, 0, 0, 1);
}
.cr-tile-uncred .cr-tile-cred { opacity: 0.72; font-style: italic; }

.cr-tile-won {
  background:
    linear-gradient(180deg,
      rgba(0, 0, 0, 0) 38%,
      rgba(0, 0, 0, 0.52) 66%,
      rgba(0, 0, 0, 0.86) 100%),
    var(--cr-green);
  border-color: #ffffff;
  border-bottom-color: #ffffff;
  box-shadow: 0 0 40px rgba(134, 255, 194, 0.95), 0 0 110px rgba(134, 255, 194, 0.5);
}

/* The rail matches the headline too -- same amber, and the walls read as lit
   rather than as a hairline. */
#cr-tie-roll {
  border-top: 2px solid var(--cr-amber);
  border-bottom: 2px solid var(--cr-amber);
  background: linear-gradient(180deg,
    rgba(255, 193, 77, 0.22) 0%,
    rgba(10, 8, 4, 0.8) 40%,
    rgba(10, 8, 4, 0.8) 60%,
    rgba(255, 193, 77, 0.22) 100%);
  box-shadow:
    0 0 70px rgba(255, 193, 77, 0.45),
    0 0 180px rgba(255, 150, 40, 0.22),
    inset 0 0 50px rgba(0, 0, 0, 0.45);
}

/* With vivid tiles the veil can come down further -- it is there to kill the
   crate, and the crate is already visibility:hidden. */
body.cr-tie-live:not(.cr-tie-announce) #cr-tie-scrim {
  opacity: 0.72;
  backdrop-filter: blur(6px) saturate(0.5);
  -webkit-backdrop-filter: blur(6px) saturate(0.5);
}


/* ---- the roll, vivid for real ----
   The tile was the raw ink, but only for its top 38% -- from there the text
   scrim ramped to 86% black, so nearly two thirds of what you actually look
   at was black laid over the colour. The colour was right and the coverage
   was wrong, which is why "the background is now rgb(255,193,77)" was true
   and the thing still looked dark.

   The scrim is measured in PIXELS now, sized to the two lines of text it
   exists for, instead of as a percentage that scales up with the tile and
   swallows it. Everything above those two lines is undiluted ink. */
.cr-tile {
  background:
    linear-gradient(to top,
      rgba(0, 0, 0, 0.9) 0px,
      rgba(0, 0, 0, 0.72) 34px,
      rgba(0, 0, 0, 0.18) 58px,
      rgba(0, 0, 0, 0) 74px),
    var(--seg-ink, #ffc14d);
  /* The profile plate pops because it is a lit EDGE, not a filled shape --
     so the tile gets the same treatment: a white rim, a bright inner line,
     and a bloom in its own colour that reaches well past the box. */
  border: 2px solid #ffffff;
  border-bottom: 6px solid #ffffff;
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--seg-ink, #ffc14d) 90%, white),
    0 0 34px color-mix(in srgb, var(--seg-ink, #ffc14d) 95%, transparent),
    0 0 90px color-mix(in srgb, var(--seg-ink, #ffc14d) 55%, transparent),
    inset 0 0 26px rgba(255, 255, 255, 0.32);
}
.cr-tile-won {
  background:
    linear-gradient(to top,
      rgba(0, 0, 0, 0.88) 0px,
      rgba(0, 0, 0, 0.7) 34px,
      rgba(0, 0, 0, 0.16) 58px,
      rgba(0, 0, 0, 0) 74px),
    var(--cr-green);
  box-shadow:
    0 0 0 1px #ffffff,
    0 0 44px rgba(134, 255, 194, 1),
    0 0 130px rgba(134, 255, 194, 0.6),
    inset 0 0 30px rgba(255, 255, 255, 0.45);
}

/* The rail stops competing: it was a lit box around lit tiles, which flattens
   both. Dark walls, and the tiles supply the light. */
#cr-tie-roll {
  background: linear-gradient(180deg,
    rgba(255, 193, 77, 0.1) 0%,
    rgba(6, 5, 3, 0.86) 34%,
    rgba(6, 5, 3, 0.86) 66%,
    rgba(255, 193, 77, 0.1) 100%);
  box-shadow:
    0 0 60px rgba(255, 193, 77, 0.3),
    inset 0 0 40px rgba(0, 0, 0, 0.55);
}

/* And the veil comes off the strip entirely -- the tiles are the brightest
   thing on screen now, and dimming them to make the room dark was always
   backwards. The blackout still does its job behind them. */
body.cr-tie-live:not(.cr-tie-announce) #cr-tie-scrim {
  opacity: 0.66;
}


/* The CRT vignette, lifted above the transition's wash while it fades back.
   It normally sits at z 500, UNDER #cart-wash (660) and #cart-overlay (640),
   so its fade-in happened underneath the green and none of it was visible
   until the wash had nearly finished -- which read as the glass snapping on
   at the very end. The scanline layer already sits above the wash at z 700;
   the vignette being below it was the outlier, since the wash is part of the
   PICTURE and the glass belongs over the picture.
   Applied only for the duration of the fade (cartStart adds it, the
   animation's onfinish and clearStuckTransition both remove it), so the
   normal stacking order is untouched the rest of the time. */
body.cart-glass-lift #crt-overlay { z-index: 670; }
/* ---- ambience settles in, it does not switch on ----
   The room textures arrive with their screen and were appearing at full
   strength on the same frame it did, which is the same complaint the CRT
   vignette had: atmosphere should build, not flick on.
   
   Audited the rest rather than blanket-applying this. The transition
   overlays, the gamemode card, fire, snow, the hologram and the boot flash
   all already have entrances (some via JS .animate, which is why a static
   check reports them as having none). These two had nothing.
   
   NO fill: the animation must not hold its end state, because #cr-room's
   opacity is driven to 0.12 while a tie is on screen and a held animation
   would outrank that declaration and keep the room lit under the blackout.
   Unfilled, it interpolates on entry and then hands control straight back
   to the stylesheet. */
@keyframes sw-ambience-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
#screen-crate.active #cr-room,
#screen-production.active #dz-texture {
  animation: sw-ambience-in 1.15s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
  #screen-crate.active #cr-room,
  #screen-production.active #dz-texture { animation: none; }
}


/* =========================================================================
   MODE ART
   =========================================================================
   Each gamemode gets a picture of what it actually IS, instead of the same
   geometric glyph in a different rotation. A shared six-slot markup
   (.mp-art > i x6) that every mode uses differently, so adding a fourth
   gamemode is a CSS block rather than new markup.

   All of it is gradients and boxes -- no image assets to ship, and it
   recolours itself from --mp-accent, so the same art reads red under
   PRODUCER and green under ARTIST without a second copy. */
.mp-art {
  position: relative;
  /* A SQUARE, not the full tile width. Every motif below is sized in
     percentages, and against a 428px-wide panel those resolved to a scoring
     floor three times wider than the bars standing on it and a tonearm twice
     the length of its own record. Percentages have to be relative to the
     artwork, so the artwork gets its own box. */
  width: clamp(96px, calc(11 * var(--dvw, 1vw)), 140px);
  height: 100%;
  display: grid;
  place-items: center;
}
.mp-art i { position: absolute; }

/* ---- BEAT SHOWCASE: bring a beat, the room scores it ----
   An analyser standing on a scoring floor. It is the showcase's own rack in
   miniature, which is the thing you actually stare at for the whole phase. */
.mp-tile[data-gamemode="beat_wars"] .mp-art {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 6px;
  padding-bottom: 26px;
}
.mp-tile[data-gamemode="beat_wars"] .mp-art i {
  position: static;
  width: 8px;
  border-radius: 1px;
  background: linear-gradient(to top,
    var(--mp-accent) 0%,
    color-mix(in srgb, var(--mp-accent) 55%, white) 100%);
  box-shadow: 0 0 12px var(--mp-accent-dim);
  transform-origin: 50% 100%;
}
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(1) { height: 22%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(2) { height: 52%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(3) { height: 82%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(4) { height: 40%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(5) { height: 66%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(6) { height: 30%; }
/* The floor they are scored against. */
.mp-tile[data-gamemode="beat_wars"] .mp-art::after {
  content: '';
  position: absolute;
  left: 16%;
  right: 16%;
  bottom: 20px;
  height: 2px;
  background: linear-gradient(90deg, transparent, var(--mp-accent), transparent);
  box-shadow: 0 0 14px var(--mp-accent-dim);
}
/* On hover the rack runs. */
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i {
  animation: mp-art-eq 0.9s ease-in-out infinite;
}
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i:nth-child(2) { animation-delay: 0.12s; }
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i:nth-child(3) { animation-delay: 0.24s; }
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i:nth-child(4) { animation-delay: 0.06s; }
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i:nth-child(5) { animation-delay: 0.3s; }
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i:nth-child(6) { animation-delay: 0.18s; }
@keyframes mp-art-eq {
  0%, 100% { transform: scaleY(0.55); }
  50%      { transform: scaleY(1.25); }
}

/* ---- SAMPLE FLIP: the room picks one record, everyone flips it ----
   A record on a deck. The grooves are one repeating gradient rather than
   twenty elements. */
/* The record sits ON something. It was a disc floating in space with a
   stray line beside it: grooves so coarse and high-contrast they read as a
   dartboard, a label taking up a fifth of the face, and a "tonearm" that was
   a bare 3px rectangle with no pivot and no headshell -- nothing that says
   record player rather than line. */

/* The platter the record rests on. */
.mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(n+2) { display: none; }
.mp-tile[data-gamemode="sample_flip"] .mp-art::before {
  content: '';
  width: clamp(62px, calc(6.8 * var(--dvw, 1vw)), 88px);
  aspect-ratio: 1;
  border-radius: 50%;
  background:
    /* spindle hole, over the label */
    radial-gradient(circle at 50% 50%, #05080a 0 2.5%, transparent 3%),
    /* label -- a fifth the size it was, and it takes the mode's accent */
    radial-gradient(circle at 50% 50%, var(--mp-accent) 0 13%, transparent 13.5%),
    /* the run-in groove: one visible band, the way a record actually looks */
    radial-gradient(circle at 50% 50%, transparent 0 15%, rgba(255,255,255,0.1) 15.5% 16.5%, transparent 17%),
    /* grooves: 1px every 3px at a tenth the old contrast, so they read as
       sheen on a black disc rather than as printed rings */
    repeating-radial-gradient(circle at 50% 50%,
      rgba(255, 255, 255, 0.075) 0 1px, transparent 1px 3px),
    /* the light a record catches: two opposing highlights, not a flat fill */
    conic-gradient(from 200deg,
      rgba(255, 255, 255, 0.16) 0deg 26deg,
      transparent 60deg 200deg,
      rgba(255, 255, 255, 0.11) 226deg 250deg,
      transparent 290deg 360deg),
    radial-gradient(circle at 38% 30%, #262c30 0%, #14181b 45%, #080a0c 100%);
  box-shadow:
    0 0 0 3px rgba(255, 255, 255, 0.05),
    0 6px 18px rgba(0, 0, 0, 0.8),
    0 0 26px var(--mp-accent-dim);
}

/* THE TONEARM, as an assembly rather than a line: a pivot it swings from,
   a tapered arm, and a headshell sitting on the record. Metal, deliberately
   -- an accent-coloured arm looked like part of the record. */
.mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(1) {
  display: block;
  top: 20%;
  right: 8%;
  width: 42%;
  height: 3px;
  border-radius: 2px;
  transform-origin: 100% 50%;
  transform: rotate(34deg);
  background: linear-gradient(90deg, #e6edf1 0%, #aab4ba 55%, #737d83 100%);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
  transition: transform 0.55s cubic-bezier(0.34, 1.3, 0.64, 1);
}
/* the pivot it turns on */
.mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(1)::before {
  content: '';
  position: absolute;
  right: -8px;
  top: -7px;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: radial-gradient(circle at 38% 32%, #f2f7fa 0%, #9aa4aa 55%, #414a4f 100%);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.75);
}
/* the headshell, angled down onto the groove */
.mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(1)::after {
  content: '';
  position: absolute;
  left: -3px;
  top: -4px;
  width: 13px;
  height: 11px;
  background: linear-gradient(180deg, #eef4f7 0%, #9fa9af 100%);
  clip-path: polygon(0 14%, 100% 0, 100% 100%, 0 86%);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}

/* Hover: it spins up and the arm drops in over the groove. */
.mp-tile[data-gamemode="sample_flip"]:hover .mp-art::before {
  animation: mp-art-spin 2.6s linear infinite;
}
.mp-tile[data-gamemode="sample_flip"]:hover .mp-art i:nth-child(1) { transform: rotate(16deg); }
@keyframes mp-art-spin { to { transform: rotate(360deg); } }

/* ---- WORD PLAY: write to the beat, using the room's three words ----
   A mic. Three of the six slots do the work; the rest sit out. */
.mp-tile[data-gamemode="word_play"] .mp-art i:nth-child(n+4) { display: none; }
.mp-tile[data-gamemode="word_play"] .mp-art i:nth-child(1) {
  width: 26px;
  height: 44px;
  top: 22%;
  border-radius: 13px;
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--mp-accent) 60%, white), var(--mp-accent));
  box-shadow: 0 0 20px var(--mp-accent-dim);
}
/* the grille */
.mp-tile[data-gamemode="word_play"] .mp-art i:nth-child(2) {
  width: 26px;
  height: 44px;
  top: 22%;
  border-radius: 13px;
  background: repeating-linear-gradient(0deg,
    rgba(0, 0, 0, 0.45) 0 2px, transparent 2px 5px);
}
/* the stand */
.mp-tile[data-gamemode="word_play"] .mp-art i:nth-child(3) {
  width: 3px;
  height: 26px;
  bottom: 22%;
  background: var(--mp-accent);
  box-shadow: 0 0 12px var(--mp-accent-dim);
}
.mp-tile[data-gamemode="word_play"] .mp-art::after {
  content: '';
  position: absolute;
  bottom: calc(22% - 4px);
  width: 40px;
  height: 4px;
  border-radius: 2px;
  background: var(--mp-accent);
  box-shadow: 0 0 14px var(--mp-accent-dim);
}
.mp-tile[data-gamemode="word_play"]:hover .mp-art i:nth-child(1) {
  animation: mp-art-mic 1.4s ease-in-out infinite;
}
@keyframes mp-art-mic {
  0%, 100% { box-shadow: 0 0 20px var(--mp-accent-dim); }
  50%      { box-shadow: 0 0 34px var(--mp-accent-soft), 0 0 60px var(--mp-accent-dim); }
}

/* The old single-glyph rule scaled and rotated whatever was in the art
   panel. There is no glyph any more, and rotating a record or a mic on
   hover looks like a mistake rather than a flourish. */
.mp-tile:hover .mp-tile-art i { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .mp-tile:hover .mp-art i,
  .mp-tile:hover .mp-art::before { animation: none; }
  .mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(1),
  .mp-tile[data-gamemode="sample_flip"]:hover .mp-art i:nth-child(1) { transition: none; transform: rotate(34deg); }
}


/* SETTINGS was the only screen carrying a corner BACK plate without the
   full-bleed body override the other ten have. Two things went wrong from
   that one omission:

   - the body's 700px cap left #screen-settings starting at x=459, and the
     plate's "left: 16px" resolves against the SCREEN -- .screen carries the
     FILTER's transform animations, and a transformed ancestor becomes the
     containing block for position:fixed. So 16px from the left put the plate
     16px inside the content column, directly on top of the title.
   - the content was escaping the cap regardless: #set-head lays out at 920px
     inside a 620px content box, overflowing 150px each side. The button was
     the only part of the screen still honouring a width nothing else did.

   Full-bleed fixes both -- the plate reaches the real corner, and the column
   centres instead of overflowing. The padding-top is what the game screens
   use for the same reason: it keeps content clear of the fixed plate. */
body:has(#screen-settings.active) { max-width: none; padding: 0; }
#screen-settings.active { padding-top: clamp(88px, calc(10 * var(--dvh, 1vh)), 112px); }

/* Party riders in the STANDBY roster: with you, not in a lobby. The rail
   and the dashed skin exist because a rider row read as a lobby member to
   an actual player -- the whole point of showing them is "who walks in
   with me", and that must never look like "who is already inside". */
.mp-slot-partyrail {
  list-style: none;
  padding: 4px 0 2px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 5px;
  text-align: center;
  color: rgba(157, 255, 176, 0.45);
}
.mp-slot-partyidle {
  border-style: dashed !important;
  opacity: 0.82;
  background: rgba(57, 255, 20, 0.03) !important;
}

/* ---------- SITTING OUT ----------
   You left the match but kept your seat. Everyone still inside reads as
   busy: dimmed, with a live ellipsis so the room feels like it is still
   running rather than frozen. */
.mp-slot-playing { opacity: 0.45; filter: saturate(0.35); }
.mp-slot-ingame {
  color: rgba(157, 255, 176, 0.75) !important;
  letter-spacing: 3px;
}
.mp-dots { font-style: normal; display: inline-block; margin-left: 1px; }
.mp-dots b {
  font-weight: normal;
  opacity: 0.25;
  animation: mp-dots 1.4s steps(1, end) infinite;
}
.mp-dots b:nth-child(2) { animation-delay: 0.24s; }
.mp-dots b:nth-child(3) { animation-delay: 0.48s; }
@keyframes mp-dots {
  0%, 60%, 100% { opacity: 0.25; }
  20%, 55%      { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .mp-dots b { animation: none; opacity: 1; }
}

/* ---------- THE PARTY RAIL ----------
   Your crew hanging under the social button: one circle each, a green bar
   that flashes while they talk, right-click for the same menu as any other
   roster row. Rides the same hue drift as the button above it. */
#party-rail {
  position: fixed;
  top: 82px;              /* clears #social-toggle (16 + 54 + 12) */
  right: 16px;
  z-index: 691;           /* under the toggle, above the screens */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
#party-rail.hidden { display: none; }
.pr-member {
  position: relative;
  width: 46px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.pr-member:hover { transform: translateX(-3px) scale(1.06); }
.pr-member .player-icon {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  border: 2px solid rgba(57, 255, 20, 0.45);
  box-shadow: 0 0 0 rgba(57, 255, 20, 0);
  transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out;
}
/* The party leader wears the cred ring. */
.pr-leader .player-icon { border-color: #ffd257; }
/* Offline: present in the party, not at the desk. */
.pr-away { opacity: 0.4; filter: saturate(0.3); }

/* THE BAR. Idle it is a dim sliver; the moment voice.js marks the icon
   .speaking it lights and pulses -- the same signal the lobby slots give,
   in the shape that fits a 46px column. */
.pr-bar {
  display: block;
  width: 26px;
  height: 3px;
  background: rgba(57, 255, 20, 0.18);
  transition: background 0.12s ease-out, box-shadow 0.12s ease-out, width 0.12s ease-out;
}
.pr-member:has(.player-icon.speaking) .pr-bar {
  width: 38px;
  background: #39ff14;
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.9);
  animation: pr-talk 0.5s ease-in-out infinite;
}
.pr-member:has(.player-icon.speaking) .player-icon {
  border-color: #39ff14;
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.75);
}
@keyframes pr-talk {
  0%, 100% { transform: scaleX(0.72); }
  50%      { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
  .pr-bar { animation: none !important; }
  .pr-member:hover { transform: none; }
}

/* ==================== NOTIFICATIONS ====================
   The tap on the shoulder (toasts, top-center, ten seconds each) and the
   memory (the drawer's bell log). Toasts live ABOVE the CRT glass (700)
   for the same reason the profile card moved up there: they carry faces,
   and the glass tints faces. 960 keeps them under the confirm modal (980)
   -- a dialog demanding an answer still owns the screen. */
#notif-toasts {
  position: fixed;
  top: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 960;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  /* The layer itself must never eat a click aimed at the game. */
  pointer-events: none;
  /* The drawer's palette, redeclared: --sc-* lives ON #social-panel and
     cannot reach a body-level layer, but socialAvatar rows land in here
     and expect it. */
  --sc-accent: #39ff14;
  --sc-soft: rgba(57, 255, 20, 0.5);
  --sc-dim: rgba(57, 255, 20, 0.16);
  --sc-ink: #cdeecb;
}
.notif-toast {
  pointer-events: auto;
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  min-width: 460px;
  max-width: 760px;
  padding: 12px 20px 15px 16px;
  background: rgba(4, 12, 4, 0.94);
  border: 1px solid var(--sc-soft);
  border-left: 3px solid var(--sc-accent);
  /* Angular plate, house cut: one clipped corner each way. */
  clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 16px 100%, 0 calc(100% - 16px));
  box-shadow: 0 0 24px rgba(57, 255, 20, 0.12);
  cursor: pointer;
  animation: notif-ignite 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
.notif-toast:hover { border-color: var(--sc-accent); }
.notif-text { min-width: 0; flex: 1; }
.notif-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 2px;
  color: #eafff0;
  text-shadow: 0 0 12px var(--sc-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.notif-body {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 1.5px;
  color: rgba(150, 210, 145, 0.8);
  margin-top: 2px;
}
.notif-acts { display: flex; gap: 8px; flex: none; }
/* THE FUSE. Lit at full width, gone at zero -- the ten seconds are visible
   the whole time (nothing instant, nothing unexplained). */
.notif-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--sc-accent);
  box-shadow: 0 0 8px var(--sc-soft);
  transform-origin: left;
  animation: notif-drain var(--nt-life, 10000ms) linear forwards;
}
@keyframes notif-drain { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* GANG-OS speaking from outside the phone: the gang-chat toast wears the
   burner terminal's chrome -- all-Courier, phosphor green, a bracketed
   [ GANG CHAT ] source line -- so it can never be mistaken for a friend
   DM. Same plate cut as every other toast; only the skin changes. */
.notif-toast.notif-gang {
  background: rgba(3, 14, 5, 0.96);
  border-color: rgba(134, 255, 102, 0.4);
  border-left-color: #57d63f;
  box-shadow: 0 0 24px rgba(87, 214, 63, 0.15);
}
.notif-toast.notif-gang:hover { border-color: rgba(134, 255, 102, 0.8); }
.notif-gang .notif-src {
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 3px;
  color: #57d63f;
  text-shadow: 0 0 6px rgba(57, 255, 20, 0.35);
}
.notif-gang .notif-title {
  font-family: 'Courier New', monospace;
  font-weight: bold;
  font-size: 17px;
  letter-spacing: 1px;
  color: #86ff66;
  text-shadow: 0 0 8px rgba(57, 255, 20, 0.4);
}
.notif-gang .notif-body { color: #c9e4c2; letter-spacing: 0.5px; }
.notif-gang .notif-bar { background: #57d63f; box-shadow: 0 0 8px rgba(87, 214, 63, 0.5); }
@keyframes notif-ignite {
  0%   { transform: scaleY(0.05) scaleX(1.03); filter: brightness(3); }
  100% { transform: none; filter: none; }
}
/* Cooling out: JS removes the node on a timeout, never animationend, so
   reduced-motion (below) can kill these without leaking toasts. */
.notif-out {
  animation: notif-cool 0.3s ease-in forwards;
  pointer-events: none;
}
@keyframes notif-cool {
  to { opacity: 0; transform: translateY(-10px) scaleY(0.9); filter: brightness(1.8); }
}

/* ---- the bell ---- */
#social-bell {
  flex: none;
  position: relative;
  width: 42px;
  height: 42px;
  /* space-between header: the auto margin drags bell + close together to
     the right edge instead of stranding the bell mid-header. */
  margin-left: auto;
  background: none;
  border: 1px solid var(--sc-dim);
  color: var(--sc-ink);
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#social-bell svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linejoin: round;
}
#social-bell:hover {
  color: #fff;
  border-color: var(--sc-soft);
  transform: translate(-2px, -2px) scale(1.06);
}
#social-bell .social-count {
  position: absolute;
  top: -9px;
  right: -9px;
  margin: 0;
  background: #ff6a6a;
  color: #1a0404;
  /* Unread must read as an ALERT even while the drawer drifts hue. */
  filter: hue-rotate(calc(-1 * var(--logo-hue)));
}

/* ---- the log view (bell replaces the whole drawer face) ---- */
#social-notifs {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
#social-notifs.hidden { display: none; }
#social-notifs-head {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 24px;
  border-bottom: 1px solid var(--sc-dim);
}
#social-notifs-back {
  flex: none;
  padding: 8px 14px;
  background: none;
  border: 1px solid var(--sc-dim);
  color: var(--sc-ink);
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 2px;
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#social-notifs-back:hover {
  color: #fff;
  border-color: var(--sc-soft);
  transform: translate(-2px, -2px);
}
#social-notifs-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 22px;
  letter-spacing: 3px;
  color: #eafff0;
}
#social-notif-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  list-style: none;
  margin: 0;
  padding: 8px 16px 24px;
}

@media (prefers-reduced-motion: reduce) {
  .notif-toast, .notif-out { animation: none !important; }
  .notif-bar { animation: none !important; transform: scaleX(1); }
}

/* The drawer's avatars counter-rotate the drawer's hue drift -- but the
   toast layer does not drift, so out here that "correction" IS a tint,
   cycling a person's face with the logo. No rotation to cancel: none. */
#notif-toasts .social-av img { filter: none; }
/* ...except inside a gang toast, where the face gets the same phosphor
   wash as the chat rail -- full color would break the terminal skin. */
#notif-toasts .notif-gang .social-av img {
  filter: grayscale(1) sepia(1) saturate(2.4) hue-rotate(60deg) brightness(0.95);
}

/* ---- the mute switch, right end of the log's own header ----
   It used to sit beside the bell in the drawer header, appearing only on
   the log page -- and the appearing was the bug: a third 42px control up
   there squeezed the title, wrapped the kata, and the whole header
   reflowed. Down here it exists exactly when its page does, and the
   drawer header never changes shape. */
#social-mute {
  flex: none;
  width: 42px;
  height: 42px;
  margin-left: auto;   /* pushes it to the far end, opposite BACK */
  background: none;
  border: 1px solid var(--sc-dim);
  color: var(--sc-ink);
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#social-mute svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linejoin: round;
  stroke-linecap: round;
}
#social-mute:hover {
  color: #fff;
  border-color: var(--sc-soft);
  transform: translate(-2px, -2px) scale(1.06);
}
/* Sound on: waves, no slash. Sound off: slash, no waves. The two
   states differ in SHAPE, not just colour -- and the speaker outline
   differs from the bell next to it, which is the point. */
#social-mute[data-on="1"] .nm-slash { opacity: 0; }
#social-mute[data-on="0"] .nm-wave { opacity: 0; }
/* Muted announces itself: the slash draws itself across, and the whole
   control goes red, because "you have turned something off" should never
   be a thing you have to squint at. */
#social-mute[data-on="0"] {
  color: #ff6a6a;
  border-color: rgba(255, 106, 106, 0.45);
  filter: hue-rotate(calc(-1 * var(--logo-hue)));
}
#social-mute[data-on="0"] .nm-slash {
  opacity: 1;
  stroke-dasharray: 24;
  animation: nm-slash-draw 0.22s ease-out;
}
@keyframes nm-slash-draw {
  from { stroke-dashoffset: 24; }
  to   { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #social-mute[data-on="0"] .nm-slash { animation: none; }
  #social-mute:hover { transform: none; }
}

/* ---- locked menu item (party members cannot start games) ----
   Dimmed and struck-through-feeling rather than removed: the item still
   has to be findable, and its tooltip is the explanation. */
.mp-item-locked { cursor: not-allowed; }
.mp-item-locked .mp-item-label {
  color: #4f7a48;
  text-shadow: none;
}
.mp-item-locked .mp-item-kata { opacity: 0.35; }
/* Kill the hover lift -- a control that cannot be pressed must not feel
   pressable. */
.mp-item-locked:hover .mp-item-label {
  color: #4f7a48;
  transform: none;
  text-shadow: none;
}

/* ---- mode picking is the party host's call ----
   Locked, not hidden: a member should still be able to read what the modes
   are and which one is selected. Only the press is refused. */
.mp-tile-locked { cursor: not-allowed; opacity: 0.55; }
.mp-tile-locked:hover { transform: none; }
/* The selected tile stays legible even while locked -- knowing what your
   party is about to play is the reason this panel still opens. */
.mp-tile-locked.mp-tile-on { opacity: 0.85; }
#mp-modal-foot.mp-foot-locked { color: #ff6a6a; letter-spacing: 2px; }
/* CO-OP teaser tile: not a real, selectable gamemode, so its "SOON" flag
   has to be forced on -- .mp-tile-flag is only shown by .mp-tile-on
   otherwise, and this tile can never be the selected one. Its own class
   (not just .mp-tile-locked, which real tiles also get when you can't
   switch to them) so this doesn't leak the flag onto those. */
.mp-tile-teaser .mp-tile-flag { opacity: 1; transform: none; background: rgba(255, 220, 120, 0.85); }
/* The shared entrance animation (mp-tile-in) fades every tile to opacity:1,
   which then snaps down to .mp-tile-locked's 0.55 the instant it ends --
   this tile reads as "pops up bright, then darkens" because it's the one
   tile that's ALWAYS locked, not just locked for the current viewer. Skip
   the animation so it's dimmed from the very first frame. */
.mp-tile-teaser { animation: none; opacity: 0.55; }
/* Two players, not the usual single-mode glyph (waveform/record/mic) --
   this is the one tile that's about a PAIR, not a solo act. Same
   --mp-accent the other tiles' art uses, same box, just a plain SVG
   instead of the div-soup those build their motifs from. */
.mp-coop-icon {
  width: clamp(64px, calc(7 * var(--dvw, 1vw)), 96px);
  height: clamp(64px, calc(7 * var(--dvw, 1vw)), 96px);
  color: var(--mp-accent);
  filter: drop-shadow(0 0 8px var(--mp-accent-dim));
}
.mode-panel-locked { cursor: not-allowed; opacity: 0.5; }
.mode-panel-locked:hover { transform: none; filter: none; }

/* ==================== THE BOARD (TAB) ====================
   Everyone in the room, on one plate, from any screen in the match. Sits
   ABOVE the CRT glass for the same reason the profile card does -- it is
   full of faces, and the glass tints faces -- and below the confirm modal,
   which still owns the screen when it wants an answer. */
#tab-board {
  position: fixed;
  inset: 0;
  z-index: 945;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.62);
  /* The drawer's palette, redeclared: --sc-* is scoped to #social-panel and
     cannot reach a body-level layer, but socialAvatar/social-btn land here. */
  --sc-accent: #39ff14;
  --sc-soft: rgba(57, 255, 20, 0.5);
  --sc-dim: rgba(57, 255, 20, 0.16);
  --sc-ink: #cdeecb;
}
#tab-board.hidden { display: none; }
#tab-board-box {
  width: min(calc(74 * var(--dvw, 1vw)), 1180px);
  max-height: calc(80 * var(--dvh, 1vh));
  display: flex;
  flex-direction: column;
  background: rgba(4, 12, 4, 0.96);
  border: 1px solid var(--sc-soft);
  border-left: 3px solid var(--sc-accent);
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
  box-shadow: 0 0 40px rgba(57, 255, 20, 0.14);
  animation: tb-open 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes tb-open {
  0%   { transform: scaleY(0.04); filter: brightness(2.6); }
  100% { transform: none; filter: none; }
}
#tab-board-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 18px 26px 14px;
  border-bottom: 1px solid var(--sc-dim);
}
#tab-board-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  letter-spacing: 3px;
  color: #eafff0;
  text-shadow: 0 0 16px var(--sc-soft);
}
#tab-board-count {
  font-family: 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 2px;
  color: rgba(150, 210, 145, 0.75);
}
#tab-board-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  list-style: none;
  margin: 0;
  padding: 10px 18px 18px;
}
.tb-row {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 10px 14px;
  border: 1px solid transparent;
  border-left: 3px solid var(--sc-dim);
  margin-bottom: 8px;
  background: linear-gradient(to right, rgba(57, 255, 20, 0.05), transparent 60%);
}
.tb-row:hover { border-color: var(--sc-dim); border-left-color: var(--sc-accent); }
/* Your own row reads as yours without needing to find your name. */
.tb-me { border-left-color: #ffcc00; }
/* Sitting out: present, not playing. Dimmed the same way the roster does it. */
.tb-benched { opacity: 0.5; }
.tb-row .player-icon { flex: 0 0 auto; width: 46px; height: 46px; line-height: 46px; font-size: 24px; }
.tb-text { flex: 1; min-width: 0; }
.tb-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 1px;
  color: #d8ffd0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tb-sub {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: rgba(150, 210, 145, 0.7);
  margin-top: 2px;
}
.tb-acts { display: flex; gap: 8px; flex: none; }
.tb-acts .social-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#tab-board-foot {
  padding: 12px 26px 16px;
  border-top: 1px solid var(--sc-dim);
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 3px;
  color: rgba(150, 210, 145, 0.5);
  text-align: center;
}
@media (prefers-reduced-motion: reduce) {
  #tab-board-box { animation: none; }
}

/* ---- per-person volume, on the board ---- */
.tb-vol {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-right: 4px;
}
.tb-vol input[type="range"] {
  width: 132px;
  height: 4px;
  -webkit-appearance: none;
  appearance: none;
  background: var(--sc-dim);
  outline: none;
  cursor: pointer;
}
.tb-vol input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 18px;
  background: var(--sc-accent);
  box-shadow: 0 0 8px var(--sc-soft);
  /* Angular, like every other handle in here -- no round thumbs. */
  clip-path: polygon(0 0, 100% 0, 100% 72%, 50% 100%, 0 72%);
  cursor: pointer;
}
.tb-vol input[type="range"]::-moz-range-thumb {
  width: 14px;
  height: 18px;
  border: none;
  border-radius: 0;
  background: var(--sc-accent);
  box-shadow: 0 0 8px var(--sc-soft);
  cursor: pointer;
}
.tb-vol b {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  font-weight: normal;
  letter-spacing: 1px;
  color: rgba(150, 210, 145, 0.85);
  /* Fixed width so the row does not twitch as the number changes width. */
  min-width: 30px;
  text-align: right;
}

/* ==================== DISQUALIFIED ====================
   The submission window closed without a track. A game-flow screen, so it
   carries NO glitch effects -- that rule holds from the beat vote through
   results and this sits inside it. Red, because this is the one screen in
   the match that is telling you something went wrong. */
#dq-screen {
  position: fixed;
  inset: 0;
  z-index: 935;            /* over the match, under the board and the modal */
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(6, 0, 0, 0.82);
  animation: dq-in 0.28s ease-out;
}
#dq-screen.hidden { display: none; }
@keyframes dq-in { from { opacity: 0; } to { opacity: 1; } }
#dq-box {
  text-align: center;
  padding: 46px 64px 40px;
  background: rgba(14, 3, 3, 0.96);
  border: 1px solid rgba(255, 106, 106, 0.5);
  border-left: 3px solid #ff6a6a;
  clip-path: polygon(0 0, calc(100% - 22px) 0, 100% 22px, 100% 100%, 22px 100%, 0 calc(100% - 22px));
  box-shadow: 0 0 46px rgba(255, 60, 60, 0.16);
}
#dq-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(54px, calc(5 * var(--dvw, 1vw)), 92px);
  letter-spacing: 6px;
  color: #ff6a6a;
  text-shadow: 0 0 26px rgba(255, 60, 60, 0.55);
  line-height: 1;
}
#dq-kata {
  font-size: 20px;
  letter-spacing: 8px;
  color: rgba(255, 106, 106, 0.5);
  margin-top: 8px;
}
#dq-line {
  font-family: 'Courier New', monospace;
  font-size: 21px;
  letter-spacing: 3px;
  color: #ffd9d9;
  margin-top: 26px;
}
#dq-sub {
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 2px;
  color: rgba(255, 190, 190, 0.62);
  margin-top: 10px;
}
#dq-ok {
  margin-top: 30px;
  padding: 13px 30px;
  background: none;
  border: 1px solid rgba(255, 106, 106, 0.55);
  color: #ffd9d9;
  font-family: 'Courier New', monospace;
  font-size: 18px;
  letter-spacing: 3px;
  cursor: pointer;
  transition: color 0.18s, border-color 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
#dq-ok:hover {
  color: #fff;
  border-color: #ff6a6a;
  transform: translate(-2px, -2px);
}
@media (prefers-reduced-motion: reduce) {
  #dq-screen { animation: none; }
  #dq-ok:hover { transform: none; }
}

/* ==================== THE OFFICIAL LINKS ====================
   Bottom-CENTRE, because both bottom corners are already taken -- the
   profile plate on the left, the gang plate on the right -- and this must
   not crowd either. Sits on the home screen only. */
#home-socials {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  /* Level with the corner plates, using the same token they do, so moving
     that stack can never leave this row floating at a different height. */
  bottom: var(--hpb-bottom, 16px);
  z-index: 600;
  display: flex;
  align-items: center;
  gap: 18px;
  /* Rides the logo's hue drift like the rest of the menu furniture. */
  filter: hue-rotate(var(--logo-hue));
}
/* Home only. Every other screen has its own business at the bottom. */
body:not(:has(#screen-home.active)) #home-socials { display: none; }

.hs-link {
  width: 46px;
  height: 46px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #39ff14;
  border: 1px solid rgba(57, 255, 20, 0.22);
  /* One clipped corner each way -- the house plate cut, at button size. */
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  background: rgba(4, 12, 4, 0.5);
  cursor: pointer;
  transition:
    color 0.16s ease-out,
    border-color 0.16s ease-out,
    box-shadow 0.18s ease-out,
    transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hs-link svg {
  width: 24px;
  height: 24px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linejoin: round;
  stroke-linecap: round;
}
/* The house hover: it pops toward you rather than just lighting up. */
.hs-link:hover {
  color: #eafff0;
  border-color: #39ff14;
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.45), inset 0 0 10px rgba(57, 255, 20, 0.12);
  transform: translate(-2px, -2px) scale(1.08);
}
.hs-link:active { transform: translate(0, 0) scale(0.98); }

/* No URL yet: present, obviously not ready, and does nothing. Better than a
   button that looks live and goes nowhere. */
.hs-dead {
  opacity: 0.32;
  cursor: not-allowed;
  pointer-events: auto; /* still hoverable, so the title can explain */
}
.hs-dead:hover {
  color: #39ff14;
  border-color: rgba(57, 255, 20, 0.22);
  box-shadow: none;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .hs-link:hover, .hs-link:active { transform: none; }
}



/* ==========================================================================
   THE SHOP
   Two shelves, two worlds. Every colour below comes from a token set on
   #screen-store, and the ONLY thing the cash shelf does is redefine those
   tokens -- so the page changes character wholesale rather than having a
   different accent bolted onto the same green page.

     BLACK MARKET  violet, lit, gritty. A night market: hot neon, visible
                   grid texture, chunky corner cuts, heavy glow.
     $$$           black and gold. Sleek and modern: near-black ground,
                   hairline rules, small precise cuts, restrained glow, more
                   air. Expensive things do not shout.

   Sizing is measured against UI already signed off -- home menu titles are
   48 design px -- and the document is zoom:0.5, so every value here renders
   at HALF on screen. That halving is what made the first attempt read tiny.
   ========================================================================== */

#screen-store {
  /* ---- BLACK MARKET (default shelf) ---- */
  --sh-accent:   #c04dff;
  --sh-accent-2: #e6b3ff;
  --sh-ink:      #f3e8ff;
  --sh-dim:      #8f76a8;
  --sh-ground:   #0a0510;
  --sh-plate:    rgba(22, 10, 34, 0.9);
  --sh-art:      #0d0616;
  --sh-line:     rgba(192, 77, 255, 0.26);
  --sh-line-hot: #c04dff;
  --sh-grid:     rgba(192, 77, 255, 0.07);
  --sh-glow:     rgba(192, 77, 255, 0.55);
  --sh-glow-soft: rgba(192, 77, 255, 0.22);
  --sh-ex1:      rgba(96, 30, 140, 0.6);
  --sh-ex2:      rgba(72, 22, 105, 0.45);
  --sh-ex3:      rgba(48, 15, 70, 0.3);
  --sh-cut:      22px;
  --sh-cut-sm:   12px;
  --sh-wash:     radial-gradient(120% 70% at 50% -10%, rgba(192, 77, 255, 0.13), transparent 68%);
}

/* ---- $$$ : black and gold ---- */
#screen-store.shelf-cash {
  --sh-accent:   #e8c46a;
  --sh-accent-2: #fff3d0;
  --sh-ink:      #f7f1e2;
  --sh-dim:      #8a8071;
  --sh-ground:   #070707;
  --sh-plate:    rgba(15, 14, 12, 0.94);
  --sh-art:      #0b0a09;
  /* Hairlines, not neon tubes -- the whole difference between the two
     shelves lives in these three values. */
  --sh-line:     rgba(232, 196, 106, 0.22);
  --sh-line-hot: #e8c46a;
  --sh-grid:     rgba(232, 196, 106, 0.035);
  --sh-glow:     rgba(232, 196, 106, 0.3);
  --sh-glow-soft: rgba(232, 196, 106, 0.1);
  --sh-ex1:      rgba(120, 96, 40, 0.45);
  --sh-ex2:      rgba(88, 70, 28, 0.32);
  --sh-ex3:      rgba(58, 46, 18, 0.2);
  /* Smaller, more precise cuts. Restraint is the look. */
  --sh-cut:      10px;
  --sh-cut-sm:   6px;
  --sh-wash:     linear-gradient(180deg, rgba(232, 196, 106, 0.05), transparent 42%);
}

#screen-store.active {
  display: flex;
  flex-direction: column;
  gap: 18px;
  /* The default .screen cap (1780px) was too narrow for a wall of product,
     but full-bleed was too wide -- with the grid packing from the left, all
     the slack collected on the right and the shop sat off-centre. This is a
     wider cap, CENTRED, so the margins are equal on both sides. */
  max-width: 2280px;
  width: 100%;
  margin-inline: auto;
  min-height: calc(100 * var(--dvh, 1vh) - 62px);
  /* Bottom padding clears #home-profile-badge and #jukebox, which are
     FIXED to the bottom corners and float over every screen -- without it
     the last row of cards sits under them. Right padding on the bar below
     clears #social-toggle for the same reason. */
  /* CENTRED down the screen rather than jammed against the top edge.
     "safe" is doing real work: a plain centred flex column whose content
     outgrows the viewport clips its own top and the first row becomes
     unreachable. safe centre falls back to start the moment that would
     happen, so it centres a small catalog and scrolls a large one. */
  justify-content: safe center;
  padding: clamp(24px, 4 * var(--dvh, 1vh), 90px)
           clamp(20px, 2.4 * var(--dvw, 1vw), 56px)
           clamp(60px, 8 * var(--dvh, 1vh), 150px);
  /* The ground is painted by the ::before below, NOT here. This element is
     sized with --dvh, which is PINNED to the 1440-tall design canvas -- so
     it is always 720 real px tall however tall the window is, and on
     anything taller it stopped short and left the page grey showing beneath
     it. */
  isolation: isolate;
}
/* The shop ground, across the REAL viewport. position:fixed ignores the
   pinned design units entirely, so this fills whatever the window actually
   is. z-index -1 keeps it behind the shop content, and the isolate above
   keeps that -1 from escaping behind the page itself. Costs nothing: it is
   two background layers, not a canvas. */
#screen-store.active::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-color: var(--sh-ground);
  background-image: var(--sh-wash);
  /* Cross-fades on a shelf change, so switching tabs reads as walking into
     a different room rather than a repaint. */
  transition: background-color 0.45s ease;
}

/* ---- top chrome ---- */
#shop-bar {
  display: flex;
  align-items: center;
  gap: clamp(18px, 2 * var(--dvw, 1vw), 44px);
  padding: 14px 0 12px;
  /* Room for the two fixed controls that float over this bar: the social
     pull-out at top right, and the LEAVE plate at top left. Neither is in
     the flow, so the bar has to leave the space rather than be pushed. */
  padding-right: 130px;
  padding-left: 250px;
  border-bottom: 1px solid var(--sh-line);
  flex-wrap: nowrap;
}
#shop-mark {
  font-family: 'Courier New', monospace;
  font-size: 30px;
  font-weight: bold;
  letter-spacing: 6px;
  color: var(--sh-ink);
  text-shadow: 0 0 18px var(--sh-glow);
  display: flex;
  align-items: baseline;
  gap: 14px;
}
#shop-mark em {
  font-style: normal;
  font-size: 13px;
  letter-spacing: 3px;
  color: var(--sh-accent);
  opacity: 0.7;
}

#shop-tabs { display: flex; gap: 4px; }
.shop-tab {
  background: none;
  border: 0;
  border-bottom: 4px solid transparent;
  color: var(--sh-dim);
  font-family: 'Courier New', monospace;
  font-size: 24px;
  font-weight: bold;
  letter-spacing: 4px;
  padding: 12px 20px 10px;
  cursor: pointer;
  white-space: nowrap;
  display: flex;
  align-items: baseline;
  gap: 12px;
  transition: color 0.25s, border-color 0.25s,
              transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.shop-tab em { font-style: normal; font-size: 12px; letter-spacing: 2px; opacity: 0.6; }
.shop-tab:hover { transform: translateY(-2px); }
.shop-tab:focus-visible { outline: 2px solid var(--sh-accent); outline-offset: 3px; }

/* Each tab is coloured by the world it leads TO, not by the current theme --
   that is what keeps the other shelf legible as a place to go rather than a
   dead label in the palette you are already in. */
.shop-tab-cred { color: #7d5a99; }
.shop-tab-cred:hover, .shop-tab-cred.is-on { color: #f3e8ff; text-shadow: 0 0 16px rgba(192, 77, 255, 0.75); }
.shop-tab-cred.is-on { border-bottom-color: #c04dff; }
.shop-tab-cash { color: #8a7a52; }
.shop-tab-cash:hover, .shop-tab-cash.is-on { color: #fff3d0; text-shadow: 0 0 16px rgba(232, 196, 106, 0.6); }
.shop-tab-cash.is-on { border-bottom-color: #e8c46a; }

#shop-tools { margin-left: auto; display: flex; align-items: center; gap: 12px; }

#shop-search {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 52px;
  padding: 0 16px;
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  clip-path: polygon(var(--sh-cut-sm) 0, 100% 0, 100% calc(100% - var(--sh-cut-sm)), calc(100% - var(--sh-cut-sm)) 100%, 0 100%, 0 var(--sh-cut-sm));
  transition: border-color 0.2s, box-shadow 0.2s;
}
#shop-search:focus-within { border-color: var(--sh-line-hot); box-shadow: 0 0 26px var(--sh-glow-soft); }
#shop-search svg { width: 20px; height: 20px; fill: none; stroke: var(--sh-accent); stroke-width: 1.9; stroke-linecap: round; flex: none; }
#shop-q {
  background: none; border: 0; outline: none;
  color: var(--sh-ink);
  font-family: 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 2px;
  width: clamp(140px, 12 * var(--dvw, 1vw), 260px);
}
#shop-q::placeholder { color: var(--sh-dim); }
#shop-q::-webkit-search-cancel-button { -webkit-appearance: none; }

#shop-fav-btn, #shop-bal {
  height: 52px;
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  clip-path: polygon(var(--sh-cut-sm) 0, 100% 0, 100% calc(100% - var(--sh-cut-sm)), calc(100% - var(--sh-cut-sm)) 100%, 0 100%, 0 var(--sh-cut-sm));
}
#shop-fav-btn {
  width: 52px;
  display: grid; place-items: center;
  color: var(--sh-accent);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s, color 0.2s;
}
#shop-fav-btn svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; }
#shop-fav-btn:hover { transform: translateY(-2px); border-color: var(--sh-line-hot); color: var(--sh-accent-2); }
#shop-fav-btn[aria-pressed="true"] { color: var(--sh-accent-2); border-color: var(--sh-line-hot); }
#shop-fav-btn[aria-pressed="true"] svg { fill: currentColor; }

#shop-bal { display: flex; align-items: center; gap: 9px; padding: 0 16px; }
#shop-bal.hidden { display: none; }
#shop-bal svg { width: 21px; height: 21px; fill: none; stroke: var(--sh-accent); stroke-width: 1.5; stroke-linejoin: round; }
#shop-bal-n {
  font-family: 'Courier New', monospace;
  font-size: 24px; font-weight: bold;
  color: var(--sh-ink);
  letter-spacing: 1px;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 0 14px var(--sh-glow);
}

/* ---- sub-header ---- */
#shop-subhead { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
#shop-heading {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: clamp(26px, 2 * var(--dvw, 1vw), 38px);
  font-weight: bold;
  letter-spacing: 5px;
  color: var(--sh-ink);
}
#shop-heading em { font-size: 13px; letter-spacing: 3px; color: var(--sh-accent); opacity: 0.75; }
#shop-sortwrap { display: flex; align-items: center; gap: 12px; }
.shop-k { font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 3px; color: var(--sh-dim); }
#shop-sort {
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  font-family: 'Courier New', monospace;
  letter-spacing: 3px;
  cursor: pointer;
  outline: none;
}
#shop-sort { color: var(--sh-ink); font-size: 16px; padding: 11px 13px; }
#shop-sort option { background: #0b0710; color: #f3e8ff; }
#screen-store.shelf-cash #shop-sort option { background: #0b0a09; color: #f7f1e2; }

/* Category filter -- same shape as #inv-tabs on the profile screen, but
   themed off --sh-accent/--sh-dim so it follows the black-market/cash shelf
   swap instead of carrying its own fixed colour. */
#shop-kind-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px; }
.shop-kind-tab {
  padding: 9px 16px;
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  color: var(--sh-dim);
  cursor: pointer;
  transition: color 0.16s ease-out, border-color 0.16s ease-out, background 0.16s ease-out;
}
.shop-kind-tab:hover { color: var(--sh-accent-2); border-color: var(--sh-accent); }
.shop-kind-tab.is-on {
  color: var(--sh-accent-2);
  border-color: var(--sh-accent);
  background: color-mix(in srgb, var(--sh-accent) 22%, transparent);
}

/* ---- grid ---- */
#shop-grid {
  display: grid;
  /* Fixed 3x3 -- the old reasoning against 1fr tracks (a SMALLER window
     produced BIGGER cards under auto-fill, because fewer columns fit and
     each one grew to claim the leftover width) doesn't apply here anymore:
     the column COUNT is now always 3, paged rather than reflowing, so 1fr
     no longer means "unpredictable size" -- it means "fill the page", which
     is the point now that the catalog is paged 9-at-a-time instead of
     scrolled. Rows are 1fr too, so the page fills the box height as well as
     its width instead of the cards floating at their own intrinsic size. */
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(3, minmax(0, 1fr));
  gap: clamp(14px, 1.2 * var(--dvw, 1vw), 24px);
  /* Height comes from flex:1 1 auto in the #shop-grid rule further down
     (the one that fills the remaining column space in #screen-store), NOT
     from a percentage here -- height:100% here was fighting that flex
     sizing instead of cooperating with it, and the grid was landing at
     whatever its own rows' content wanted rather than the real leftover
     space in the screen. */
}
#shop-empty {
  font-family: 'Courier New', monospace;
  font-size: 20px; letter-spacing: 5px;
  color: var(--sh-dim);
  text-align: center;
  padding: 90px 0;
}
#shop-empty em { font-style: normal; opacity: 0.6; }
#shop-empty.hidden { display: none; }

.shop-card {
  position: relative;
  display: flex;
  flex-direction: column;
  text-align: left;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  clip-path: polygon(var(--sh-cut) 0, 100% 0, 100% calc(100% - var(--sh-cut)), calc(100% - var(--sh-cut)) 100%, 0 100%, 0 var(--sh-cut));
  /* The signature pop: lift toward the viewer with a springy overshoot while
     a stack of receding shadows draws the extrusion wall behind it. */
  transition:
    transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 0.2s, border-color 0.2s, background-color 0.45s ease;
}
.shop-card:hover, .shop-card:focus-visible {
  transform: translate(-4px, -4px);
  border-color: var(--sh-line-hot);
  box-shadow:
    3px 3px 0 var(--sh-ex1),
    6px 6px 0 var(--sh-ex2),
    9px 9px 0 var(--sh-ex3),
    0 0 34px var(--sh-glow-soft);
  outline: none;
}
.shop-card:active { transform: translate(0, 0) scale(0.985); transition-duration: 0.07s; }

.shop-art {
  position: relative;
  /* Cards are now a fixed 3x3 grid cell (see #shop-grid), not an
     aspect-ratio-sized box floating in a reflowing one -- flex-grow fills
     whatever height the card's grid row actually gives it, art taking
     everything .shop-body doesn't need, instead of the box stopping at a
     fixed 16:10 ratio and leaving dead space under a page of only 9 cards. */
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  place-items: center;
  background-color: var(--sh-art);
  background-image:
    linear-gradient(180deg, var(--sh-glow-soft), transparent 58%),
    repeating-linear-gradient(0deg, var(--sh-grid) 0 1px, transparent 1px 30px),
    repeating-linear-gradient(90deg, var(--sh-grid) 0 1px, transparent 1px 30px);
  border-bottom: 1px solid var(--sh-line);
  overflow: hidden;
}
.shop-glyph {
  font-size: clamp(64px, 7 * var(--dvw, 1vw), 160px);
  line-height: 1;
  color: var(--sh-accent-2);
  filter: drop-shadow(0 0 22px var(--sh-glow));
  transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.shop-card:hover .shop-glyph { transform: scale(1.1) rotate(-3deg); }

/* FONT cards preview the PLAYER'S OWN NAME, not a generic "Aa" -- names
   vary wildly in length, and decorative fonts vary wildly in how much
   visual weight the same font-size draws. A fixed/clamped font-size (what
   every other card uses) can't handle that range: it either overflows a
   long name or leaves a short one tiny. invFitFontCell measures the real
   rendered text and binary-searches a size that fits THIS box, same as the
   profile screen's inventory font cells -- so this needs to be a real
   sized box (not an inline span sized to its own content) for that search
   to have a target to fit against. */
.shop-card.is-font-card .shop-glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: 6% 8%;
  text-align: center;
  line-height: 1.05;
  white-space: normal;
  overflow: hidden;
}
/* The glyph is the point of a font card -- name and price are just there to
   say what it's called and what it costs, not compete with the preview. */
.shop-card.is-font-card .shop-name { font-size: 15px; }
.shop-card.is-font-card .shop-price { font-size: 17px; }

/* Rarity reads as a band: colour is the only thing separating tiers, and a
   word there would compete with the item name. */
.shop-rare-bar { position: absolute; top: 0; left: 0; right: 0; height: 3px; }
.r-common .shop-rare-bar { background: var(--sh-dim); }
.r-rare   .shop-rare-bar { background: var(--sh-accent); opacity: 0.75; }
.r-elite  .shop-rare-bar { background: var(--sh-accent); box-shadow: 0 0 14px var(--sh-glow); }
.r-mythic .shop-rare-bar {
  background: linear-gradient(90deg, var(--sh-accent), var(--sh-accent-2), var(--sh-accent));
  box-shadow: 0 0 18px var(--sh-glow);
}
/* CHROMA is the one tier that does NOT read the shelf accent. Every other
   rung is a brightness of the same house colour, which is what makes them a
   ladder -- and a ladder is exactly the wrong shape for a rung that is six
   times the one below it. The spectrum says "not more of the same" at a
   glance, and it is also literally what the item is. */
.r-chroma .shop-rare-bar {
  background: linear-gradient(90deg,
    #ff2f5e, #ffd42f, #7bff4d, #2fb0ff, #a52fff, #ff2f5e);
  box-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
}

.shop-badge {
  position: absolute;
  top: 12px; left: 12px;
  font-family: 'Courier New', monospace;
  font-size: 13px; font-weight: bold; letter-spacing: 2px;
  padding: 5px 10px;
  background: var(--sh-accent);
  color: var(--sh-ground);
}
/* ---- OWNED IS WRITTEN ACROSS THE ITEM, NOT PINNED IN A CORNER ----
   A corner chip is a LABEL you have to go and read; owned-ness needs to be
   answerable from across the grid, while scanning, without focusing any one
   card. So the item itself goes dark and the word sits over it -- the card
   reads as spent at a glance and only the ones you can still buy stay lit.

   THE DARKENING IS A FILTER ON THE ART CONTENT, NOT A SCRIM OVER THE ART
   BOX. Frame cards have no panel and no art background (see
   .is-frame-card .shop-art) -- an inset overlay there would paint a grey
   rectangle into a card that deliberately has no rectangle, and the frame's
   halo overhangs the box anyway, so a scrim clipped to it would leave a lit
   glow around a dimmed plate. Filtering the glyph/face dims the item and
   everything it throws, exactly to its own edges, on both card shapes.

   Hover lifts the dim rather than clearing it: you still need to see what
   you own when you go looking at it, but it must never come back up to the
   brightness of something still for sale. */
.shop-card.is-owned .shop-glyph,
.shop-card.is-owned .shop-frame-face {
  filter: brightness(0.34) saturate(0.5);
  transition: filter 0.18s;
}
.shop-card.is-owned:hover .shop-glyph,
.shop-card.is-owned:hover .shop-frame-face,
.shop-card.is-owned:focus-visible .shop-glyph,
.shop-card.is-owned:focus-visible .shop-frame-face {
  filter: brightness(0.55) saturate(0.72);
}
.shop-badge.is-owned {
  top: 50%; left: 50%;
  right: auto;
  transform: translate(-50%, -50%);
  z-index: 4;
  background: transparent;
  color: var(--sh-ink);
  font-size: 14px;
  letter-spacing: 5px;
  /* Rules top and bottom instead of a filled chip: a solid block over the
     art is a second object sitting on the item, a ruled word reads as a
     stamp ON it. The right-side letter-spacing padding keeps the tracked
     text optically centred -- letter-spacing adds a trailing gap after the
     last character that translate(-50%) would otherwise count as ink. */
  padding: 5px 7px 5px 12px;
  border-top: 1px solid var(--sh-dim);
  border-bottom: 1px solid var(--sh-dim);
  text-shadow: 0 0 12px rgba(0, 0, 0, 0.95), 0 0 4px rgba(0, 0, 0, 1);
  pointer-events: none;
}

.shop-fav {
  position: absolute;
  top: 10px; right: 10px;
  width: 40px; height: 40px;
  display: grid; place-items: center;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid var(--sh-line);
  color: var(--sh-accent);
  cursor: pointer;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 0.18s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.18s;
}
.shop-card:hover .shop-fav, .shop-fav[aria-pressed="true"] { opacity: 1; transform: translateY(0); }
.shop-fav svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; }
.shop-fav[aria-pressed="true"] { color: var(--sh-accent-2); border-color: var(--sh-line-hot); }
.shop-fav[aria-pressed="true"] svg { fill: currentColor; }

.shop-body { padding: 14px 18px 18px; display: flex; flex-direction: column; gap: 3px; }
.shop-kind { font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 3px; color: var(--sh-dim); }
.shop-name {
  font-family: 'Courier New', monospace;
  font-size: 23px; font-weight: bold; letter-spacing: 2px;
  color: var(--sh-ink);
  line-height: 1.2;
}
.shop-price-row { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; }
.shop-price {
  font-family: 'Courier New', monospace;
  font-size: 26px; font-weight: bold;
  color: var(--sh-accent-2);
  font-variant-numeric: tabular-nums;
  letter-spacing: 1px;
}
.shop-price-k { font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 2px; color: var(--sh-dim); }
/* Cannot afford it: the price goes red, the card stays open. Seeing what you
   are saving toward is most of what a shop does before you can afford it. */
.shop-card.too-dear .shop-price { color: #ff6b4a; }
.shop-card.is-owned .shop-price { color: var(--sh-dim); }

/* A part-full last page (say 4 items on a 9-slot page) fills the rest with
   these -- same dashed-and-empty language .inv-cell-empty already uses on
   the profile screen's inventory grid, so the page still reads as a full
   3x3 sheet instead of the grid rows collapsing around whatever few cards
   are actually on that page. */
.shop-card-empty {
  cursor: default;
  border-style: dashed;
  border-color: var(--sh-dim);
  opacity: 0.3;
  background: transparent;
  box-shadow: none;
}
.shop-card-empty:hover { transform: none; border-color: var(--sh-dim); box-shadow: none; }
.shop-dots { display: flex; gap: 7px; margin-left: auto; }
.shop-dots i { width: 15px; height: 15px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); }

/* ---- detail ---- */
#shop-detail { position: fixed; inset: 0; z-index: 950; }
#shop-detail.hidden { display: none; }
#shop-detail-scrim {
  position: absolute; inset: 0;
  background: rgba(0, 0, 0, 0.84);
  backdrop-filter: blur(7px);
  animation: sd2Scrim 0.22s ease-out;
}
@keyframes sd2Scrim { from { opacity: 0; } to { opacity: 1; } }

/* ONE compact card, sized to its content. It used to be a 1500px two-column
   spread whose right half was a near-empty preview stage -- on a wide screen
   that read as a broken layout rather than a product page. */
#shop-detail-panel {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: min(520px, 92vw);
  max-height: 88vh;
  padding: clamp(18px, 2vw, 26px);
  display: flex; flex-direction: column; gap: 10px;
  background: var(--sh-ground);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--sh-line-hot);
  clip-path: polygon(calc(var(--sh-cut) + 8px) 0, 100% 0, 100% calc(100% - var(--sh-cut) - 8px), calc(100% - var(--sh-cut) - 8px) 100%, 0 100%, 0 calc(var(--sh-cut) + 8px));
  overflow: auto;
  animation: sd2In 0.26s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes sd2In {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.94); }
  to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
#shop-detail-close {
  position: absolute; top: 12px; right: 12px; z-index: 2;
  width: 36px; height: 36px;
  background: rgba(0, 0, 0, 0.6);
  border: 1px solid var(--sh-line);
  color: var(--sh-accent);
  font-size: 19px;
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.2s, border-color 0.2s;
}
#shop-detail-close:hover { transform: translateY(-2px); color: var(--sh-accent-2); border-color: var(--sh-line-hot); }

#sd2-art {
  aspect-ratio: 16 / 10;
  max-height: 230px;
  flex: none;
  display: grid; place-items: center;
  background-color: var(--sh-art);
  background-image:
    repeating-linear-gradient(0deg, var(--sh-grid) 0 1px, transparent 1px 30px),
    repeating-linear-gradient(90deg, var(--sh-grid) 0 1px, transparent 1px 30px);
  border: 1px solid var(--sh-line);
}
#sd2-glyph { font-size: 96px; line-height: 1; color: var(--sh-accent-2); filter: drop-shadow(0 0 28px var(--sh-glow)); }
/* Font items swap the fixed-size shape glyph for the player's own name,
   sized to fit by invFitFontCell (see shopOpen) -- same reasoning as
   .shop-card.is-font-card .shop-glyph: it needs to be a real sized box for
   that fit search to target, not an inline span sized to its own text. */
#sd2-glyph.is-font-glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: 6% 8%;
  text-align: center;
  line-height: 1.05;
  white-space: normal;
  overflow: hidden;
}
#sd2-kind { font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 3px; color: var(--sh-accent); }
#sd2-name {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 30px; font-weight: bold; letter-spacing: 2px;
  color: var(--sh-ink);
  line-height: 1.15;
}
#sd2-desc { margin: 0; font-size: 16px; line-height: 1.55; color: var(--sh-dim); }
#sd2-includes { border-top: 1px solid var(--sh-line); padding-top: 14px; }
.sd2-inc-k { font-family: 'Courier New', monospace; font-size: 12px; letter-spacing: 3px; color: var(--sh-dim); margin-bottom: 10px; }
#sd2-inc-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
#sd2-inc-list li {
  font-family: 'Courier New', monospace;
  font-size: 15px; color: var(--sh-ink);
  display: flex; align-items: center; gap: 9px;
}
#sd2-inc-list li::before { content: ''; width: 6px; height: 6px; background: var(--sh-accent); flex: none; }

#sd2-buyrow {
  margin-top: 6px; padding-top: 16px;
  display: flex; align-items: center; gap: 16px;
  border-top: 1px solid var(--sh-line);
}
#sd2-price {
  font-family: 'Courier New', monospace;
  font-size: 26px; font-weight: bold;
  color: var(--sh-accent-2);
  font-variant-numeric: tabular-nums;
  text-shadow: 0 0 14px var(--sh-glow);
}
#sd2-buy { margin-left: auto; }
#sd2-buy[disabled] { opacity: 0.45; cursor: not-allowed; filter: grayscale(0.6); }
#sd2-note {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 13px; letter-spacing: 1px; line-height: 1.5;
  color: var(--sh-dim);
  min-height: 1.55em;
}
#sd2-note.warn { color: #ff6b4a; }

/* The rarity that used to ride the preview mock's border now rides the art
   frame, which is the only plate left. */
#sd2-art.rar-rare   { border-color: var(--sh-line-hot); }
#sd2-art.rar-elite  { border-color: var(--sh-line-hot); box-shadow: 0 0 26px var(--sh-glow-soft); }
#sd2-art.rar-mythic {
  border-color: var(--sh-accent-2);
  box-shadow: 0 0 40px var(--sh-glow), inset 0 0 30px var(--sh-glow-soft);
}

/* The house BUY slab is sized for a full screen; in a 520px card it needs to
   be a button, not a banner. */
#shop-detail .jp-btn { padding: 10px 34px; font-size: 22px; }

@media (max-width: 900px) {
  #shop-tools { width: 100%; margin-left: 0; }
  #shop-search { flex: 1; }
  #shop-q { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  #screen-store.active, .shop-card, .shop-tab, #shop-fav-btn,
  .shop-fav, #shop-detail-panel, #shop-detail-scrim, .shop-glyph {
    transition: none !important;
    animation: none !important;
  }
}

/* The stock .jp-btn is hard-coded green -- BACK and BUY were the last two
   things on this screen still painting the old palette over a violet or gold
   page. Retinted through the shelf tokens, scoped to the shop so the button
   everywhere else in the game is untouched.

   The hover extrusion is a twelve-stop hand-tuned green stack; rather than
   restate it per shelf, the slab keeps its shape and the COLOUR comes from
   the tokens, with the extrusion reduced to a shorter neutral-dark stack
   that reads correctly on both grounds. */
#screen-store .jp-btn {
  background: var(--sh-plate);
  border-color: var(--sh-accent);
  color: var(--sh-accent);
  text-shadow:
    1px 1px 0 rgba(0, 0, 0, 0.8),
    2px 2px 0 rgba(0, 0, 0, 0.55),
    0 0 10px var(--sh-glow);
}
#screen-store .jp-btn:hover:not(:disabled) {
  color: var(--sh-accent-2);
  border-color: var(--sh-accent-2);
  text-shadow:
    1px 1px 0 rgba(0, 0, 0, 0.9),
    2px 2px 0 rgba(0, 0, 0, 0.85),
    3px 3px 0 rgba(0, 0, 0, 0.8),
    4px 4px 0 rgba(0, 0, 0, 0.7),
    5px 5px 0 rgba(0, 0, 0, 0.6),
    6px 6px 0 rgba(0, 0, 0, 0.45),
    0 0 18px var(--sh-glow);
}
/* The detail panel is a fixed overlay and therefore NOT a descendant of
   #screen-store, so it needs the tint applied on its own. */
#shop-detail .jp-btn {
  background: var(--sh-plate);
  border-color: var(--sh-accent);
  color: var(--sh-accent);
  text-shadow:
    1px 1px 0 rgba(0, 0, 0, 0.8),
    2px 2px 0 rgba(0, 0, 0, 0.55),
    0 0 10px var(--sh-glow);
}
#shop-detail .jp-btn:hover:not(:disabled) {
  color: var(--sh-accent-2);
  border-color: var(--sh-accent-2);
}
/* The overlay lives outside the screen, so it cannot inherit the shelf
   tokens -- it carries its own copy, switched by the same is-cash class the
   detail already sets. */
#shop-detail {
  --sh-accent:   #c04dff;
  --sh-accent-2: #e6b3ff;
  --sh-ink:      #f3e8ff;
  --sh-dim:      #8f76a8;
  --sh-ground:   #0a0510;
  --sh-plate:    rgba(22, 10, 34, 0.9);
  --sh-art:      #0d0616;
  --sh-line:     rgba(192, 77, 255, 0.26);
  --sh-line-hot: #c04dff;
  --sh-grid:     rgba(192, 77, 255, 0.07);
  --sh-glow:     rgba(192, 77, 255, 0.55);
  --sh-glow-soft: rgba(192, 77, 255, 0.22);
  --sh-cut:      22px;
  --sh-cut-sm:   12px;
  --sh-wash:     radial-gradient(120% 70% at 50% -10%, rgba(192, 77, 255, 0.13), transparent 68%);
}
#shop-detail:has(#shop-detail-panel.is-cash) {
  --sh-accent:   #e8c46a;
  --sh-accent-2: #fff3d0;
  --sh-ink:      #f7f1e2;
  --sh-dim:      #8a8071;
  --sh-ground:   #070707;
  --sh-plate:    rgba(15, 14, 12, 0.94);
  --sh-art:      #0b0a09;
  --sh-line:     rgba(232, 196, 106, 0.22);
  --sh-line-hot: #e8c46a;
  --sh-grid:     rgba(232, 196, 106, 0.035);
  --sh-glow:     rgba(232, 196, 106, 0.3);
  --sh-glow-soft: rgba(232, 196, 106, 0.1);
  --sh-cut:      10px;
  --sh-cut-sm:   6px;
  --sh-wash:     linear-gradient(180deg, rgba(232, 196, 106, 0.05), transparent 42%);
}


/* ==========================================================================
   THE WAGER PANEL
   Before every round. The room stakes, the pot pays the podium.

   Gold rather than the house green, and the only screen in the game that is:
   this is the one moment a player can LOSE something, and it should not look
   like every other menu they click through on autopilot.

   Sizing follows the shop -- the document is zoom-scaled, so every value here
   renders at roughly half on screen.
   ========================================================================== */

#screen-wager {
  --wg-ink:   #ffd257;
  --wg-ink-2: #fff3d0;
  --wg-dim:   #8a7c58;
  --wg-line:  rgba(255, 210, 87, 0.26);
  --wg-hot:   #ffd257;
  --wg-glow:  rgba(255, 210, 87, 0.5);
  --wg-plate: rgba(20, 16, 6, 0.9);
  --wg-ground: #0a0803;
  --wg-risk:  #ff6b4a;
  isolation: isolate;
}
/* Full-viewport ground: screens are sized with --dvh, which is pinned to the
   1440-tall design canvas, so the element itself stops short on a taller
   window and the page shows through. Same fix as the shop. */
/* An OVERLAY on the menu, not a screen that replaces it. Pressing start
   should put the stakes in front of you while the lobby is still there --
   the hand-off animation into gameplay plays AFTER betting, not before it.
   As a .screen it inherited the screen system's width cap and rendered as a
   narrow column with the menu already torn down behind it. */
#screen-wager {
  position: fixed;
  inset: 0;
  z-index: 940;
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: clamp(14px, 2 * var(--dvh, 1vh), 40px) clamp(20px, 2.4 * var(--dvw, 1vw), 56px);
  background-color: rgba(6, 5, 2, 0.93);
  background-image: radial-gradient(120% 80% at 50% 0%, rgba(255, 210, 87, 0.1), transparent 66%);
  backdrop-filter: blur(6px);
  animation: wgIn 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
#screen-wager.hidden { display: none; }
@keyframes wgIn { from { opacity: 0; } to { opacity: 1; } }

/* ---- head ---- */
#wg-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 24px;
  padding: 4px 0 12px;
  border-bottom: 1px solid var(--wg-line);
}
#wg-title {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 44px; font-weight: bold; letter-spacing: 8px;
  color: var(--wg-ink-2);
  text-shadow: 0 0 20px var(--wg-glow);
}
#wg-title em { font-size: 16px; letter-spacing: 4px; color: var(--wg-ink); opacity: 0.7; }

#wg-clock {
  display: flex; align-items: baseline; gap: 8px;
  padding: 10px 22px;
  border: 1px solid var(--wg-line);
  background: var(--wg-plate);
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
#wg-clock-n {
  font-family: 'Courier New', monospace;
  font-size: 40px; font-weight: bold;
  font-variant-numeric: tabular-nums;
  color: var(--wg-ink-2);
}
#wg-clock-k { font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 3px; color: var(--wg-dim); }
/* Under ten seconds it goes red and breathes. The deadline is real -- if you
   have not locked in by then you are staked at whatever the dial says. */
#wg-clock.urgent { border-color: var(--wg-risk); }
#wg-clock.urgent #wg-clock-n { color: var(--wg-risk); animation: wgPulse 1s ease-in-out infinite; }
@keyframes wgPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }

/* ---- body ---- */
#wg-body { display: grid; grid-template-columns: minmax(320px, 460px) 1fr; gap: clamp(20px, 2vw, 44px); flex: 1; }

.wg-plate {
  background: var(--wg-plate);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px var(--wg-line);
  clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
  padding: 20px 24px;
  display: flex; flex-direction: column; gap: 10px;
}
.wg-grow { flex: 1; min-height: 0; }
.wg-k {
  font-family: 'Courier New', monospace;
  font-size: 14px; letter-spacing: 4px; color: var(--wg-dim);
}
.wg-k em { font-style: normal; opacity: 0.6; }
.wg-sub { font-family: 'Courier New', monospace; font-size: 14px; letter-spacing: 2px; color: var(--wg-dim); }

#wg-pot-side { display: flex; flex-direction: column; gap: 16px; }
#wg-pot-n {
  font-family: 'Courier New', monospace;
  font-size: 62px; font-weight: bold; line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--wg-ink-2);
  text-shadow: 0 0 26px var(--wg-glow);
}

#wg-roster { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; overflow-y: auto; }
.wg-row {
  display: flex; align-items: center; gap: 12px;
  padding: 9px 12px;
  border: 1px solid transparent;
  background: rgba(255, 210, 87, 0.04);
  font-family: 'Courier New', monospace;
}
.wg-row.locked { border-color: var(--wg-line); background: rgba(255, 210, 87, 0.1); }
.wg-row-name { font-size: 18px; letter-spacing: 1px; color: var(--wg-ink-2); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wg-row.you .wg-row-name { color: var(--wg-ink); }
.wg-row-amt { font-size: 20px; font-weight: bold; font-variant-numeric: tabular-nums; color: var(--wg-ink-2); }
.wg-row-amt.zero { color: var(--wg-dim); font-weight: normal; }
.wg-row-wait { font-size: 13px; letter-spacing: 2px; color: var(--wg-dim); }

#wg-terms { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
#wg-terms li {
  font-family: 'Courier New', monospace;
  font-size: 14px; line-height: 1.5; color: var(--wg-dim);
  padding-left: 16px; position: relative;
}
#wg-terms li::before { content: ''; position: absolute; left: 0; top: 8px; width: 5px; height: 5px; background: var(--wg-ink); }
#wg-terms b { color: var(--wg-ink-2); font-weight: bold; }

/* ---- the rig ---- */
#wg-rig { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px; }

#wg-num-wrap { position: relative; display: flex; flex-direction: column; align-items: center; }
#wg-num {
  font-family: 'Courier New', monospace;
  font-size: clamp(90px, 9 * var(--dvw, 1vw), 150px);
  font-weight: bold; line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 2px;
  color: var(--wg-ink-2);
  text-shadow: 0 0 34px var(--wg-glow);
  transition: color 0.25s, text-shadow 0.25s;
}
#wg-num-k { font-family: 'Courier New', monospace; font-size: 18px; letter-spacing: 8px; color: var(--wg-dim); margin-top: 4px; }
/* Staking nothing is a legitimate choice, so zero is calm rather than an
   error state -- it just stops glowing. */
#wg-num.zero { color: var(--wg-dim); text-shadow: none; }
/* Past half your balance the number runs hot. Not a warning, a temperature. */
#wg-num.hot { color: #ffb347; text-shadow: 0 0 40px rgba(255, 140, 40, 0.75); }
#wg-num.allin { color: var(--wg-risk); text-shadow: 0 0 46px rgba(255, 80, 50, 0.85); }

/* Heat licks. Height is driven by --heat (0..1) from JS, so the effect grows
   with the stake instead of switching on. */
#wg-heat {
  position: absolute; left: 50%; bottom: -6px;
  transform: translateX(-50%);
  width: 70%; height: 100%;
  display: flex; align-items: flex-end; justify-content: center; gap: 5%;
  opacity: calc(var(--heat, 0) * 0.9);
  pointer-events: none;
  filter: blur(5px);
}
#wg-heat i {
  flex: 1;
  height: calc(var(--heat, 0) * 60%);
  background: linear-gradient(180deg, transparent, rgba(255, 150, 40, 0.85));
  border-radius: 50% 50% 40% 40%;
  animation: wgLick 1.1s ease-in-out infinite;
}
#wg-heat i:nth-child(2n) { animation-duration: 0.85s; }
#wg-heat i:nth-child(3n) { animation-duration: 1.35s; }
@keyframes wgLick {
  0%, 100% { transform: scaleY(0.8); opacity: 0.55; }
  50%      { transform: scaleY(1.15); opacity: 1; }
}

/* ---- presets ---- */
#wg-presets { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
.wg-preset {
  font-family: 'Courier New', monospace;
  font-size: 18px; font-weight: bold; letter-spacing: 3px;
  padding: 12px 22px;
  background: var(--wg-plate);
  border: 1px solid var(--wg-line);
  color: var(--wg-ink);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s, color 0.2s, background-color 0.2s;
}
.wg-preset:hover { transform: translateY(-3px); border-color: var(--wg-hot); color: var(--wg-ink-2); }
.wg-preset:active { transform: translateY(0) scale(0.95); transition-duration: 0.06s; }
.wg-preset.on { background: var(--wg-ink); color: #14100a; border-color: var(--wg-ink); }
.wg-preset.risky { color: var(--wg-risk); border-color: rgba(255, 107, 74, 0.5); }
.wg-preset.risky.on { background: var(--wg-risk); color: #150603; border-color: var(--wg-risk); }
.wg-preset:disabled { opacity: 0.3; cursor: not-allowed; }

/* ---- fader ---- */
#wg-fader { width: min(660px, 100%); cursor: pointer; outline: none; }
#wg-fader:focus-visible .wgf-track { border-color: var(--wg-hot); box-shadow: 0 0 22px var(--wg-glow); }
.wgf-track {
  position: relative;
  height: 46px;
  border: 1px solid var(--wg-line);
  background: rgba(0, 0, 0, 0.5);
  display: flex; align-items: center;
  padding: 0 12px;
}
.wgf-slit { position: absolute; left: 12px; right: 12px; height: 5px; background: rgba(0, 0, 0, 0.75); box-shadow: inset 0 1px 0 rgba(255, 210, 87, 0.18); }
.wgf-fill { position: absolute; left: 12px; height: 5px; width: 0; background: var(--wg-ink); box-shadow: 0 0 16px var(--wg-glow); }
.wgf-cap {
  position: absolute;
  width: 26px; height: 34px;
  margin-left: -13px;
  background: linear-gradient(180deg, #3b3220, #17130a);
  border: 1px solid var(--wg-ink);
  box-shadow: 0 0 18px var(--wg-glow);
  pointer-events: none;
}
.wgf-ends { display: flex; justify-content: space-between; margin-top: 7px; font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 2px; color: var(--wg-dim); }

/* ---- lock in ---- */
#wg-lock {
  position: relative;
  min-width: 340px;
  padding: 20px 52px 26px;
  background: var(--wg-plate);
  border: 2px solid var(--wg-ink);
  clip-path: polygon(18px 0, 100% 0, calc(100% - 18px) 100%, 0 100%);
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 34px; letter-spacing: 5px;
  color: var(--wg-ink);
  cursor: pointer;
  text-shadow: 0 0 12px var(--wg-glow);
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.2s, border-color 0.2s, background-color 0.2s;
}
#wg-lock:hover:not(:disabled) { transform: translate(-5px, -5px); color: var(--wg-ink-2); background: rgba(255, 210, 87, 0.12); }
#wg-lock:active:not(:disabled) { transform: translate(0, 0) scale(0.97); transition-duration: 0.06s; }
#wg-lock:disabled { cursor: default; }
/* Locked: the plate fills and the label becomes a statement rather than an
   instruction. You cannot take it back -- that is the point of a wager. */
#wg-lock.locked { background: var(--wg-ink); color: #14100a; text-shadow: none; }
#wg-lock.locked #wg-tally-txt { color: rgba(20, 16, 10, 0.7); }
#wg-lock.locked .wg-pip { background: rgba(20, 16, 10, 0.28); }
#wg-lock.locked .wg-pip.on { background: #14100a; }

#wg-tally {
  position: absolute; left: 0; right: 0; bottom: 6px;
  display: flex; align-items: center; justify-content: center; gap: 10px;
  font-family: 'Courier New', monospace;
  font-size: 12px; letter-spacing: 3px;
  color: var(--wg-dim);
}
#wg-pips { display: flex; gap: 4px; }
.wg-pip { width: 8px; height: 8px; background: rgba(255, 210, 87, 0.22); }
.wg-pip.on { background: var(--wg-ink); box-shadow: 0 0 8px var(--wg-glow); }

#wg-note {
  margin: 0; min-height: 1.5em;
  font-family: 'Courier New', monospace;
  font-size: 15px; letter-spacing: 2px; color: var(--wg-dim);
  text-align: center;
}
#wg-note.risk { color: var(--wg-risk); }

/* ---- locked out ---- */
#wg-locked {
  flex: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
  text-align: center;
}
#wg-locked.hidden { display: none; }
.wg-lock-mark svg { width: 76px; height: 76px; fill: none; stroke: var(--wg-dim); stroke-width: 1.6; }
#wg-locked-msg {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 26px; letter-spacing: 3px; color: var(--wg-ink-2);
}
#wg-locked-msg b { color: var(--wg-ink); }
#wg-locked-bar { width: min(520px, 80%); height: 12px; border: 1px solid var(--wg-line); background: rgba(0, 0, 0, 0.5); }
#wg-locked-fill { display: block; height: 100%; width: 0; background: var(--wg-ink); box-shadow: 0 0 14px var(--wg-glow); transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1); }
#wg-locked-sub { margin: 0; font-family: 'Courier New', monospace; font-size: 15px; line-height: 1.6; color: var(--wg-dim); max-width: 44em; }
#wg-locked-sub b { color: var(--wg-ink-2); }

@media (max-width: 1000px) {
  #wg-body { grid-template-columns: 1fr; }
  #wg-head { padding-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #wg-heat i, #wg-clock.urgent #wg-clock-n { animation: none; }
  .wg-preset, #wg-lock, #wg-locked-fill { transition: none; }
}


/* ==========================================================================
   DEV PANEL
   Admin only, and deliberately plain: this is a tool, not part of the game.
   No neon, no katakana flourish beyond the header -- anything that looks like
   the game invites somebody to treat it like the game.
   ========================================================================== */
/* Directly under the social pull-out, same size and shape, so the two read
   as one column of controls in that corner. Amber rather than the house
   green: it is a tool, not part of the game, and it should not be mistaken
   for one at a glance. */
#dev-open {
  position: fixed;
  top: 82px;              /* clears #social-toggle (16 + 54 + 12) */
  right: 16px;
  z-index: 692;
  width: 54px; height: 54px;
  display: flex; align-items: center; justify-content: center;
  background: rgba(26, 18, 6, 0.86);
  border: 1px solid rgba(255, 179, 71, 0.5);
  border-radius: 50%;
  font-family: 'Courier New', monospace;
  font-size: 13px; font-weight: bold; letter-spacing: 2px;
  color: #ffb347;
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
              border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
#dev-open:hover {
  transform: translateY(-3px);
  border-color: #ffb347;
  color: #fff0d0;
  box-shadow: 0 0 20px rgba(255, 179, 71, 0.35);
}
#dev-open:active { transform: translateY(0) scale(0.94); transition-duration: 0.06s; }
/* The party rail lives at 82px too, so it moves down to make room. Only
   when an admin is actually looking -- for everyone else nothing changes. */
body.has-dev #party-rail { top: 148px; }
#dev-open.hidden { display: none; }
#dev-open:hover { background: #ffb347; color: #150d02; }

/* ==========================================================================
   THE DEV CONSOLE (remastered)

   A tool, not part of the game -- it keeps the amber so it can never be
   mistaken for game UI, but it wears workstation chrome now: one plate, a
   header carrying the console's ONE status line, and a tab rail (ECONOMY /
   MAILBOX / LIBRARY / PLAYERS / CHAT.LOG) instead of six blocks stacked
   down one long scroll. Solid panel colours on purpose: the active tab
   "plugs into" the page below it by painting over the rail's border, and
   that trick only lines up over a flat colour, never a gradient.
   ========================================================================== */
#dev-panel {
  width: min(1180px, 100%);
  display: flex;
  flex-direction: column;
  font-family: 'Courier New', monospace;
  color: #d7d2c8;
  background: #12100b;
  border: 1px solid rgba(255, 179, 71, 0.3);
  border-radius: 4px;
  box-shadow:
    0 30px 80px rgba(0, 0, 0, 0.6),
    0 0 42px rgba(255, 179, 71, 0.06),
    inset 0 1px 0 rgba(255, 220, 160, 0.08);
  overflow: hidden;              /* rounds the header's corners with the plate */
}
#dev-panel.hidden { display: none; }

#dev-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 18px;
  padding: 20px 26px 14px;
  background: linear-gradient(180deg, #1b1610 0%, #12100b 100%);
}
#dev-title {
  font-size: 26px; font-weight: bold; letter-spacing: 8px;
  color: #ffb347;
  text-shadow: 0 0 22px rgba(255, 179, 71, 0.4);
  white-space: nowrap;
}
#dev-title em { font-style: normal; font-size: 12px; letter-spacing: 3px; opacity: 0.55; }

/* The console's one status line, up in the header where it is visible no
   matter which tab the action happened on. */
#dev-msg {
  margin: 0; min-height: 1.4em;
  font-size: 13px; letter-spacing: 1px; text-align: right;
  color: #8df59a;
}
#dev-msg.bad { color: #ff6b4a; }

/* ---- the tab rail ---- */
#dev-tabs {
  display: flex;
  align-items: flex-end;
  gap: 6px;
  padding: 10px 22px 0;
  background: #0c0a07;
  border-bottom: 1px solid rgba(255, 179, 71, 0.35);
  overflow-x: auto;
  scrollbar-width: none;
}
.dev-tab {
  position: relative;
  display: inline-flex; align-items: center; gap: 9px;
  padding: 11px 20px 10px;
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid rgba(255, 179, 71, 0.14);
  border-bottom: none;
  border-radius: 3px 3px 0 0;
  font-family: inherit; font-size: 13px; font-weight: bold; letter-spacing: 3px;
  color: #8b8577;
  cursor: pointer;
  white-space: nowrap;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.dev-tab:hover { color: #ffcf87; border-color: rgba(255, 179, 71, 0.4); }
.dev-tab.is-on {
  color: #ffd98f;
  background: #12100b;                       /* the page's own colour */
  border-color: rgba(255, 179, 71, 0.55);
  box-shadow: inset 0 2px 0 #ffb347;
}
/* The plug: the active tab paints over the rail's bottom border so tab and
   page read as one surface. */
.dev-tab.is-on::after {
  content: '';
  position: absolute; left: 0; right: 0; bottom: -1px; height: 1px;
  background: #12100b;
}
/* The mailbox tab's waiting count -- visible from every tab, because a
   producer waiting on a decision is the console's most time-sensitive fact. */
.dev-tab-badge {
  min-width: 21px;
  padding: 2px 6px;
  border-radius: 10px;
  background: #ffb347;
  color: #171006;
  font-size: 11px; letter-spacing: 0;
  text-align: center;
}
.dev-tab-badge.hidden { display: none; }

/* ---- pages ---- */
.dev-page { display: none; }
.dev-page.is-on {
  display: flex; flex-direction: column; gap: 16px;
  padding: 22px 26px 30px;
  animation: dev-page-in 0.18s ease-out;
}
@keyframes dev-page-in {
  from { opacity: 0; transform: translateY(5px); }
  to   { opacity: 1; transform: none; }
}

/* ---- blocks: cards now, not hairline-separated strata ---- */
.dev-block {
  display: flex; flex-direction: column; gap: 12px;
  padding: 16px 18px 18px;
  background: rgba(0, 0, 0, 0.32);
  border: 1px solid rgba(255, 179, 71, 0.13);
  border-radius: 3px;
}
.dev-block.hidden { display: none; }
.dev-k {
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
  font-size: 12px; font-weight: bold; letter-spacing: 5px;
  color: #cf9a4e;
  padding-bottom: 9px;
  border-bottom: 1px solid rgba(255, 179, 71, 0.12);
}
.dev-k .dev-hint { font-weight: normal; letter-spacing: 1px; }
.dev-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.dev-hint { font-size: 11px; color: #7d776b; letter-spacing: 0.5px; }

#dev-panel input {
  flex: 1; min-width: 120px;
  background: #0b0a08; border: 1px solid #3a3226; border-radius: 2px;
  color: #eee;
  font-family: 'Courier New', monospace; font-size: 14px;
  padding: 10px 12px; outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
}
#dev-panel input:focus {
  border-color: #ffb347;
  box-shadow: 0 0 0 1px rgba(255, 179, 71, 0.25), 0 0 16px rgba(255, 179, 71, 0.1);
}
#dev-panel select {
  background: #0b0a08; border: 1px solid #3a3226; border-radius: 2px;
  color: #eee;
  font-family: 'Courier New', monospace; font-size: 13px;
  padding: 10px; outline: none;
}
#dev-panel select:focus { border-color: #ffb347; }

.dev-btn {
  background: rgba(255, 179, 71, 0.06);
  border: 1px solid rgba(255, 179, 71, 0.35); border-radius: 2px;
  color: #e8c584;
  font-family: 'Courier New', monospace; font-size: 13px; font-weight: bold;
  letter-spacing: 2px; padding: 10px 16px; cursor: pointer;
  transition: background 0.13s, color 0.13s, border-color 0.13s, box-shadow 0.13s;
}
.dev-btn:hover { background: #ffb347; border-color: #ffb347; color: #171006; box-shadow: 0 0 18px rgba(255, 179, 71, 0.35); }
.dev-btn-good { background: rgba(110, 231, 135, 0.05); border-color: rgba(110, 231, 135, 0.4); color: #8df59a; }
.dev-btn-good:hover { background: #6ee787; border-color: #6ee787; color: #06180b; box-shadow: 0 0 18px rgba(110, 231, 135, 0.3); }
.dev-btn-warn { background: rgba(255, 107, 74, 0.05); border-color: rgba(255, 107, 74, 0.4); color: #ff8a7a; }
.dev-btn-warn:hover { background: #ff6b4a; border-color: #ff6b4a; color: #1b0704; box-shadow: 0 0 18px rgba(255, 107, 74, 0.3); }

#dev-econ-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.dev-stat {
  background: linear-gradient(180deg, #16130d 0%, #0e0c09 100%);
  border: 1px solid #2c2820; border-radius: 3px;
  padding: 12px 14px;
}
.dev-stat b { display: block; font-size: 22px; color: #fff; font-variant-numeric: tabular-nums; }
.dev-stat span { font-size: 10px; letter-spacing: 2px; color: #8b8577; }
/* Drift is the one number that means something is WRONG. */
.dev-stat.bad { border-color: #ff6b4a; background: linear-gradient(180deg, rgba(255, 107, 74, 0.14) 0%, rgba(255, 107, 74, 0.03) 100%); }
.dev-stat.bad b { color: #ff6b4a; }

#dev-results, #dev-ledger { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
#dev-results li {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; background: #0f0d0a; border: 1px solid #2c2820; border-radius: 2px;
  cursor: pointer; font-size: 13px;
  transition: border-color 0.13s, background 0.13s;
}
#dev-results li:hover { border-color: #ffb347; background: #171310; }
#dev-results .dev-r-name { flex: 1; color: #eee; }
#dev-results .dev-r-creds { color: #6ee787; font-variant-numeric: tabular-nums; }

#dev-player-head { font-size: 18px; color: #fff; letter-spacing: 1px; }
#dev-player-head small { display: block; font-size: 11px; color: #7d776b; letter-spacing: 1px; }
#dev-player-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; font-size: 12px; color: #9b958a; }
#dev-player-stats i { font-style: normal; color: #fff; }

#dev-ledger { max-height: 320px; overflow-y: auto; }
#dev-ledger li {
  display: flex; gap: 10px; font-size: 12px;
  padding: 6px 9px; background: #0f0d0a; border-left: 2px solid #2c2820;
}
#dev-ledger .d-pos { color: #6ee787; }
#dev-ledger .d-neg { color: #ff8a7a; }
#dev-ledger .d-reason { color: #8b8577; flex: 1; }
#dev-ledger .d-bal { color: #7d776b; font-variant-numeric: tabular-nums; }

/* chat.log: one row per message, meta line over the words. Its own tab now,
   so the list gets real height instead of being capped to spare blocks that
   no longer sit below it. */
#dev-chat-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
  max-height: min(56vh, 620px);
  overflow-y: auto;
}
#dev-chat-list li {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 11px;
  background: #0f0d0a;
  border-left: 2px solid #2c2820;
  font-size: 12px;
}
.dev-chat-meta { font-size: 10px; color: #7d776b; letter-spacing: 0.5px; }
.dev-chat-who { color: #ffb347; }
.dev-chat-text { color: #eee; overflow-wrap: anywhere; }
.dev-chat-empty { color: #7d776b; font-size: 12px; padding: 4px 0; }

/* DISCIPLINE: the lock readout goes red while a lock is live. */
.dev-hint.bad { color: #ff6b4a; }

/* ---- the LIVE tab: connected players and their addresses ---- */
#dev-live-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
  max-height: min(56vh, 620px);
  overflow-y: auto;
}
/* An address is monospace data you read digit by digit -- but it is also
   the one thing in this console that identifies a person outside the game,
   so it renders MASKED and reveals on a click (devIp in main.js). The
   masked state is deliberately dim and dotted: present, clearly a control,
   and worthless in a screenshot. */
.dev-r-ip {
  color: #9ad7ff;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  border-bottom: 1px dotted rgba(154, 215, 255, 0.35);
  transition: color 0.13s, border-color 0.13s;
}
.dev-r-ip:hover { color: #cfe6ff; border-bottom-color: rgba(154, 215, 255, 0.7); }
.dev-r-ip.is-masked {
  color: #5c6672;
  letter-spacing: 1px;
  border-bottom-color: transparent;
}
.dev-r-ip.is-masked:hover { color: #9ad7ff; }
/* Nothing to reveal: not a control, so it doesn't dress like one. */
.dev-r-ip.is-none {
  color: #6b665c;
  cursor: default;
  border-bottom: none;
  font-style: italic;
}
.dev-r-ip.is-none:hover { color: #6b665c; }
.dev-live-acct { color: #7d776b; font-size: 11px; font-variant-numeric: tabular-nums; }
/* A guest is the case the tab exists for: no account to lock, so the
   address is the only handle there is. */
.dev-live-guest {
  color: #ffb347;
  font-size: 10px;
  letter-spacing: 2px;
  border: 1px solid rgba(255, 179, 71, 0.4);
  padding: 1px 6px;
  border-radius: 2px;
}
#dev-results .dev-r-ip { margin-left: auto; }

/* ---- the BANS tab (ip bans, active locks, the ban log) + alt cluster ---- */
#dev-ipban-list, #dev-locks-list, #dev-banlog-list, #dev-alts {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
  max-height: 300px;
  overflow-y: auto;
}
.dev-ban-row {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 9px 12px;
  background: #0f0d0a;
  border: 1px solid #2c2820;
  border-radius: 2px;
  font-size: 13px;
}
.dev-ban-row .dev-r-name { color: #eee; }
.dev-ban-row .dev-btn { padding: 5px 12px; font-size: 11px; margin-left: auto; }
.dev-ban-until { color: #ffd0c4; font-size: 12px; font-variant-numeric: tabular-nums; }
.dev-ban-why { color: #9b958a; font-size: 12px; flex: 1 1 120px; min-width: 0; overflow-wrap: anywhere; }
.dev-ban-by { color: #7d776b; font-size: 11px; }
.dev-empty { color: #7d776b; font-size: 12px; padding: 4px 0; }

.dev-log-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  padding: 6px 9px;
  background: #0f0d0a;
  border-left: 2px solid #2c2820;
  font-size: 12px;
}
.dev-log-when { color: #7d776b; font-size: 11px; font-variant-numeric: tabular-nums; }
.dev-log-act { font-weight: bold; letter-spacing: 1px; font-size: 11px; }
.dev-log-act.is-lock, .dev-log-act.is-ipban { color: #ff6b4a; }
.dev-log-act.is-unlock, .dev-log-act.is-ipunban { color: #6ee787; }
.dev-log-row .dev-r-name { color: #eee; }

/* The alt cluster under SELECTED: clickable rows, amber like a lead. */
#dev-alts.hidden { display: none; }
#dev-alts li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  background: #0f0d0a;
  border: 1px solid #2c2820;
  border-radius: 2px;
  cursor: pointer;
  font-size: 12px;
  transition: border-color 0.13s;
}
#dev-alts li:hover { border-color: #ffb347; }
#dev-alts .dev-r-name { color: #ffcf87; }
.dev-alt-locked {
  color: #ff6b4a;
  font-size: 10px;
  letter-spacing: 2px;
  border: 1px solid rgba(255, 107, 74, 0.4);
  padding: 1px 6px;
  border-radius: 2px;
}

/* ==========================================================================
   THE LOCK PLATE + THE LOCKED DOORS

   What a banned/timed-out account sees. The plate (fixed, bottom-centre)
   only draws over the main menu -- on any other screen it would sit on top
   of content, and a locked account can still legitimately be in settings,
   the store or its profile. The doors it explains (PLAY / MAILBOX / GANGS)
   grey out under body.sw-banned; clicking them answers with the ACCOUNT
   LOCKED modal (banBlock in main.js), and the server refuses them anyway.
   ========================================================================== */
#ban-plate { display: none; }
body:has(#screen-home.active) #ban-plate:not(.hidden) {
  display: flex;
  position: fixed;
  left: 50%;
  bottom: 22px;
  transform: translateX(-50%);
  z-index: 700;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  padding: 13px 30px;
  background: rgba(26, 7, 5, 0.92);
  border: 1px solid rgba(255, 107, 74, 0.65);
  border-radius: 3px;
  box-shadow: 0 0 34px rgba(255, 60, 30, 0.22), inset 0 1px 0 rgba(255, 138, 122, 0.14);
  font-family: 'Courier New', monospace;
  color: #ff8a7a;
  text-align: center;
  pointer-events: none;   /* a notice, not a control -- clicks pass through */
}
#ban-plate b {
  font-size: 16px;
  letter-spacing: 6px;
  color: #ff6b4a;
  text-shadow: 0 0 14px rgba(255, 107, 74, 0.55);
}
#ban-plate b em { font-style: normal; font-size: 11px; letter-spacing: 3px; opacity: 0.6; }
#ban-plate-when { font-size: 13px; letter-spacing: 2px; color: #ffd0c4; font-variant-numeric: tabular-nums; }
#ban-plate-why {
  font-size: 11px;
  letter-spacing: 1px;
  opacity: 0.75;
  max-width: 480px;
  overflow-wrap: anywhere;
}
/* The shut doors. Dimmed, not hidden: a vanished menu looks broken; a grey
   one looks locked. The buttons stay clickable so the click can explain. */
body.sw-banned #btn-play,
body.sw-banned #btn-clans,
body.sw-banned #hs-mailbox {
  opacity: 0.35;
  filter: saturate(0.3);
}

/* MODE ART: Genre Roulette. Six bars is what the tile markup gives every
   gamemode, so this reuses them as a wheel of segments rather than asking
   for different markup. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art {
  position: relative;
  display: grid;
  place-items: center;
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art i {
  position: absolute;
  width: 3px;
  height: 46%;
  background: currentColor;
  transform-origin: 50% 100%;
  bottom: 50%;
  opacity: 0.55;
}
/* Six spokes at 60-degree steps, so it reads as a wheel at a glance. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(1) { transform: rotate(0deg); }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(2) { transform: rotate(60deg); }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) { transform: rotate(120deg); }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(4) { transform: rotate(180deg); }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(5) { transform: rotate(240deg); }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(6) { transform: rotate(300deg); }
/* The rim, and a hub so the spokes have something to turn around. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art::before {
  content: '';
  position: absolute;
  width: 78%; height: 78%;
  border: 2px solid currentColor;
  border-radius: 50%;
  opacity: 0.85;
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art::after {
  content: '';
  position: absolute;
  width: 16%; height: 16%;
  background: currentColor;
  border-radius: 50%;
}
/* Only the hovered/selected tile spins -- a wheel turning on every tile all
   the time would pull the eye away from whatever the player is reading. */
.mp-tile[data-gamemode="genre_roulette"]:hover .mp-art,
.mp-tile[data-gamemode="genre_roulette"].is-on .mp-art {
  animation: mpWheelSpin 2.6s linear infinite;
}
@keyframes mpWheelSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .mp-tile[data-gamemode="genre_roulette"] .mp-art { animation: none !important; }
}

/* ==========================================================================
   GENRE ROULETTE: THE DRAW  (rebuild)

   The draw IS the brief for this mode -- it is the only thing the round is
   built on -- so it is staged as a machine you are standing in front of
   rather than a list that scrolls. Bracketed housing, graduated rails either
   side of the window, a status readout that commits from DRAWING to LOCKED,
   and a landing that lands: flash, kick, then the name.

   Nothing is instant. It builds, it ignites, it cools -- the house rule.
   ========================================================================== */

#screen-roulette.active {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: safe center;
  gap: clamp(16px, 2.4 * var(--dvh, 1vh), 40px);
  min-height: calc(100 * var(--dvh, 1vh) - 62px);
  padding: clamp(20px, 3 * var(--dvh, 1vh), 60px) 24px;
}

/* ---- head ---- */
/* ONE PHRASE: the gamemode's name, and nothing stacked above or below it.
   Sized smaller than the old THE DRAW headline because it now carries a
   whole mode name -- ROULETTE DES GENRES is nineteen characters of
   monospace, and this line must not wrap. */
#rl-head { display: flex; flex-direction: column; align-items: center; text-align: center; }
#rl-title {
  margin: 0;
  max-width: 100%;
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, 3.4 * var(--dvw, 1vw), 62px);
  font-weight: bold;
  letter-spacing: clamp(5px, 0.7 * var(--dvw, 1vw), 13px);
  line-height: 1;
  color: #eafff0;
  /* Two-tone glow: a tight white core inside a wide green bloom, so it reads
     as lit rather than blurred. */
  text-shadow: 0 0 8px rgba(255, 255, 255, 0.5), 0 0 34px rgba(57, 255, 20, 0.75);
  white-space: nowrap;
}
/* ---- the housing ---- */
#rl-machine {
  position: relative;
  display: flex;
  align-items: stretch;
  gap: clamp(10px, 1.2 * var(--dvw, 1vw), 22px);
  padding: clamp(18px, 2 * var(--dvw, 1vw), 44px);
  /* --dvw, NOT vw. Raw viewport units inside this zoomed document resolve
     against the real window and are THEN scaled by the zoom, so 95vw came
     out at 48% of the screen. The design unit is already in canvas space. */
  width: min(2260px, 95 * var(--dvw, 1vw));
  background:
    linear-gradient(180deg, rgba(10, 26, 14, 0.75), rgba(3, 10, 5, 0.9));
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.24);
  clip-path: polygon(26px 0, 100% 0, 100% calc(100% - 26px), calc(100% - 26px) 100%, 0 100%, 0 26px);
}
/* Corner brackets, drawn as two borders each -- the HUD language used across
   the game, and cheaper than four more elements with backgrounds. */
.rl-br { position: absolute; width: 26px; height: 26px; pointer-events: none; }
.rl-br-tl { top: 8px; left: 8px; border-top: 2px solid #39ff14; border-left: 2px solid #39ff14; }
.rl-br-tr { top: 8px; right: 8px; border-top: 2px solid #39ff14; border-right: 2px solid #39ff14; }
.rl-br-bl { bottom: 8px; left: 8px; border-bottom: 2px solid #39ff14; border-left: 2px solid #39ff14; }
.rl-br-br { bottom: 8px; right: 8px; border-bottom: 2px solid #39ff14; border-right: 2px solid #39ff14; }

/* Graduated rails: the machine's own scale, ticking past the window. Drawn
   with a repeating gradient rather than elements -- it is texture, not data. */
.rl-rail {
  flex: 0 0 clamp(20px, 2 * var(--dvw, 1vw), 42px);
  background: repeating-linear-gradient(
    180deg,
    rgba(57, 255, 20, 0.42) 0 2px,
    transparent 2px 14px
  );
  opacity: 0.55;
}
/* While it spins the rails crawl, so the housing feels driven rather than
   painted on. Settles the moment it lands. */
#screen-roulette.spinning .rl-rail { animation: rlRail 0.5s linear infinite; }
@keyframes rlRail { to { background-position-y: 16px; } }

#rl-stage { position: relative; flex: 1; min-width: 0; }

/* ---- the window ---- */
#rl-window {
  position: relative;
  height: clamp(230px, 39 * var(--dvh, 1vh), 570px);
  overflow: hidden;
  background: #030b05;
  border: 1px solid rgba(57, 255, 20, 0.3);
  box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.9);
}
#rl-strip { will-change: transform; }
.rl-cell {
  height: clamp(230px, 39 * var(--dvh, 1vh), 570px);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'Courier New', monospace;
  /* A LABEL CAN BE 28 CHARACTERS NOW. Genres used to be a closed server-side
     list of short words; hosts type their own, so the display size is capped
     against the longest one on the wheel (--rl-len, set by rouletteRun) or a
     long name silently runs off both sides of the glass. Courier advances
     0.6em a character and the tracking adds most of the rest -- 0.85em per
     character is the conservative figure that keeps it inside. */
  /* Tighter budget than the result panel: a tile has to fit the GLASS, which
     is about three quarters of the viewport, not the viewport. */
  font-size: min(clamp(46px, 5 * var(--dvw, 1vw), 104px),
                 calc(58vw / 0.85 / var(--rl-len, 10)));
  font-weight: bold;
  letter-spacing: clamp(8px, 1.1 * var(--dvw, 1vw), 22px);
  color: #6f8f76;
  white-space: nowrap;
  text-shadow: 0 0 18px rgba(57, 255, 20, 0.18);
}
/* Only once it has stopped. Lighting the winner during the spin would give
   the answer away as it flew past. */
#screen-roulette.landed .rl-cell.is-win {
  color: #ffffff;
  text-shadow: 0 0 10px #fff, 0 0 40px rgba(57, 255, 20, 1), 0 0 90px rgba(57, 255, 20, 0.6);
}

/* Scanlines inside the glass -- the window is a screen, not a hole. */
#rl-scan {
  position: absolute; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(180deg, rgba(0, 0, 0, 0.5) 0 1px, transparent 1px 3px);
  opacity: 0.5;
}
/* Vignette so tiles arrive and leave rather than popping at a hard edge. */
#rl-glass {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg,
    #030b05 0%, rgba(3, 11, 5, 0) 30%,
    rgba(3, 11, 5, 0) 70%, #030b05 100%);
}

/* The marker: a lit band across the middle with a caret biting in from each
   side, so there is an obvious place the answer will come to rest. */
#rl-marker { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
#rl-marker::before, #rl-marker::after {
  content: '';
  position: absolute; left: 0; right: 0; height: 2px;
  background: #39ff14;
  box-shadow: 0 0 18px rgba(57, 255, 20, 0.9);
}
#rl-marker::before { top: 22%; }
#rl-marker::after { bottom: 22%; }
.rl-caret {
  position: absolute;
  top: 50%;
  width: 0; height: 0;
  margin-top: -13px;
  border-top: 13px solid transparent;
  border-bottom: 13px solid transparent;
  filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.9));
}
.rl-caret-l { left: 0; border-left: 16px solid #39ff14; }
.rl-caret-r { right: 0; border-right: 16px solid #39ff14; }
/* The carets press inward as it settles -- the machine closing on an answer. */
#screen-roulette.landed .rl-caret-l { animation: rlBiteL 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
#screen-roulette.landed .rl-caret-r { animation: rlBiteR 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
@keyframes rlBiteL { to { transform: translateX(14px); } }
@keyframes rlBiteR { to { transform: translateX(-14px); } }

/* The hit. One white bloom out of the window at the moment it stops. */
#rl-flash {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  background: radial-gradient(60% 120% at 50% 50%, rgba(255, 255, 255, 0.95), rgba(57, 255, 20, 0.35) 45%, transparent 72%);
  opacity: 0;
}
#screen-roulette.landed #rl-flash { animation: rlFlash 0.7s ease-out; }
@keyframes rlFlash {
  0% { opacity: 0; }
  12% { opacity: 1; }
  100% { opacity: 0; }
}
/* And the housing takes the kick, so the impact belongs to the machine
   rather than floating on top of it. */
#screen-roulette.landed #rl-machine { animation: rlKick 0.42s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
@keyframes rlKick {
  0%, 100% { transform: translate3d(0, 0, 0); }
  18% { transform: translate3d(0, 5px, 0) scale(1.008); }
  42% { transform: translate3d(0, -3px, 0); }
  70% { transform: translate3d(0, 2px, 0); }
}

/* ---- status readout ---- */
#rl-status {
  display: flex; align-items: center; gap: 12px;
  font-family: 'Courier New', monospace;
  font-size: 21px; letter-spacing: 8px;
  color: #5d7a64;
}
#rl-status-dot {
  width: 13px; height: 13px;
  background: #39ff14;
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.9);
}
#screen-roulette.spinning #rl-status-dot { animation: rlBlink 0.55s steps(1) infinite; }
@keyframes rlBlink { 50% { opacity: 0.15; } }
#screen-roulette.landed #rl-status { color: #eafff0; }
#screen-roulette.landed #rl-status-dot { background: #eafff0; box-shadow: 0 0 16px #fff; animation: none; }

/* ---- the answer ---- */
#rl-result { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
#rl-result.hidden { display: none; }
.rl-k { font-family: 'Courier New', monospace; font-size: 17px; letter-spacing: 7px; color: #5d7a64; }
.rl-k em { font-style: normal; opacity: 0.6; }
#rl-genre {
  font-family: 'Courier New', monospace;
  /* Capped against its own length -- see .rl-cell for the arithmetic. */
  font-size: min(clamp(48px, 5 * var(--dvw, 1vw), 96px),
                 calc(84vw / 0.85 / var(--rl-len, 10)));
  font-weight: bold;
  letter-spacing: clamp(8px, 1 * var(--dvw, 1vw), 18px);
  line-height: 1;
  color: #ffffff;
  text-shadow: 0 0 12px #fff, 0 0 46px rgba(57, 255, 20, 0.9), 0 0 100px rgba(57, 255, 20, 0.45);
  animation: rlSlam 0.55s cubic-bezier(0.16, 1, 0.3, 1);
  white-space: nowrap;
}
/* Arrives BIG and settles, rather than growing into place -- it is a verdict,
   not a reveal. */
@keyframes rlSlam {
  0% { transform: scale(1.5); opacity: 0; letter-spacing: 40px; filter: blur(8px); }
  60% { opacity: 1; filter: blur(0); }
  100% { transform: scale(1); opacity: 1; }
}
#rl-note {
  margin: 0;
  font-family: 'Courier New', monospace;
  font-size: 18px; letter-spacing: 5px; color: #5d7a64;
}

/* ---- the host fixed the genre: the panel IS the screen ----
   No machine, no status lamp -- both of those narrate a draw, and there
   wasn't one. What is left is a plate with the word on it, framed like the
   machine's window so the screen still reads as the same room. */
#screen-roulette.is-fixed #rl-machine,
#screen-roulette.is-fixed #rl-status { display: none; }
#screen-roulette.is-fixed #rl-result {
  gap: 18px;
  max-width: 94vw;
  padding: clamp(30px, 5vh, 64px) clamp(28px, 6vw, 90px);
  background:
    linear-gradient(180deg, rgba(10, 40, 20, 0.55), rgba(4, 14, 8, 0.75));
  border: 1px solid rgba(57, 255, 20, 0.35);
  box-shadow:
    inset 0 0 60px rgba(57, 255, 20, 0.12),
    0 0 40px rgba(57, 255, 20, 0.18);
  animation: rlPanelIn 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
/* The bracketed corners the machine wears, on the plate instead. */
#screen-roulette.is-fixed #rl-result::before,
#screen-roulette.is-fixed #rl-result::after {
  content: '';
  position: absolute;
  width: 34px; height: 34px;
  border: 2px solid #39ff14;
  box-shadow: 0 0 14px rgba(57, 255, 20, 0.65);
}
#screen-roulette.is-fixed #rl-result {
  position: relative;
}
#screen-roulette.is-fixed #rl-result::before {
  top: -2px; left: -2px; border-right: 0; border-bottom: 0;
}
#screen-roulette.is-fixed #rl-result::after {
  bottom: -2px; right: -2px; border-left: 0; border-top: 0;
}
@keyframes rlPanelIn {
  0% { transform: translate3d(0, 26px, 0) scale(0.97); opacity: 0; }
  100% { transform: none; opacity: 1; }
}

@media (max-width: 700px) {
  .rl-rail { display: none; }
  #rl-title { letter-spacing: 3px; font-size: clamp(22px, 5.4 * var(--dvw, 1vw), 40px); }
}
@media (prefers-reduced-motion: reduce) {
  #rl-strip { transition: none !important; }
  #rl-genre, #rl-flash, #rl-machine, .rl-caret, .rl-rail, #rl-status-dot { animation: none !important; }
  #screen-roulette.is-fixed #rl-result { animation: none !important; }
}

/* ==========================================================================
   GENRE ROULETTE -- pass 3: no scrollbar, and dressed properly.
   Appended, so these override the block above deliberately.
   ========================================================================== */

/* NO SCROLLBAR. The screen is exactly one canvas tall and nothing here is
   meant to scroll -- the draw is a fixed tableau, not a document. Height is
   pinned rather than min-height so a stray pixel of content cannot push a
   bar onto the page, and the layout absorbs it instead. */
/* AN OPAQUE GROUND. Without one the screen is transparent, and during the
   hand-off animation the menu underneath showed straight through the draw.
   Fixed to the viewport rather than painted on the element, because the
   element is 200px short of the canvas (see the height note below) and the
   page would still have shown around its edges.

   z-index -1 inside the isolate keeps it behind the draw and in front of
   whatever screen is being torn down. */
/* The screen has to sit ABOVE the outgoing pane, not merely own a
   background. During the hand-off both screens are on screen at once by
   design, and without a z-index the menu's own positioned children painted
   straight over the draw -- which is what "the previous UI" was. */
#screen-roulette.active { isolation: isolate; position: relative; z-index: 30; }
#screen-roulette.active::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(120% 80% at 50% 40%, rgba(12, 40, 18, 0.55), transparent 70%),
    #040a06;
}

#screen-roulette.active {
  /* -200, not the -62 the other screens use. Those set MIN-height and are
     free to scroll; this one pins its height, so it also has to account for
     the body's own vertical padding (56 top + 40 bottom, doubled into
     canvas space). Using -62 here overflowed the page by exactly that
     difference and put a scrollbar on a screen that never scrolls. */
  /* The body opt-out above removes its padding, so this takes the whole
     canvas. (It was -200px to dodge that padding, which no longer
     applies.) */
  height: calc(100 * var(--dvh, 1vh));
  min-height: 0;
  overflow: hidden;
  padding: clamp(14px, 2 * var(--dvh, 1vh), 40px) 24px;
  gap: clamp(12px, 1.8 * var(--dvh, 1vh), 30px);
}

/* ---- chromatic split: the house cyberpunk tell ----
   A red ghost one way, a cyan ghost the other, under a white core. Applied
   to the two pieces of type that carry the moment; everywhere else stays
   clean so this reads as emphasis rather than as a broken screen. */
#rl-title {
  text-shadow:
    -3px 0 0 rgba(255, 40, 90, 0.55),
    3px 0 0 rgba(40, 220, 255, 0.55),
    0 0 8px rgba(255, 255, 255, 0.5),
    0 0 40px rgba(57, 255, 20, 0.75);
  animation: rlAberrate 4.2s steps(1) infinite;
}
@keyframes rlAberrate {
  0%, 92%, 100% { transform: translate3d(0, 0, 0); }
  93% { transform: translate3d(-2px, 1px, 0); }
  95% { transform: translate3d(2px, -1px, 0); }
  97% { transform: translate3d(-1px, 0, 0); }
}

/* Katakana ghost behind the housing -- set dressing, deliberately unreadable
   and barely there. It is texture, not information. */
#rl-machine::before {
  content: 'ジャンル抽選 // 選択中 // データ照合 // 乱数生成 // 確定待機';
  position: absolute;
  top: -26px; left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 7px;
  color: rgba(57, 255, 20, 0.22);
  pointer-events: none;
}
/* A sweep of light travelling down the housing while it draws, so the
   machine looks powered rather than lit. */
#rl-machine::after {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 34%;
  pointer-events: none;
  background: linear-gradient(180deg, transparent, rgba(57, 255, 20, 0.09), transparent);
  opacity: 0;
}
#screen-roulette.spinning #rl-machine::after {
  opacity: 1;
  animation: rlSweep 2.4s ease-in-out infinite;
}
@keyframes rlSweep {
  0% { transform: translateY(-40%); }
  100% { transform: translateY(300%); }
}

/* Tiles smear as they fly past and snap clean when they stop -- motion blur
   the cheap way, and it is what makes the crawl at the end feel like the
   machine is fighting to a halt. */
#screen-roulette.spinning .rl-cell {
  filter: blur(0.6px);
  opacity: 0.85;
}
#screen-roulette.spinning .rl-cell {
  text-shadow:
    -2px 0 0 rgba(255, 40, 90, 0.28),
    2px 0 0 rgba(40, 220, 255, 0.28),
    0 0 18px rgba(57, 255, 20, 0.25);
}

/* The verdict gets the same split, harder, plus a one-off tear as it lands. */
#rl-genre {
  text-shadow:
    -4px 0 0 rgba(255, 40, 90, 0.6),
    4px 0 0 rgba(40, 220, 255, 0.6),
    0 0 12px #fff,
    0 0 52px rgba(57, 255, 20, 0.9),
    0 0 110px rgba(57, 255, 20, 0.45);
}

/* Corner readouts. Fake machine telemetry -- the sort of thing that is always
   in the corner of a screen in this genre and that nobody is meant to read. */
#rl-machine .rl-br-tl::after,
#rl-machine .rl-br-br::after {
  position: absolute;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(57, 255, 20, 0.4);
  white-space: nowrap;
}
#rl-machine .rl-br-tl::after { content: 'RNG//SEEDED'; top: 2px; left: 34px; }
#rl-machine .rl-br-br::after { content: 'SEALED'; bottom: 2px; right: 34px; }
#screen-roulette.landed .rl-br-br::after { content: 'CONFIRMED'; color: #eafff0; }

/* The rails get a travelling highlight rather than only crawling, so the
   sides of the machine have depth. */
.rl-rail { position: relative; overflow: hidden; }
.rl-rail::after {
  content: '';
  position: absolute; left: 0; right: 0;
  height: 40%;
  background: linear-gradient(180deg, transparent, rgba(57, 255, 20, 0.55), transparent);
  opacity: 0;
}
#screen-roulette.spinning .rl-rail::after {
  opacity: 1;
  animation: rlRailGlow 1.6s linear infinite;
}
@keyframes rlRailGlow {
  0% { transform: translateY(-100%); }
  100% { transform: translateY(250%); }
}

/* Status readout reads like a process, not a label. */
#rl-status { text-transform: none; }
#screen-roulette.spinning #rl-status-txt::after {
  content: '';
  animation: rlDots 1.2s steps(4) infinite;
}
@keyframes rlDots {
  0% { content: ''; }
  25% { content: '.'; }
  50% { content: '..'; }
  75% { content: '...'; }
}

@media (prefers-reduced-motion: reduce) {
  #rl-title, #rl-machine::after, .rl-rail::after, #rl-status-txt::after { animation: none !important; }
  #screen-roulette.spinning .rl-cell { filter: none; }
}

/* ==========================================================================
   ROULETTE RAILS -- soften the glow
   The travelling highlight was a hard-edged block sliding down a column of
   sharp bars, so it read as a rectangle rather than as light. Three changes,
   all about killing straight edges:
     - the highlight is an ellipse with a real blur, not a linear band
     - it is much taller, so the falloff is gradual instead of a visible edge
     - the ticks fade out at both ends, so nothing terminates on a hard line
   ========================================================================== */

/* Ticks fade horizontally, so each one dissolves at its ends rather than
   stopping dead against the rail edge. */
.rl-rail {
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 28%, #000 72%, transparent),
                      linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
  -webkit-mask-composite: source-in;
          mask-image: linear-gradient(90deg, transparent, #000 28%, #000 72%, transparent),
                      linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
          mask-composite: intersect;
  opacity: 0.45;
}

/* The highlight: an ellipse, blurred, and tall enough that its edges are
   never anywhere near the visible area at once. */
.rl-rail::after {
  height: 130%;
  background: radial-gradient(
    ellipse 140% 34% at 50% 50%,
    rgba(160, 255, 190, 0.55) 0%,
    rgba(57, 255, 20, 0.32) 32%,
    rgba(57, 255, 20, 0.08) 58%,
    transparent 78%
  );
  filter: blur(7px);
  mix-blend-mode: screen;
}
/* Slower and travelling further, so it drifts through rather than swiping. */
#screen-roulette.spinning .rl-rail::after {
  animation: rlRailGlowSoft 2.6s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes rlRailGlowSoft {
  0%   { transform: translateY(-115%); opacity: 0; }
  18%  { opacity: 1; }
  82%  { opacity: 1; }
  100% { transform: translateY(215%); opacity: 0; }
}

/* The crawl underneath loses its hard step too -- eased, not linear, so the
   ticks breathe rather than march. */
#screen-roulette.spinning .rl-rail {
  animation: rlRail 1.1s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .rl-rail::after, #screen-roulette.spinning .rl-rail { animation: none !important; }
}

/* ---- GENRE ROULETTE: the brief gets its OWN PANEL at the top of the dock,
   right under the title strip (it used to ride inside the pad, above the
   clock). The genre is the only constraint on the round, so it is the
   biggest thing after the timer itself. Blue like the rest of this screen --
   the green belongs to the draw, this is the dock. The plate (background,
   border, chamfer) is painted on ::before rather than the element: the name
   wears a 70px bloom, and a clip-path on the element itself would slice
   that glow off hard at the panel edge (same rule as #hpb-plate). */
#dz-genre {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  align-self: center;
  padding: 14px clamp(28px, calc(4 * var(--dvw, 1vw)), 64px) 18px;
}
#dz-genre::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: rgba(10, 40, 68, 0.5);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 210, 255, 0.3);
  clip-path: polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%);
}
#dz-genre.hidden { display: none; }
.dz-genre-k {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 8px;
  /* Tracking adds a phantom space AFTER the last glyph, so a centered
     box renders the word nudged left -- pull the trailing space back in
     so GENRE sits dead-center over the name. Same trick on the name. */
  margin-right: -8px;
  color: rgba(140, 215, 255, 0.55);
}
#dz-genre-name {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(46px, calc(5.4 * var(--dvw, 1vw)), 86px);
  letter-spacing: clamp(4px, 0.5 * var(--dvw, 1vw), 10px);
  margin-right: calc(-1 * clamp(4px, 0.5 * var(--dvw, 1vw), 10px));
  line-height: 1;
  color: #eaf7ff;
  text-shadow: 0 0 20px rgba(120, 210, 255, 0.8), 0 0 70px rgba(90, 200, 255, 0.35);
  white-space: nowrap;
}

/* ==========================================================================
   THE HOUSE GLOW -- one recipe, applied to every plate on the menu

   The profile plate and the jukebox were lit with a two-stack bloom (a tight
   hot core inside a wide halo) while the social links, the friends button and
   the cred counter were still drawn with a 0.22-alpha hairline and no resting
   light at all. Side by side the menu read as two different games.

   The stack below is the SAME pair of shadows those two plates already use,
   lifted into tokens so there is one place to change it. The syntax is valid
   as both box-shadow and drop-shadow, so a dark plate can wear it on its
   border and a bare glyph can wear it on its silhouette.
   ========================================================================== */
:root {
  /* THE house glow, retuned to the LOGO's exact resting pair -- the sign
     is the reference material and everything neon on the menu (menu
     words, both corner plates, the social row, the friends button, the
     cred counter) reads these same two tokens now. One place to change
     the entire menu's light. */
  --sw-glow-core: 0 0 6px rgba(57, 255, 20, 0.9);
  --sw-glow-bloom: 0 0 30px rgba(57, 255, 20, 0.7);
  /* Hover: the same two, hotter. */
  --sw-glow-core-hot: 0 0 8px rgba(57, 255, 20, 1);
  --sw-glow-bloom-hot: 0 0 36px rgba(57, 255, 20, 0.8);
}

/* ---- the official links ---- */
.hs-link {
  /* Lit edge at rest. It was a hairline you had to look for. */
  border-color: rgba(57, 255, 20, 0.7);
  background: rgba(4, 12, 4, 0.72);
  box-shadow: var(--sw-glow-core), var(--sw-glow-bloom),
              inset 0 0 12px rgba(57, 255, 20, 0.07);
}
/* The glyph glows too, so the icon is lit rather than sitting in a lit box --
   which is the difference between these and the plates in the corners. */
.hs-link svg {
  filter: drop-shadow(0 0 5px rgba(57, 255, 20, 0.85));
  transition: filter 0.16s ease-out;
}
/* Hover is the house extrusion: lifts up-left, hard steps trail down-right. */
.hs-link:hover {
  box-shadow:
    2px 2px 0 rgba(24, 140, 10, 0.9),
    4px 4px 0 rgba(15, 88, 6, 0.7),
    var(--sw-glow-core-hot), var(--sw-glow-bloom-hot),
    inset 0 0 12px rgba(57, 255, 20, 0.14);
}
.hs-link:hover svg { filter: drop-shadow(0 0 9px rgba(255, 255, 255, 0.9)); }
/* Dead links stay obviously dead -- the glow must not sell a button that
   goes nowhere. */
.hs-dead, .hs-dead:hover {
  box-shadow: none;
  border-color: rgba(57, 255, 20, 0.22);
}
.hs-dead svg, .hs-dead:hover svg { filter: none; }

/* ---- the friends button ---- */
#social-toggle {
  border-color: rgba(57, 255, 20, 0.7);
  box-shadow: var(--sw-glow-core), var(--sw-glow-bloom),
              inset 0 0 12px rgba(57, 255, 20, 0.07);
}
#social-toggle:hover {
  box-shadow:
    2px 2px 0 rgba(24, 140, 10, 0.9),
    4px 4px 0 rgba(15, 88, 6, 0.7),
    var(--sw-glow-core-hot), var(--sw-glow-bloom-hot),
    inset 0 0 12px rgba(57, 255, 20, 0.14);
}

/* ---- the cred counter ----
   Same treatment, no hover: it is a readout, not a control (pointer-events
   are off), so lighting it on hover would promise something it can't do. */
#cred-hud {
  border-color: rgba(57, 255, 20, 0.7);
  box-shadow: var(--sw-glow-core), var(--sw-glow-bloom),
              inset 0 0 12px rgba(57, 255, 20, 0.07);
}

/* ==========================================================================
   THE HOUSE GLOW, CORRECTED -- clipped plates need drop-shadow, not box-shadow

   box-shadow is painted as part of the element's own box, so clip-path cuts
   it off: every plate in this app wearing the house corner-cut was silently
   throwing its glow away. The friends button lit up only because it is a
   border-radius circle with no clip-path.

   filter: drop-shadow() is applied AFTER clipping and blooms off the clipped
   silhouette, which is why #home-profile-badge and #jukebox have always used
   it. Same recipe, right mechanism.

   hue-rotate stays LAST in every chain -- filters run in order, and rotating
   before the shadows would leave the glow a different colour from the plate.
   ========================================================================== */

/* ---- the official links ---- */
.hs-link {
  /* The line itself is the lit part now, not a hairline hint of one. */
  border-color: #39ff14;
  /* Parent #home-socials owns the hue-rotate, so this chain is shadows only. */
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
  /* The clipped shadow that never rendered -- only the inset survives, and
     inset shadows are not clipped away because they paint inside the box. */
  box-shadow: inset 0 0 12px rgba(57, 255, 20, 0.1);
  transition:
    color 0.16s ease-out,
    border-color 0.16s ease-out,
    filter 0.18s ease-out,
    transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hs-link:hover {
  /* House extrusion: hard offset steps trail down-right under the bloom. */
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(2px 2px 0 rgba(15, 88, 6, 0.7))
    drop-shadow(var(--sw-glow-core-hot))
    drop-shadow(var(--sw-glow-bloom-hot));
  box-shadow: inset 0 0 12px rgba(57, 255, 20, 0.16);
}
.hs-dead, .hs-dead:hover {
  filter: none;
  box-shadow: none;
  border-color: rgba(57, 255, 20, 0.22);
}

/* ---- the cred counter ----
   Its own hue-rotate lives here, so the shadows go in front of it. */
#cred-hud {
  border-color: #39ff14;
  filter:
    drop-shadow(var(--sw-glow-core))
    drop-shadow(var(--sw-glow-bloom))
    hue-rotate(var(--logo-hue));
  box-shadow: inset 0 0 14px rgba(57, 255, 20, 0.1);
}
/* The ignite-on-change pulse has to move the same property it now glows with;
   animating box-shadow would have been animating something invisible, and it
   ended on `none`, which would have snuffed the resting glow for 1.1s. */
#cred-hud.credited { animation: credPlateGlow 1.1s ease-out; }
@keyframes credPlateGlow {
  0% {
    filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom))
            hue-rotate(var(--logo-hue));
  }
  22% {
    filter: drop-shadow(0 0 12px rgba(57, 255, 20, 1))
            drop-shadow(0 0 44px rgba(57, 255, 20, 0.85))
            hue-rotate(var(--logo-hue));
  }
  100% {
    filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom))
            hue-rotate(var(--logo-hue));
  }
}

/* ==========================================================================
   THE SOCIAL ROW, ON THE PLATES' LINE

   The profile plate and the jukebox are 84px tall and step down 44px to a
   low foot, so their foot is 40px and its top edge sits 40px off the floor.
   Sizing these buttons to that exact 40px puts their top edge AND their
   bottom edge on the same two lines the plates already draw -- the row reads
   as part of the same bar rather than as something floating above it.

   Derived from the tokens, not typed in: change --hpb-height or the step and
   this follows instead of drifting.
   ========================================================================== */
#home-socials {
  --hs-foot: calc(var(--hpb-height, 84px) - var(--jb-step, 44px)); /* 40px */
  bottom: var(--hpb-bottom, 16px);
  /* Baselines with the plates rather than centring on them. */
  align-items: flex-end;
}
.hs-link {
  width: var(--hs-foot);
  height: var(--hs-foot);
  /* The corner cut scales with the button so it stays the same angle. */
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
}
.hs-link svg { width: 21px; height: 21px; }

/* ==========================================================================
   THE SOCIAL BUTTONS, BUILT LIKE THE PLATES

   Matching the glow numbers was never going to match the look, because the
   glow was blooming off the wrong thing. #home-profile-badge and #jukebox are
   a SOLID neon shape with a dark fill inset 3px inside it -- the leftover 3px
   band is the lit edge, and it is a big bright silhouette for drop-shadow to
   work off. These were a dark box with a 1px border: almost no light to bloom.

   Same construction here, same shadow stack, so they finally read as the same
   family of object. The corner cut on the inner fill is 3px shallower than the
   outer so the lit band stays an even width around the diagonal too -- a plain
   inset would pinch to nothing at the cut corners.
   ========================================================================== */
.hs-link {
  background: #86ff66;
  border: none;
  position: relative;
  color: #86ff66;
  box-shadow: none;
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
}
.hs-link::before {
  content: '';
  position: absolute;
  inset: 3px;
  background: #061006;
  clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px));
}
/* Above the fill, or the glyph is behind it. */
.hs-link svg {
  position: relative;
  z-index: 1;
  filter: drop-shadow(0 0 5px rgba(134, 255, 102, 0.9));
}
.hs-link:hover { color: #eafff0; }
.hs-link:hover svg { filter: drop-shadow(0 0 9px rgba(255, 255, 255, 0.95)); }

/* Dead links: no shell, no bloom. The whole point is that they do not look
   like the live ones. */
.hs-dead, .hs-dead:hover {
  background: none;
  border: 1px solid rgba(57, 255, 20, 0.22);
  color: rgba(57, 255, 20, 0.7);
  filter: none;
}
.hs-dead::before { display: none; }
.hs-dead svg, .hs-dead:hover svg { filter: none; }

/* ==========================================================================
   ONE CONSTRUCTION FOR EVERY PLATE ON SCREEN

   Same recipe as #home-profile-badge / #jukebox / .hs-link: a SOLID neon
   shape, a dark fill inset 3px, and the leftover band is the lit edge. The
   remaining stragglers were dark boxes with a hairline, which is why no
   amount of tuning the shadow numbers ever made them match -- there was
   nothing bright for the bloom to come off.

   Children have to be lifted above the fill or they end up behind it. The
   ones that are already absolutely positioned only need the z-index; giving
   them position:relative would move them.
   ========================================================================== */

/* ---- the friends button ---- */
#social-toggle {
  background: #86ff66;
  border: none;
  color: #86ff66;
  box-shadow: none;
  filter:
    drop-shadow(var(--sw-glow-core))
    drop-shadow(var(--sw-glow-bloom))
    hue-rotate(var(--logo-hue));
}
#social-toggle::before {
  content: '';
  position: absolute;
  inset: 3px;
  background: #061006;
  border-radius: 50%;
}
#social-toggle svg {
  position: relative;
  z-index: 1;
  filter: drop-shadow(0 0 5px rgba(134, 255, 102, 0.9));
}
#social-toggle-dot { z-index: 2; }
#social-toggle:hover {
  color: #eafff0;
  box-shadow: none;
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(2px 2px 0 rgba(15, 88, 6, 0.7))
    drop-shadow(var(--sw-glow-core-hot))
    drop-shadow(var(--sw-glow-bloom-hot))
    hue-rotate(var(--logo-hue));
}
#social-toggle:hover svg { filter: drop-shadow(0 0 9px rgba(255, 255, 255, 0.95)); }

/* ---- the cred counter ---- */
#cred-hud {
  background: #86ff66;
  border: none;
  box-shadow: none;
}
#cred-hud::before {
  content: '';
  position: absolute;
  inset: 3px;
  background: #061006;
  /* 3px shallower than the outer cut, so the lit band keeps an even width
     around the diagonal instead of pinching shut at the corner. */
  clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
}
#cred-hud > *:not(#cred-hud-gain) { position: relative; z-index: 1; }
#cred-hud-gain { z-index: 2; }
#cred-hud-mark { stroke: #86ff66; opacity: 1; }

/* ---- the dev button ----
   Same construction, amber kept: this one is admin-only and is supposed to
   be the odd one out at a glance. Matching its shape without matching its
   colour is the point.
   Tokens can't be reused here -- they are authored green. */
#dev-open {
  background: #ffb347;
  border: none;
  color: #ffb347;
  box-shadow: none;
  filter:
    drop-shadow(0 0 8px rgba(255, 179, 71, 0.9))
    drop-shadow(0 0 28px rgba(255, 179, 71, 0.55));
}
#dev-open::before {
  content: '';
  position: absolute;
  inset: 3px;
  background: #150d02;
  border-radius: 50%;
}
#dev-open { position: fixed; }
#dev-open:hover {
  background: #ffb347;
  color: #fff0d0;
  box-shadow: none;
  filter:
    drop-shadow(2px 2px 0 rgba(150, 96, 20, 0.9))
    drop-shadow(2px 2px 0 rgba(96, 60, 10, 0.7))
    drop-shadow(0 0 10px rgba(255, 179, 71, 1))
    drop-shadow(0 0 30px rgba(255, 179, 71, 0.7));
}

/* The +N flyout, now a body-level sibling of the plate rather than a child of
   it (clip-path was cutting it away). Anchored with the same numbers the plate
   uses -- top offset + its height, and its right offset + the old inset -- so
   it lands exactly where it always meant to. */
#cred-hud-gain {
  position: fixed;
  top: 74px;   /* 16 (plate top) + 54 (plate height) + 4 */
  right: 96px; /* 82 (plate right) + 14 (the old inset) */
  z-index: 693;
}
@media (max-width: 640px) {
  #cred-hud-gain { top: 66px; right: 92px; } /* plate is 46px tall at 78px */
}

/* ==========================================================================
   SOCIAL BUTTONS: EMIT LIKE THE PLATE, NOT LIKE A SMALLER PLATE

   The corner plates throw light across the background because they are big:
   ~900px of lit perimeter against these buttons' ~160px. Copying the plate's
   shadow numbers onto a 40px button therefore gives a tight ring, not a
   spill -- the same radius reads as "hugging the edge" when the edge is a
   fifth of the length.

   So the stack is scaled to THESE, not inherited: four chained drop-shadows
   instead of two. Chained is the operative word -- each one blurs the result
   of the last, so the falloff compounds outward and the light keeps carrying
   well past where a single wide shadow would have stopped. Tight bright core
   first, then progressively wider and fainter, which is how the plate's
   two-stack reads at its own size.
   ========================================================================== */
.hs-link {
  filter:
    drop-shadow(0 0 4px rgba(160, 255, 130, 0.95))
    drop-shadow(0 0 11px rgba(57, 255, 20, 0.85))
    drop-shadow(0 0 26px rgba(57, 255, 20, 0.6))
    drop-shadow(0 0 58px rgba(57, 255, 20, 0.4));
}
.hs-link:hover {
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(2px 2px 0 rgba(15, 88, 6, 0.7))
    drop-shadow(0 0 5px rgba(210, 255, 190, 1))
    drop-shadow(0 0 14px rgba(57, 255, 20, 0.95))
    drop-shadow(0 0 32px rgba(57, 255, 20, 0.75))
    drop-shadow(0 0 70px rgba(57, 255, 20, 0.5));
}
/* The friends button is the same size as these and was lit off the plate's
   numbers too, so it gets the same correction -- hue-rotate stays last. */
#social-toggle {
  filter:
    drop-shadow(0 0 4px rgba(160, 255, 130, 0.95))
    drop-shadow(0 0 11px rgba(57, 255, 20, 0.85))
    drop-shadow(0 0 26px rgba(57, 255, 20, 0.6))
    drop-shadow(0 0 58px rgba(57, 255, 20, 0.4))
    hue-rotate(var(--logo-hue));
}
#social-toggle:hover {
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(2px 2px 0 rgba(15, 88, 6, 0.7))
    drop-shadow(0 0 5px rgba(210, 255, 190, 1))
    drop-shadow(0 0 14px rgba(57, 255, 20, 0.95))
    drop-shadow(0 0 32px rgba(57, 255, 20, 0.75))
    drop-shadow(0 0 70px rgba(57, 255, 20, 0.5))
    hue-rotate(var(--logo-hue));
}
/* The cred plate is long like the corner plates, so it keeps the two-stack --
   giving it this one would have it bleeding across the top of every screen. */

/* ==========================================================================
   THE ROUND BUTTONS GET A BORDER, NOT A SHELL

   The shell + inset-fill trick is for the clip-path plates: clip-path cuts
   box-shadow off, so their lit edge has to be real geometry. A circle has no
   such problem -- border-radius does not clip shadows -- and the trick
   actively broke #dev-open, whose label is a bare text node with no element
   to lift above the fill, so the fill painted straight over the word DEV.

   A 3px border gives the identical lit ring, keeps the text, and the bloom is
   unaffected: drop-shadow works off the whole opaque circle either way.
   ========================================================================== */
#dev-open, #social-toggle {
  border: 3px solid currentColor;
}
#dev-open::before, #social-toggle::before { content: none; }
#dev-open {
  background: #150d02;
  color: #ffb347;
}
#dev-open:hover { background: #150d02; color: #fff0d0; border-color: #ffb347; }
#social-toggle { background: #061006; }

/* The drawer covers the whole right edge, and this button is pinned to it.
   Sitting on top of an open drawer it read as a stray sticker, so it stands
   down while the drawer is out -- the drawer is where you are looking. */
body:has(#social-panel.social-open) #dev-open { opacity: 0; pointer-events: none; }
#dev-open { transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease-out, color 0.2s; }

/* ==========================================================================
   THE SHOP BAR HOLDS STILL ACROSS TABS

   The column was `justify-content: safe center` with a min-height, so its
   HEIGHT was whatever the catalog happened to be -- and centring something
   whose height changes moves everything in it. Switching BLACK MARKET -> $$$
   changes the row count, so the bar, the heading and the sort control all
   slid up or down with it. Nothing was wrong with the bar; it was being
   centred along with the cards.

   Fix: the column is now a FIXED height and starts at the top, so the bar
   lands in exactly one place regardless of what is on the shelf. The
   centring moves down onto the grid, which centres a short catalog inside
   the leftover space and scrolls a long one -- so a half-empty shelf still
   reads as centred, and the chrome above it never moves.

   safe centre, again deliberately: plain centring on a grid taller than its
   box clips the first row out of reach instead of scrolling to it.
   ========================================================================== */
#screen-store.active {
  height: calc(100 * var(--dvh, 1vh) - 62px);
  min-height: 0;
  justify-content: flex-start;
}
#shop-grid {
  flex: 1 1 auto;
  min-height: 0;
  /* Every page is padded to exactly 9 cards (see shopRender), so the grid
     never has more content than its 3x3 tracks -- nothing to scroll. The
     scrollbar rules below are dead now but harmless; left in case a future
     page size ever exceeds 9 again. */
  overflow: hidden;
}
#shop-grid::-webkit-scrollbar { width: 8px; }
#shop-grid::-webkit-scrollbar-track { background: transparent; }
#shop-grid::-webkit-scrollbar-thumb {
  background: var(--sh-dim, rgba(190, 120, 255, 0.25));
  border-radius: 4px;
}
#shop-grid { scrollbar-width: thin; scrollbar-color: var(--sh-dim, rgba(190,120,255,0.25)) transparent; }
/* The empty state sat in the flow under the grid; with the grid now flexing
   to fill, it needs to hold that space itself or the bar shifts after all. */
#shop-empty:not(.hidden) { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; }

/* The pager is the last thing in the column now, so IT owns the clearance
   for the fixed corner plates that the grid used to own back when the grid
   was the thing that scrolled all the way to the bottom of the screen. */
.shop-pager {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 14px;
  flex: none;
  padding: 14px 0 clamp(40px, 6 * var(--dvh, 1vh), 110px);
}
.shop-pager.hidden { display: none; }
.shop-page-btn {
  width: 38px;
  height: 38px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--sh-plate);
  border: 1px solid var(--sh-line);
  clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 7px 100%, 0 calc(100% - 7px));
  color: var(--sh-accent);
  font-family: 'Courier New', monospace;
  font-size: 18px;
  cursor: pointer;
  transition: color 0.16s ease-out, border-color 0.16s ease-out, background 0.16s ease-out;
}
.shop-page-btn:hover:not(:disabled) { color: var(--sh-accent-2); border-color: var(--sh-accent); }
.shop-page-btn:disabled { opacity: 0.3; cursor: default; }
.shop-page-readout {
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 2px;
  color: var(--sh-dim);
  min-width: 5ch;
  text-align: center;
}

/* ==========================================================================
   FULLSCREEN GANG-OS ACTUALLY GOES FULLSCREEN

   The handset's screen node is position:fixed in this state, and it was
   landing offset with the window scrolling in both directions.

   Cause: #screen-clans is one of the panels THE FILTER animates (crtv-jerkup
   + crtv-panel-glitch), and an element with a transform -- even the identity
   transform an animation resolves to between keyframes -- becomes the
   containing block for its position:fixed descendants. So "fixed" meant the
   1200px centred screen box, not the window: offset by that box's left edge,
   overflowing its right.

   Declaring `transform: none` cannot fix this. Animation-origin values
   outrank normal declarations, so the animation wins every frame.

   So instead of fighting for the containing block, make it harmless: the
   screen itself becomes the window in this state. The fixed child then
   resolves against a box that IS the viewport, the numbers come out the same,
   and the FILTER keeps running -- which is what the comment on that rule
   asks for anyway ("during a warp they skew WITH the picture, which is the
   point"). Dropping the animation instead would have made GANG-OS the one
   screen that stops glitching when you open it.

   #phone-screen's own sizing is left exactly as it was: measured at 1920x1080
   it lands on the viewport to the pixel. It was never the wrong size, only in
   the wrong place.
   ========================================================================== */
#screen-clans.phone-full {
  position: fixed;
  inset: 0;
  width: auto;
  height: auto;
  max-width: none;
  margin: 0;
  padding: 0;
  overflow: hidden;
  z-index: 40;
}
body:has(#screen-clans.phone-full) { max-width: none; padding: 0; overflow: hidden; }

/* ==========================================================================
   REAL EMISSION: THE HOST STOPS BEING CLIPPED

   Two dead ends behind "thick border, no glow":

   - box-shadow emits strongly but is painted as part of the element's own
     box, so clip-path erases it. Every plate wearing the house corner-cut
     was throwing its light away.
   - drop-shadow survives the clip, but it works off the ALPHA SILHOUETTE.
     On a small opaque shape that means the only visible part is the blur
     falloff past the edge, and chaining does not rescue it: each pass blurs
     the previous FAINT halo, so the stack spreads without ever brightening.
     It works on #jukebox purely because that shape is ~900px of perimeter.

   So: the host keeps the geometry but drops the clip, and the shape moves
   onto two pseudo-elements -- ::before is the neon shell, ::after is the dark
   fill 3px inside it, and the band between them is the lit edge, exactly as
   before. The host is now an unclipped box whose box-shadow nothing can cut,
   and box-shadow is drawn from the border box rather than from alpha, so it
   emits at full strength. At these blur radii the shadow being a plain
   rectangle rather than a cut one is invisible.

   Circles never had the clip problem, so they just take the same stack.
   ========================================================================== */

/* ---- the official links ---- */
.hs-link {
  background: none;
  border: none;
  clip-path: none;
  filter: none;
  box-shadow:
    0 0 10px rgba(57, 255, 20, 0.95),
    0 0 26px rgba(57, 255, 20, 0.7),
    0 0 55px rgba(57, 255, 20, 0.5),
    0 0 105px rgba(57, 255, 20, 0.32);
  transition: box-shadow 0.18s ease-out, color 0.16s ease-out,
              transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hs-link::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  background: #86ff66;
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
}
.hs-link::after {
  content: '';
  position: absolute;
  inset: 3px;
  z-index: 1;
  background: #061006;
  clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px));
}
.hs-link svg { position: relative; z-index: 2; }
.hs-link:hover {
  box-shadow:
    0 0 12px rgba(210, 255, 190, 1),
    0 0 32px rgba(57, 255, 20, 0.9),
    0 0 70px rgba(57, 255, 20, 0.65),
    0 0 130px rgba(57, 255, 20, 0.45);
}
.hs-dead, .hs-dead:hover { box-shadow: none; }
.hs-dead::before { background: rgba(57, 255, 20, 0.22); }

/* ---- the cred counter ---- */
#cred-hud {
  background: none;
  border: none;
  clip-path: none;
  filter: hue-rotate(var(--logo-hue));
  box-shadow:
    0 0 10px rgba(57, 255, 20, 0.9),
    0 0 28px rgba(57, 255, 20, 0.6),
    0 0 60px rgba(57, 255, 20, 0.4),
    0 0 110px rgba(57, 255, 20, 0.24);
}
#cred-hud::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  background: #86ff66;
  clip-path: polygon(11px 0, 100% 0, 100% calc(100% - 11px), calc(100% - 11px) 100%, 0 100%, 0 11px);
}
#cred-hud::after {
  content: '';
  position: absolute;
  inset: 3px;
  z-index: 1;
  background: #061006;
  clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
}
#cred-hud > *:not(#cred-hud-gain) { position: relative; z-index: 2; }
/* The ignite pulse now rides box-shadow, since that is what carries the light. */
#cred-hud.credited { animation: credPlateBurn 1.1s ease-out; }
@keyframes credPlateBurn {
  0%, 100% {
    box-shadow: 0 0 10px rgba(57,255,20,0.9), 0 0 28px rgba(57,255,20,0.6),
                0 0 60px rgba(57,255,20,0.4), 0 0 110px rgba(57,255,20,0.24);
  }
  22% {
    box-shadow: 0 0 16px rgba(210,255,190,1), 0 0 44px rgba(57,255,20,0.95),
                0 0 90px rgba(57,255,20,0.7), 0 0 160px rgba(57,255,20,0.45);
  }
}

/* ---- the circles ---- */
#social-toggle {
  filter: hue-rotate(var(--logo-hue));
  box-shadow:
    0 0 10px rgba(57, 255, 20, 0.95),
    0 0 26px rgba(57, 255, 20, 0.7),
    0 0 55px rgba(57, 255, 20, 0.5),
    0 0 105px rgba(57, 255, 20, 0.32);
}
#social-toggle:hover {
  box-shadow:
    0 0 12px rgba(210, 255, 190, 1),
    0 0 32px rgba(57, 255, 20, 0.9),
    0 0 70px rgba(57, 255, 20, 0.65),
    0 0 130px rgba(57, 255, 20, 0.45);
}
#dev-open {
  filter: none;
  box-shadow:
    0 0 10px rgba(255, 179, 71, 0.95),
    0 0 26px rgba(255, 179, 71, 0.65),
    0 0 55px rgba(255, 179, 71, 0.45),
    0 0 105px rgba(255, 179, 71, 0.28);
}
#dev-open:hover {
  box-shadow:
    0 0 12px rgba(255, 226, 170, 1),
    0 0 32px rgba(255, 179, 71, 0.9),
    0 0 70px rgba(255, 179, 71, 0.6),
    0 0 130px rgba(255, 179, 71, 0.4);
}

/* The scanline texture sits at z-index 700, ABOVE all of this, and it is what
   was eating the last of the bloom. It stays on top -- that is the whole
   point of THE FILTER -- but the plates move up under it instead of down
   among the page furniture, so the light lands before the glass does. */
#home-socials { z-index: 690; }

/* ==========================================================================
   THE CORNER TRIANGLES

   The dark fill was `inset: 3px` with its own smaller corner cut. That is
   correct for the four straight edges and WRONG for the two diagonals: a
   3px box inset moves a 45-degree edge only 3/sqrt(2) = 2.12px perpendicular,
   so the fill overshot the shell's cut and poked out as a dark triangle at
   each cut corner.

   Fixed by dropping the inset and writing the inner shape directly in the
   host's coordinates, with every edge offset a true 3px along its own
   normal. For a 45-degree edge that means shifting the line by 3*sqrt(2) =
   4.243px, which lands the diagonal's endpoints 10.24px in rather than the
   naive 9px. Hence the odd-looking constants -- they are the geometry, not a
   fudge, and the band is now an even 3px the whole way round.
   ========================================================================== */
.hs-link::after {
  inset: 0;
  clip-path: polygon(
    3px 3px,
    calc(100% - 10.24px) 3px,
    calc(100% - 3px) 10.24px,
    calc(100% - 3px) calc(100% - 3px),
    10.24px calc(100% - 3px),
    3px calc(100% - 10.24px)
  );
}
/* The cred plate cuts the opposite pair of corners, so its inner shape is
   the mirror -- same 4.243px normal offset, same 12.24px landing. */
#cred-hud::after {
  inset: 0;
  clip-path: polygon(
    12.24px 3px,
    calc(100% - 3px) 3px,
    calc(100% - 3px) calc(100% - 12.24px),
    calc(100% - 12.24px) calc(100% - 3px),
    3px calc(100% - 3px),
    3px 12.24px
  );
}

/* ==========================================================================
   THE BALANCE MOVES INTO THE DRAWER

   It was fixed to the top-right of every non-gameplay screen. Now it is a
   member of the drawer header, sitting between the wordmark and the bell.

   Three things had to come off it, all of which only made sense while it
   floated:
   - position:fixed and its offsets, so it takes part in the header's flex row
   - its own hue-rotate: #social-panel already rotates the entire drawer, and
     two rotations would have left this plate a different colour from the
     panel it is now inside
   - the emissive bloom is dialled back, because on a lit panel a 105px halo
     washes the header rather than reading as light
   ========================================================================== */
#cred-hud {
  position: static;
  top: auto;
  right: auto;
  z-index: auto;
  filter: none;
  height: 44px;
  padding: 0 14px 0 11px;
  /* Pushes it against the buttons on the right; the wordmark keeps the left. */
  margin-left: auto;
  flex: 0 0 auto;
  box-shadow:
    0 0 8px rgba(57, 255, 20, 0.75),
    0 0 22px rgba(57, 255, 20, 0.45),
    0 0 46px rgba(57, 255, 20, 0.22);
}
/* Same, quieter, for the ignite pulse. */
#cred-hud.credited { animation: credPlateBurnPanel 1.1s ease-out; }
@keyframes credPlateBurnPanel {
  0%, 100% {
    box-shadow: 0 0 8px rgba(57,255,20,0.75), 0 0 22px rgba(57,255,20,0.45),
                0 0 46px rgba(57,255,20,0.22);
  }
  22% {
    box-shadow: 0 0 14px rgba(210,255,190,1), 0 0 36px rgba(57,255,20,0.9),
                0 0 74px rgba(57,255,20,0.5);
  }
}
#cred-hud-n { font-size: 19px; }
#cred-hud-mark { width: 18px; height: 18px; }
/* The header is a tight row; the wordmark yields first when it gets tight. */
#social-head #social-title-wrap { min-width: 0; }

/* The +N flyout stays OUT of the drawer on purpose. Creds land at the end of
   a match, and if the confirmation lived inside the panel you would only ever
   see it by happening to have the drawer open at that moment -- which is to
   say, almost never. It stays a fixed toast under the drawer button, so the
   payout is still announced wherever you are. */
#cred-hud-gain {
  top: 78px;
  right: 16px;
  text-align: right;
}
@media (max-width: 640px) {
  #cred-hud-gain { top: 70px; right: 14px; }
}

/* ==========================================================================
   THE TWO CIRCLES COME DOWN

   They inherited the social row's stack, which was tuned for buttons sitting
   alone on a dark strip at the bottom of the menu. These sit in the top-right
   corner over whatever the screen happens to be, and at 105px the two halos
   overlapped each other and washed the corner out.

   Cut to a close rim plus one soft falloff -- lit, not radiating. The social
   row keeps its stack; it was right for where it lives.
   ========================================================================== */
#social-toggle {
  box-shadow:
    0 0 6px rgba(57, 255, 20, 0.55),
    0 0 16px rgba(57, 255, 20, 0.28);
}
#social-toggle:hover {
  box-shadow:
    0 0 8px rgba(210, 255, 190, 0.8),
    0 0 22px rgba(57, 255, 20, 0.45);
}
#dev-open {
  box-shadow:
    0 0 6px rgba(255, 179, 71, 0.5),
    0 0 16px rgba(255, 179, 71, 0.24);
}
#dev-open:hover {
  box-shadow:
    0 0 8px rgba(255, 226, 170, 0.8),
    0 0 22px rgba(255, 179, 71, 0.42);
}

/* ==========================================================================
   NO CUT CORNERS ON THE SOCIAL BUTTONS

   The dark notches were the corner cut itself, not a stray element. An outer
   box-shadow is deliberately never painted inside the element's own box --
   otherwise it would bleed through every transparent background -- so no
   glow can reach the cut-away triangle. Against a bright rim that reads as a
   hole punched in the button.

   There is no tuning that fixes it while the host stays a rectangle carrying
   the light, and the host has to stay a rectangle because clip-path erases
   box-shadow. So the cut goes. Plain square shell, even 3px band all round,
   nothing to punch a hole in.

   The cred plate keeps its cut: it sits on the drawer's lit panel, so its
   corners show panel rather than a black hole.
   ========================================================================== */
.hs-link::before { clip-path: none; }
.hs-link::after { inset: 3px; clip-path: none; }

/* ==========================================================================
   THE PLATE MUST BE ITS OWN POSITIONING CONTEXT

   Moving it into the drawer header, I set `position: static` -- correct for
   the flex row, catastrophic for the shape. The shell and fill are
   `position: absolute; inset: 0`, so with no positioned ancestor of their own
   they resolved against #social-panel and stretched over the ENTIRE drawer:
   the fill painted the friends list out, and only this plate's own children
   survived because they carry z-index 2.

   relative, not static. It changes nothing about the layout -- the plate is
   still a normal flex item -- but it puts the pseudo-elements back inside it.
   ========================================================================== */
#cred-hud { position: relative; }

/* The cred plate loses its cut corners too. I left them on assuming the
   drawer's panel would show through and read as intentional -- it does not;
   the panel is dark enough that the unlit corner reads as the same black
   notch the social buttons had. Same cause: an outer box-shadow is never
   painted inside the element's own box, so no glow reaches a cut-away
   corner. Square shell, even 3px band. */
#cred-hud::before { clip-path: none; }
#cred-hud::after { inset: 3px; clip-path: none; }

/* ==========================================================================
   THE BALANCE READS BIGGER, AND THE SEASON TAG GOES

   The number is the only thing on this plate anyone actually reads, so it
   and the mark go up; CRED stays small because it is a unit, not a value.

   The season tag is hidden in CSS rather than ripped out of the markup: the
   element is still written to by credHudPaint on every balance push, and
   deleting it would mean touching that path for a purely visual decision.
   Hidden here, it costs nothing and comes back by deleting three lines.
   ========================================================================== */
#cred-hud {
  height: 52px;
  padding: 0 16px 0 13px;
  gap: 10px;
}
#cred-hud-n { font-size: 27px; letter-spacing: 1.5px; }
#cred-hud-mark { width: 26px; height: 26px; stroke-width: 1.7; }
#cred-hud-k { font-size: 11px; }
#cred-hud-season, #cred-hud-season:not(.hidden) { display: none; }

/* CRED goes too -- the mark already says what the number is. Hidden rather
   than deleted, same reasoning as the season tag: the markup stays so the
   aria-label on the plate keeps naming the value for a screen reader. */
#cred-hud-k { display: none; }

/* ---- the wager panel is anonymous ----
   The roster plate is gone from the markup, so the pot plate grows into the
   space instead of leaving a hole in the column. */
#wg-pot { display: flex; flex-direction: column; justify-content: center; }
#wg-anon {
  margin-top: 14px;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 3px;
  color: rgba(134, 255, 102, 0.5);
}
#wg-anon em { font-style: normal; opacity: 0.7; letter-spacing: 2px; }

/* ==========================================================================
   THE WAGER IS ONE PANEL, IN THE MIDDLE

   It was a two-column grid stretched edge to edge, with the terms stranded
   in the bottom-left corner and the pot plate a tall empty column. On a wide
   window the two halves of a single decision sat a screen apart.

   Now: one centred plate holding the pot, the rig and a short explanation,
   in that reading order. The overlay itself becomes the centring device --
   it was already fixed inset:0, it just needed to stop being the layout.
   ========================================================================== */
#screen-wager {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  padding: clamp(16px, 3 * var(--dvh, 1vh), 60px) clamp(16px, 2 * var(--dvw, 1vw), 48px);
}
#wg-panel {
  display: flex;
  flex-direction: column;
  gap: clamp(14px, 1.6 * var(--dvh, 1vh), 26px);
  /* Design px, not viewport px: --dvw is pinned to the 2560 canvas, so this
     is one size that scales with everything else rather than a width that
     grows on a big monitor. */
  width: min(820px, 92 * var(--dvw, 1vw));
  max-height: 88 * var(--dvh, 1vh);
  padding: clamp(20px, 2.4 * var(--dvh, 1vh), 38px) clamp(22px, 2 * var(--dvw, 1vw), 42px);
  background: var(--wg-plate);
  border: 1px solid var(--wg-line);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 30px 90px rgba(0, 0, 0, 0.75),
              0 0 60px rgba(255, 210, 87, 0.09);
  animation: wgPanelIn 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes wgPanelIn {
  from { opacity: 0; transform: translateY(14px) scale(0.985); }
  to   { opacity: 1; transform: none; }
}
#wg-head { padding-top: 0; }

/* One column, in the order the decision is actually made: what is at stake,
   what you are putting in, then the rules. */
#wg-body {
  display: flex;
  flex-direction: column;
  gap: clamp(14px, 1.6 * var(--dvh, 1vh), 26px);
  min-height: 0;
}

/* The pot is a strip now, not a column. */
#wg-pot {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px 16px;
  padding: 14px 18px;
  background: rgba(255, 210, 87, 0.05);
  border: 1px solid var(--wg-line);
}
#wg-pot .wg-k { margin: 0; }
#wg-pot-n { margin: 0 auto 0 0; line-height: 1; }
#wg-pot .wg-sub { margin: 0; }

#wg-rig { gap: clamp(10px, 1.2 * var(--dvh, 1vh), 20px); }

/* The explanation: prose, one block, no per-control captions. */
#wg-how {
  padding-top: 14px;
  border-top: 1px solid var(--wg-line);
}
#wg-how p {
  margin: 8px 0 0;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  line-height: 1.65;
  letter-spacing: 0.5px;
  color: var(--wg-dim);
}
#wg-how b { color: var(--wg-ink-2); font-weight: bold; }

/* calc(), not a bare multiplication -- `88 * var(--dvh)` is not a valid
   length and the whole declaration was being dropped, so the panel had no
   ceiling at all. With a tall enough catalog of controls it would have run
   off the screen rather than scrolling inside itself. */
#wg-panel {
  max-height: calc(88 * var(--dvh, 1vh));
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--wg-line) transparent;
}
#wg-panel::-webkit-scrollbar { width: 8px; }
#wg-panel::-webkit-scrollbar-track { background: transparent; }
#wg-panel::-webkit-scrollbar-thumb { background: var(--wg-line); border-radius: 4px; }

/* ==========================================================================
   HOVER GLOW STAYS ON ITS OWN BUTTON

   The hover stack reached 130px from a 40px button sitting 18px from its
   neighbour, so hovering one washed the one beside it. Raising the hovered
   button's z-index would not help -- that makes its glow paint even more
   firmly ON TOP of the neighbour, which is the complaint.

   So the reach comes down instead. The bright core is untouched, since that
   is what makes the hover read; only the wide outer layers go, because those
   were never lighting this button, they were lighting the next one.
   ========================================================================== */
#home-socials { gap: 24px; }
.hs-link:hover {
  box-shadow:
    0 0 8px rgba(210, 255, 190, 1),
    0 0 18px rgba(57, 255, 20, 0.9),
    0 0 36px rgba(57, 255, 20, 0.55);
}


/* ==========================================================================
   CHOOSE YOUR MODE: JUST THE MODES

   No blurb, no katakana. A tile is the artwork and the name, and that is the
   whole thing -- three names is a smaller decision than three paragraphs,
   and this screen only has to get you to one of them.

   Hidden rather than cut from mpModalRender(): the strings still live in
   MP_GAMEMODES beside each mode, so a blurb cannot go stale against a mode
   it no longer describes, and putting either back is deleting a line here.

   The tile's own padding takes over from the body's bottom spacing, so the
   name is not left sitting on the floor of the plate.
   ========================================================================== */
.mp-tile-blurb, .mp-tile-kata { display: none; }
.mp-tile-body { padding-bottom: 2px; }
.mp-tile-label { white-space: normal; }

/* ==========================================================================
   MODE TILE ART: DRESS THE STAGE

   The motifs were fine; the stage was not. Each one floated in a large flat
   grid with nothing framing it, which is what read as minimal effort. This
   adds the dressing -- a spotlight behind the motif, a horizon line, HUD
   corner brackets and a line of telemetry -- and rebuilds the one motif that
   was actually weak: the roulette wheel's spokes were 46% long against a 39%
   rim radius, so they overshot the rim and the whole thing read as an
   asterisk over a circle.

   Still all gradients and pseudo-elements: no image assets, no new markup,
   and everything keys off --mp-accent so it recolours red/green with the
   producer/artist split exactly like the art it replaces. Static dressing
   only -- the sole animation remains the existing hover spin/EQ.
   ========================================================================== */
.mp-tile-art {
  position: relative;
  background:
    /* HUD corner brackets: an L at each corner, eight strokes. */
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px 10px / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px 10px / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) 10px / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) 10px / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px calc(100% - 10px) / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px calc(100% - 10px) / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) calc(100% - 10px) / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) calc(100% - 10px) / 2px 14px no-repeat,
    /* spotlight behind the motif, so it sits in light instead of on wallpaper */
    radial-gradient(38% 60% at 50% 46%, color-mix(in srgb, var(--mp-accent) 16%, transparent), transparent 70%),
    /* horizon line the motif stands against */
    linear-gradient(180deg, transparent calc(72% - 1px), color-mix(in srgb, var(--mp-accent) 30%, transparent) calc(72% - 1px) 72%, transparent 72%),
    /* vignette so the edges fall away instead of running flat to the border */
    radial-gradient(120% 120% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.5) 100%),
    /* the original grid + ground, unchanged underneath */
    repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    linear-gradient(150deg, rgba(24, 60, 34, 0.75) 0%, rgba(6, 16, 9, 0.9) 100%);
}
/* Telemetry, top-left inside the brackets. Machine dressing, not information
   -- which is why it is aria-hidden along with the rest of the art. */
.mp-tile-art::before {
  content: 'SYS//READY';
  position: absolute;
  top: 15px; left: 30px;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  font-style: normal;
  letter-spacing: 3px;
  color: color-mix(in srgb, var(--mp-accent) 55%, transparent);
  pointer-events: none;
}
.mp-tile[data-gamemode="beat_wars"] .mp-tile-art::before { content: 'CH·01 // SCORE'; }
.mp-tile[data-gamemode="sample_flip"] .mp-tile-art::before { content: '33RPM // FLIP'; }
.mp-tile[data-gamemode="genre_roulette"] .mp-tile-art::before { content: 'RNG // SEEDED'; }

/* ---- BEAT SHOWCASE: peak caps on the analyser ---- */
.mp-tile[data-gamemode="beat_wars"] .mp-art i { position: relative; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i::after {
  content: '';
  position: absolute;
  top: -6px; left: 0; right: 0;
  height: 3px;
  background: color-mix(in srgb, var(--mp-accent) 45%, white);
  box-shadow: 0 0 8px var(--mp-accent-dim);
}

/* ---- SAMPLE FLIP: the record gets weight ----
   A glow off the disc and darkness behind its edge, so it reads as an object
   on the deck rather than a printed circle. */
.mp-tile[data-gamemode="sample_flip"] .mp-art::before {
  box-shadow:
    0 0 22px color-mix(in srgb, var(--mp-accent) 35%, transparent),
    0 6px 18px rgba(0, 0, 0, 0.7);
}

/* ---- GENRE ROULETTE: an actual wheel ---- */
/* Spokes end AT the rim (39% radius) instead of overshooting it, and thin
   out -- they are structure now, not the subject. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i {
  width: 2px;
  height: 39%;
  opacity: 0.35;
}
/* The rim becomes pockets: alternating conic segments masked to a ring, the
   thing that says "roulette" rather than "circle". */
.mp-tile[data-gamemode="genre_roulette"] .mp-art::before {
  width: 78%; height: 78%;
  border: 2px solid currentColor;
  background: repeating-conic-gradient(
    color-mix(in srgb, currentColor 80%, transparent) 0 30deg,
    transparent 30deg 60deg
  );
  -webkit-mask: radial-gradient(circle, transparent 0 62%, #000 63% 100%);
          mask: radial-gradient(circle, transparent 0 62%, #000 63% 100%);
  box-shadow: 0 0 20px color-mix(in srgb, currentColor 40%, transparent);
  opacity: 1;
}
/* Hub: smaller, lit, with a dark centre so it reads as a bearing. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art::after {
  width: 13%; height: 13%;
  background: radial-gradient(circle, #05080a 0 28%, currentColor 34% 100%);
  box-shadow: 0 0 12px color-mix(in srgb, currentColor 60%, transparent);
}
/* The pointer, OUTSIDE the spinning element so it holds still while the
   wheel turns under it -- a wheel with nothing to land on is a spinner. */
.mp-tile[data-gamemode="genre_roulette"] .mp-tile-art::after {
  content: '';
  position: absolute;
  top: 12%; left: 50%;
  transform: translateX(-50%);
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top: 9px solid var(--mp-accent);
  filter: drop-shadow(0 0 6px var(--mp-accent-dim));
  pointer-events: none;
}

/* ==========================================================================
   ROULETTE TILE = THE ACTUAL MACHINE

   The wheel was never what this mode looks like. The draw is #rl-machine: a
   dark window with scanlines, a marker band across the middle, carets biting
   in from the sides and the drawn name lit between them. So the tile is now
   that machine in miniature, built from the same six <i> the other modes
   use: two carets, the scrolling strip, the lit name; two spare, hidden.

   Everything from the wheel is explicitly unwound rather than trusted to
   cascade away -- these selectors tie with the wheel's, and only source
   order makes this block win, so each property the wheel set is reset here.
   ========================================================================== */
.mp-tile[data-gamemode="genre_roulette"] .mp-art { animation: none; }
.mp-tile[data-gamemode="genre_roulette"]:hover .mp-art,
.mp-tile[data-gamemode="genre_roulette"].is-on .mp-art { animation: none; }
/* the old top pointer -- gone; the machine's carets do this job */
.mp-tile[data-gamemode="genre_roulette"] .mp-tile-art::after { content: none; }

/* THE WINDOW: dark glass, scanlines, two marker lines. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art::before {
  content: '';
  position: absolute;
  left: 0; right: 0;
  top: 22%; height: 56%;
  width: auto;
  border: 1px solid color-mix(in srgb, currentColor 50%, transparent);
  border-radius: 0;
  -webkit-mask: none;
          mask: none;
  background:
    /* marker lines at the band the name lands in */
    linear-gradient(currentColor 0 0) 0 30% / 100% 2px no-repeat,
    linear-gradient(currentColor 0 0) 0 70% / 100% 2px no-repeat,
    /* glass scanlines */
    repeating-linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0 1px, transparent 1px 3px),
    #030b05;
  box-shadow:
    inset 0 0 22px rgba(0, 0, 0, 0.9),
    0 0 14px color-mix(in srgb, currentColor 25%, transparent);
  opacity: 1;
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art::after { content: none; }

/* Reset the spokes' geometry before the parts reuse them. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i {
  position: absolute;
  width: auto; height: auto;
  bottom: auto;
  background: none;
  opacity: 1;
  transform: none;
  transform-origin: center;
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(1),
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(2),
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3),
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(4) { transform: none; }
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(5),
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(6) { display: none; }

/* The carets, biting in at the marker band -- same language as #rl-marker. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(1) {
  left: -2px; top: 50%;
  transform: translateY(-50%);
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
  border-left: 7px solid currentColor;
  filter: drop-shadow(0 0 5px currentColor);
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(2) {
  right: -2px; top: 50%;
  transform: translateY(-50%);
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
  border-right: 7px solid currentColor;
  filter: drop-shadow(0 0 5px currentColor);
}
/* The strip: rows of "names" waiting above and below the window's band. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) {
  left: 14%; right: 14%;
  top: 26%; height: 48%;
  background: repeating-linear-gradient(180deg,
    color-mix(in srgb, currentColor 35%, transparent) 0 4px,
    transparent 4px 13px);
  opacity: 0.5;
}
/* The drawn one, lit between the markers -- the landed state of the draw. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(4) {
  left: 24%; right: 24%;
  top: calc(50% - 3px); height: 6px;
  background: color-mix(in srgb, currentColor 55%, white);
  box-shadow: 0 0 10px currentColor, 0 0 26px color-mix(in srgb, currentColor 50%, transparent);
}
/* Hover: the strip runs, the way the machine does while drawing. */
.mp-tile[data-gamemode="genre_roulette"]:hover .mp-art i:nth-child(3) {
  animation: mpReelRun 0.45s linear infinite;
}
@keyframes mpReelRun { to { background-position-y: 13px; } }
@media (prefers-reduced-motion: reduce) {
  .mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) { animation: none !important; }
}

/* And the SELECTED tile keeps the stage dressing: .mp-tile-on .mp-tile-art
   sets its own background, which was silently discarding the brackets,
   spotlight and horizon on exactly the tile most looked at. Same stack, with
   the -on state's brighter grid and ground underneath. */
.mp-tile-on .mp-tile-art {
  background:
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px 10px / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px 10px / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) 10px / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) 10px / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px calc(100% - 10px) / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) 10px calc(100% - 10px) / 2px 14px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) calc(100% - 10px) / 14px 2px no-repeat,
    linear-gradient(color-mix(in srgb, var(--mp-accent) 75%, transparent) 0 0) calc(100% - 10px) calc(100% - 10px) / 2px 14px no-repeat,
    radial-gradient(38% 60% at 50% 46%, color-mix(in srgb, var(--mp-accent) 16%, transparent), transparent 70%),
    linear-gradient(180deg, transparent calc(72% - 1px), color-mix(in srgb, var(--mp-accent) 30%, transparent) calc(72% - 1px) 72%, transparent 72%),
    radial-gradient(120% 120% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.5) 100%),
    repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
    repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
    linear-gradient(150deg, rgba(38, 90, 50, 0.8) 0%, rgba(8, 22, 12, 0.92) 100%);
}

/* ==========================================================================
   ROULETTE TILE: REAL NAMES ON THE STRIP

   The strip was abstract bars; the machine it is imitating shows genre
   names. So the strip is a column of actual names now, faded at the glass
   edges, with the drawn one lit between the markers -- the landed state of
   the real draw, in miniature. On hover the column runs, exactly like the
   machine mid-draw.

   The list lives in CSS content, written TWICE: the loop animates to -50%,
   so the second copy is standing where the first began and the wrap is
   invisible. Decorative text on a decorative tile -- the art is aria-hidden,
   so a screen reader never meets it.
   ========================================================================== */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) {
  background: none;
  overflow: hidden;
  left: 8%; right: 8%;
  top: 24%; height: 52%;
  opacity: 1;
  -webkit-mask: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
          mask: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3)::before {
  content: 'TRAP \A HOUSE \A DRILL \A TECHNO \A BOOM BAP \A PHONK \A R&B \A DNB \A TRAP \A HOUSE \A DRILL \A TECHNO \A BOOM BAP \A PHONK \A R&B \A DNB';
  display: block;
  white-space: pre;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-style: normal;
  font-weight: bold;
  font-size: 9px;
  line-height: 15px;
  letter-spacing: 3px;
  color: color-mix(in srgb, currentColor 45%, transparent);
}
/* The drawn one, lit in the marker band -- text now, not a bar. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(4) {
  background: none;
  box-shadow: none;
  left: 0; right: 0;
  top: 50%; height: auto;
  transform: translateY(-50%);
  text-align: center;
  font-family: 'Courier New', monospace;
  font-style: normal;
  font-weight: bold;
  font-size: 11px;
  letter-spacing: 4px;
  color: color-mix(in srgb, currentColor 40%, white);
  text-shadow: 0 0 8px currentColor, 0 0 22px color-mix(in srgb, currentColor 60%, transparent);
}
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(4)::before { content: 'TRAP'; }
/* Hover: the names fly past. -50% = exactly one copy of the list, so the
   wrap lands on identical pixels and the loop has no visible seam. */
.mp-tile[data-gamemode="genre_roulette"]:hover .mp-art i:nth-child(3)::before {
  animation: mpNamesRun 1.1s linear infinite;
}
@keyframes mpNamesRun { to { transform: translateY(-50%); } }
/* While the strip runs, the landed name stands by instead of fighting it. */
.mp-tile[data-gamemode="genre_roulette"]:hover .mp-art i:nth-child(4) { opacity: 0.25; }
@media (prefers-reduced-motion: reduce) {
  .mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3)::before { animation: none !important; }
}

/* Bind the machine's colour to the accent explicitly. Everything in this art
   is drawn with currentColor, and in some states nothing up the chain sets a
   colour, so the whole machine quietly rendered in the default ink -- black
   glass lines on black glass. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art { color: var(--mp-accent, #39ff14); }

/* The strip stays OUT of the marker band. Its rows were scrolling straight
   through the lit name and the two piled up in the band -- so the mask that
   fades the column at the glass edges now also punches a hole over the band
   itself. A passing name dissolves before it reaches the lit one and
   re-forms below it, which is how the real machine's glass reads. The band
   (markers at 30%/70% of the window) spans 28.5%..71.5% of the strip; the
   feather starts a touch inside those. */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) {
  -webkit-mask: linear-gradient(180deg,
    transparent 0, #000 14%, #000 24%, transparent 31%,
    transparent 69%, #000 76%, #000 86%, transparent 100%);
          mask: linear-gradient(180deg,
    transparent 0, #000 14%, #000 24%, transparent 31%,
    transparent 69%, #000 76%, #000 86%, transparent 100%);
}

/* ==========================================================================
   ROULETTE TILE + TILE NAMES: CENTRED

   Measured, the machine itself was already exact -- window, band and lit
   name all on the art's centre to the pixel. Two things were not:

   - The strip's text column started at the strip's top edge, so its rows sat
     0.23 line-heights off the vertical centre: one name hugged the top
     marker while a gap yawned under the bottom one. The column is now flex-
     centred, which lands a row BOUNDARY exactly on the centre line -- rows
     mirror above and below the band, whatever size the art resolves to.
     The scroll still loops seamlessly: the -50% travel is measured on the
     text block itself, and centring moves its start, not its length.

   - The mode name under the art was left-aligned, which read as off-centre
     now that it is the only text on the tile. Centred, along with the body
     column it sits in.
   ========================================================================== */
.mp-tile[data-gamemode="genre_roulette"] .mp-art i:nth-child(3) {
  display: flex;
  align-items: center;
  justify-content: center;
}
.mp-tile-body { align-items: center; text-align: center; }

/* The telemetry line goes. The corner brackets, spotlight and horizon stay;
   the words were the part that read as noise. One line to bring back. */
.mp-tile-art::before { content: none; }

/* ==========================================================================
   TILE ART: SIMPLER, SMOOTHER

   The corner telemetry goes for real this time -- the first attempt reset
   only the base rule, and the per-mode content rules outrank it, so all
   three are named here at the same specificity that set them.

   SAMPLE FLIP loses the tonearm assembly entirely (stick, pivot ball and
   headshell read as clutter at this size) and the groove rings, which
   aliased into a dartboard. What remains is the smooth object: a dark disc
   with soft shading, one wide whisper of a sheen, the accent label and the
   spindle. The hover spin stays -- the conic light turning is what sells it.

   BEAT SHOWCASE loses the peak caps and rounds the bars off; the EQ run
   slows and eases so the hover reads as breathing rather than flickering.
   ========================================================================== */
.mp-tile-art::before,
.mp-tile[data-gamemode="beat_wars"] .mp-tile-art::before,
.mp-tile[data-gamemode="sample_flip"] .mp-tile-art::before,
.mp-tile[data-gamemode="genre_roulette"] .mp-tile-art::before { content: none; }

/* ---- sample flip ---- */
.mp-tile[data-gamemode="sample_flip"] .mp-art i:nth-child(1) { display: none; }
.mp-tile[data-gamemode="sample_flip"] .mp-art::before {
  background:
    /* spindle */
    radial-gradient(circle at 50% 50%, #05080a 0 2.5%, transparent 3%),
    /* label */
    radial-gradient(circle at 50% 50%, var(--mp-accent) 0 13%, transparent 13.5%),
    /* one soft sheen band instead of printed rings */
    radial-gradient(circle at 50% 50%, transparent 0 34%, rgba(255, 255, 255, 0.05) 48%, transparent 62%),
    /* smooth turning light */
    conic-gradient(from 200deg,
      rgba(255, 255, 255, 0.12) 0deg 40deg,
      transparent 90deg 200deg,
      rgba(255, 255, 255, 0.08) 230deg 265deg,
      transparent 310deg 360deg),
    radial-gradient(circle at 38% 30%, #22282c 0%, #14181b 45%, #080a0c 100%);
  box-shadow:
    0 6px 18px rgba(0, 0, 0, 0.7),
    0 0 22px var(--mp-accent-dim);
}

/* ---- beat showcase ---- */
.mp-tile[data-gamemode="beat_wars"] .mp-art i::after { content: none; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i {
  width: 9px;
  border-radius: 4px;
}
.mp-tile[data-gamemode="beat_wars"]:hover .mp-art i {
  animation-duration: 1.5s;
  animation-timing-function: ease-in-out;
}

/* The EQ dips, it does not grow. The run went 0.55x..1.25x, so the tallest
   bar (82% of the art) overshot its own resting height by a quarter and
   nearly hit the frame. Rest height is now the CEILING: bars duck under it
   and return, and the tile's silhouette never changes on hover. */
@keyframes mp-art-eq {
  0%, 100% { transform: scaleY(0.62); }
  50%      { transform: scaleY(1); }
}

/* ==========================================================================
   THE WAGER WEARS BLACK MARKET PURPLE

   Owner's rule, and it is a good one: PURPLE is the creds currency (the
   BLACK MARKET shelf), GOLD is real money (the $$$ shelf). The wager stakes
   creds, so a gold betting screen was wearing the wrong currency's colour.

   Same palette the shop's cred shelf declares, applied by re-pointing the
   --wg-* variables; the handful of hardcoded golds below them are re-stated
   in purple at the same alphas. The risk colour stays red -- danger is not
   a currency.
   ========================================================================== */
#screen-wager {
  --wg-ink:   #c04dff;
  --wg-ink-2: #f3e8ff;
  --wg-dim:   #8f76a8;
  --wg-line:  rgba(192, 77, 255, 0.26);
  --wg-hot:   #c04dff;
  --wg-glow:  rgba(192, 77, 255, 0.5);
  --wg-plate: rgba(22, 10, 34, 0.9);
  --wg-ground: #0a0510;
  background-image: radial-gradient(120% 80% at 50% 0%, rgba(192, 77, 255, 0.1), transparent 66%);
}
#wg-panel {
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 30px 90px rgba(0, 0, 0, 0.75),
              0 0 60px rgba(192, 77, 255, 0.09);
}
#wg-pot { background: rgba(192, 77, 255, 0.05); }
.wgf-slit { box-shadow: inset 0 1px 0 rgba(192, 77, 255, 0.18); }
#wg-lock:hover:not(:disabled) { background: rgba(192, 77, 255, 0.12); }
.wg-pip { background: rgba(192, 77, 255, 0.22); }
/* The warm-up as the stake climbs: gold ran amber; purple runs magenta.
   ALL IN stays the risk red. */
#wg-num.hot { color: #e07bff; text-shadow: 0 0 40px rgba(214, 100, 255, 0.75); }
/* The flames that build behind the number as the stake climbs -- orange on
   the gold panel, violet here. */
#wg-heat i { background: linear-gradient(180deg, transparent, rgba(214, 100, 255, 0.85)); }

/* ==========================================================================
   MODE SELECT, STRIPPED TO THE CHOICE

   The split screen IS the information -- PRODUCER red, ARTIST blue, one
   press. The taglines under the titles restated what the sides already say,
   and the HUD cluster (friends button, dev button, layers chip) floated over
   the split like stickers on a poster. All of it stands down on this screen
   only; every other screen keeps its furniture.

   The chips are hidden with display:none rather than visibility so their
   fixed positions cannot still eat clicks over the panels' corners.
   ========================================================================== */
#screen-mode-select .mode-sub { display: none; }
body:has(#screen-mode-select.active) #social-toggle,
body:has(#screen-mode-select.active) #dev-open,
body:has(#screen-mode-select.active) #dev-layers,
body:has(#screen-mode-select.active) #party-rail { display: none; }

/* The MODE 01 index line above the lobby title goes -- the title already
   says which mode this is; a serial number for it is dressing that reads as
   clutter. Hidden rather than deleted: mpApplyGamemodeHead() still writes
   the number and katakana, and it comes back by removing one line. */
#mp-head-idx { display: none; }

/* ==========================================================================
   SETTINGS AT A READABLE SIZE

   The panel was 920 design px -- 460 real on screen at the document's half
   zoom -- with 16px design labels rendering at 8 real px. Everything scales
   up: the column half again as wide, type sizes roughly half again, controls
   grown to match. Gaps grow less than the type (the house rule: compress
   gaps, not letters), and the whole column still centres and scrolls inside
   itself, so nothing falls off a short window.
   ========================================================================== */
#set-head, #set-body { width: min(1440px, calc(94 * var(--dvw, 1vw))); }
#set-title { font-size: clamp(44px, calc(4.2 * var(--dvw, 1vw)), 66px); }
#set-title .set-kata { font-size: 18px; }
.set-tab { font-size: 20px; letter-spacing: 5px; padding: 15px 10px 13px; }
.set-sec { font-size: 17px; letter-spacing: 6px; padding: 9px 18px; margin: 22px 0 10px; }
.set-row { padding: 12px 18px; gap: 26px; }
.set-label { font-size: 22px; letter-spacing: 2px; }
.set-val { font-size: 21px; min-width: 64px; }
.set-slider input[type='range'] { height: 26px; }
.set-toggle span { font-size: 17px; padding: 8px 18px; }

/* The title rides a few px lower in its plate. Hiding the MODE 01 index
   line above it took away what used to hold it down, and the headline
   drifted up against the plate's top edge. Padding, not margin -- the plate
   is the thing that grew hollow. */
#mp-head-plate { padding-top: 10px; }

/* ==========================================================================
   SERVER BROWSER: THE GAMEMODE COLUMN

   Between SERVER NAME and HOST, sortable like its neighbours. The two
   templates must stay identical -- the headers stop lining up with the data
   the moment they drift -- so both are restated here in full: idx, name,
   MODE, host, players, genre, action. Name gives up width to pay for it.
   ========================================================================== */
#term-columns,
.term-row {
  grid-template-columns: 56px minmax(0, 1.9fr) minmax(0, 1.25fr) minmax(0, 1.25fr) 130px 160px 130px;
}
.term-row-mode {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: #9fdb95;
  letter-spacing: 1px;
}

/* The profile flyout menu: just the destinations. The subtitle under each
   entry listed what the page contains, which nobody reads twice -- gone,
   and the names grow into the room it frees. */
.hpb-mi-s { display: none; }
.hpb-mi-t { font-size: 24px; letter-spacing: 3px; }

/* The katakana beside the PROFILE headline runs small, the way the section
   heads on the same page carry theirs -- a tag along the baseline, not a
   second title. */
#profile-title .jp-sub { font-size: 14px; letter-spacing: 4px; opacity: 0.55; }

/* ==========================================================================
   GANG-OS EXIT

   The whole GANGS screen -- handset in hand AND fullscreen -- carries the
   same corner LEAVE every other full screen does, top-left like all of
   them. It replaced both the power circle (a 22px target on a 2560px
   screen) and the [ BACK ] button under the handset. It sits outside
   the section (see index.html for why: the true viewport corner, not
   the animated column's), so the screen's .active class can't show it
   -- these gates do. The power ritual owns the whole frame: no chrome
   over the boot log or the dolly.
   ========================================================================== */
#gang-leave { display: none; }
body:has(#screen-clans.active) #gang-leave { display: block; }
body:has(#gang-phone-wrap.booting) #gang-leave { display: none; }
#screen-clans.phone-full #phone-full-off { display: none; }

/* The GENRE column is gone -- the grid drops to six tracks, and the two
   templates stay identical or the headers drift off the data. Mode and
   host take the freed width. */
#term-columns,
.term-row {
  grid-template-columns: 56px minmax(0, 1.9fr) minmax(0, 1.35fr) minmax(0, 1.35fr) 130px 130px;
}

/* ==========================================================================
   BEAT SHOWCASE TILE: A WAVEFORM, NOT AN ANALYSER

   Same six bars, different physics: they centre on a zero-line and mirror
   above and below it, the way audio is drawn, instead of standing on a
   floor. The floor element becomes that zero-line, and the hover pulse
   scales from the centre so the wave breathes symmetrically.
   ========================================================================== */
.mp-tile[data-gamemode="beat_wars"] .mp-art {
  align-items: center;
  padding-bottom: 0;
}
.mp-tile[data-gamemode="beat_wars"] .mp-art i {
  transform-origin: 50% 50%;
}
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(1) { height: 26%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(2) { height: 54%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(3) { height: 78%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(4) { height: 40%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(5) { height: 62%; }
.mp-tile[data-gamemode="beat_wars"] .mp-art i:nth-child(6) { height: 30%; }
/* the scoring floor becomes the zero-line, through the middle */
.mp-tile[data-gamemode="beat_wars"] .mp-art::after {
  top: 50%;
  bottom: auto;
  transform: translateY(-50%);
  left: 8%;
  right: 8%;
}
/* bars read as level pairs mirrored around it */
.mp-tile[data-gamemode="beat_wars"] .mp-art i {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--mp-accent) 55%, white) 0%,
    var(--mp-accent) 50%,
    color-mix(in srgb, var(--mp-accent) 55%, white) 100%);
}

/* ==========================================================================
   THE WAGER PANEL: CYBERPUNK CASINO

   Third pass on the shape, and the rounded card is gone -- border-radius
   chrome read as a generic web modal, not a surface from this game. The
   panel is now a cut-metal faceplate in the house language: double-chamfer
   corners, a recessed marquee dock the sign sits down into, vent notches
   on the flanks, wing cuts and an engraved chin tab at the bottom, bolts
   and edge-light rails on the plate.

   Construction: clip-path erases box-shadow and borders never follow a
   polygon, so #wg-frame (a decorative div, see index.html) carries TWO
   painted layers -- ::before is the edge colour, ::after the plate fill
   inset 2px -- both clipped to the same --wg-shape, with drop-shadow on
   the frame itself for the glow (paint+clip low, filter on the parent:
   the clipped-glow rule from CLAUDE.md).

   The sign finally wears the house display font. Courier at 8px tracking
   was the only title in the game not set in 'SD CyberPunk City', and it
   is most of why the old panel read as somebody else's product. The
   letter-flicker keyframes survive from the last pass -- neon cuts, it
   never fades.
   ========================================================================== */
#wg-panel {
  position: relative;
  overflow: visible;
  margin-top: 66px; /* the sign rides higher now; keep it off the screen edge */
  padding-top: clamp(42px, 4 * var(--dvh, 1vh), 64px);
  background: transparent;
  border: none;
  border-radius: 0;
  box-shadow: none;
}
#wg-body {
  position: relative; z-index: 1;
  overflow-y: auto; min-height: 0;
  scrollbar-width: thin;
  scrollbar-color: var(--wg-line) transparent;
}
#wg-body::-webkit-scrollbar { width: 8px; }
#wg-body::-webkit-scrollbar-thumb { background: var(--wg-line); border-radius: 4px; }
#wg-locked { position: relative; z-index: 1; }
#wg-head { display: contents; }
#wg-head { border-bottom: none; }

/* ---- the chassis ---- */
#wg-frame {
  --wg-shape: polygon(
    /* top-left double chamfer */
    0 46px, 10px 36px, 10px 26px, 36px 0,
    /* marquee dock: the recess the sign sits down into. Percent-capped so
       the slimmer panel keeps it clear of the shot clock on the right. */
    calc(50% - min(224px, 27%)) 0,
    calc(50% - min(206px, 25%)) 18px,
    calc(50% + min(206px, 25%)) 18px,
    calc(50% + min(224px, 27%)) 0,
    /* top-right double chamfer */
    calc(100% - 36px) 0, calc(100% - 10px) 26px, calc(100% - 10px) 36px, 100% 46px,
    /* right vent notch */
    100% calc(50% - 44px), calc(100% - 8px) calc(50% - 34px),
    calc(100% - 8px) calc(50% + 34px), 100% calc(50% + 44px),
    /* bottom-right wing */
    100% calc(100% - 88px), calc(100% - 26px) calc(100% - 62px),
    calc(100% - 26px) calc(100% - 22px),
    /* chin tab */
    calc(50% + min(200px, 30%)) calc(100% - 22px),
    calc(50% + min(184px, 28%)) 100%,
    calc(50% - min(184px, 28%)) 100%,
    calc(50% - min(200px, 30%)) calc(100% - 22px),
    /* bottom-left wing */
    26px calc(100% - 22px), 26px calc(100% - 62px), 0 calc(100% - 88px),
    /* left vent notch */
    0 calc(50% + 44px), 8px calc(50% + 34px), 8px calc(50% - 34px), 0 calc(50% - 44px)
  );
  position: absolute;
  inset: 0 0 -22px;
  z-index: 0;
  pointer-events: none;
  filter: drop-shadow(0 0 26px rgba(192, 77, 255, 0.16)) drop-shadow(0 26px 50px rgba(0, 0, 0, 0.65));
}
/* edge layer */
#wg-frame::before {
  content: '';
  position: absolute; inset: 0; z-index: 0;
  clip-path: var(--wg-shape);
  /* The mids used to dip to 0.5 alpha, and over the dark plate that read
     as the edge DISAPPEARING through the middle of the long sides -- the
     seam is the panel's outline, so it has to hold its line the whole way
     down. Still darker than the corners (the shading is the point), just
     never below legible. */
  background: linear-gradient(180deg,
    rgba(222, 140, 255, 0.9),
    rgba(168, 80, 235, 0.78) 30%,
    rgba(140, 60, 210, 0.72) 72%,
    rgba(206, 120, 255, 0.85));
}
/* plate layer, inset so the edge reads as a 2px seam that follows the cut */
#wg-frame::after {
  content: '';
  position: absolute; inset: 2px; z-index: 1;
  clip-path: var(--wg-shape);
  background:
    radial-gradient(120% 90% at 50% 0%, rgba(192, 77, 255, 0.09), transparent 55%),
    repeating-linear-gradient(-35deg, rgba(192, 77, 255, 0.03) 0 3px, transparent 3px 9px),
    linear-gradient(180deg, #180a28 0%, #0d0517 55%, #120720 100%);
}
.wgfr-bolt {
  position: absolute; z-index: 2;
  width: 10px; height: 10px;
  transform: rotate(45deg);
  border: 1px solid rgba(192, 77, 255, 0.5);
  background: #1a0c2a;
  box-shadow: inset 0 0 0 2px #0d0517, inset 0 0 6px rgba(192, 77, 255, 0.6);
}
.wgfr-b1 { left: 18px; top: 54px; }
.wgfr-b2 { right: 18px; top: 54px; }
.wgfr-b3 { left: 32px; bottom: 34px; }
.wgfr-b4 { right: 32px; bottom: 34px; }
.wgfr-rail {
  position: absolute; z-index: 2;
  top: 130px; bottom: 130px; width: 3px;
  background: linear-gradient(180deg, transparent, rgba(192, 77, 255, 0.55) 14%, rgba(192, 77, 255, 0.55) 86%, transparent);
  opacity: 0.45;
}
.wgfr-rail-l { left: 15px; }
.wgfr-rail-r { right: 15px; }
/* the chin tab's engraving, on the tab the frame extends below the panel */
#wg-panel::after {
  content: 'STAKE UP // 賭けろ';
  position: absolute;
  left: 50%; bottom: -19px;
  transform: translateX(-50%);
  z-index: 2;
  font-family: 'Courier New', monospace;
  font-size: 13px; letter-spacing: 6px; line-height: 1;
  color: rgba(210, 160, 255, 0.55);
  white-space: nowrap;
  pointer-events: none;
}

/* ---- the marquee ---- */
#wg-title {
  position: absolute;
  top: 0; left: 50%;
  /* -76%, was -56%: at full pump the readout grew into the sign's chin.
     The sign rides higher and the panel gains top padding below -- both
     measured against the pumped "20,000" rect until daylight showed. The
     dock recess still catches the sign's lower quarter. */
  transform: translate(-50%, -76%);
  margin: 0;
  /* The LOCKED stamp's centring recipe, scaled to this font size: line-
     height 1 collapses the display font's oversized line box, text-indent
     hands back the letter-spacing trailing the last glyph, and the
     paddings split around the optical middle (the glyphs ride high in
     their em box, so top takes more). */
  /* Symmetric horizontals; the vertical split narrows to 4px -- the
     display font still rides slightly high in its em box, but without the
     bulb rows crowding it the near-even split is what reads centred. */
  padding: 21px 76px 17px;
  line-height: 1;
  text-indent: 6px;
  text-align: center;
  z-index: 2;
  white-space: nowrap;
  background: transparent;
  border: none;
  border-radius: 0;
  box-shadow: none;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(44px, calc(3.4 * var(--dvw, 1vw)), 64px);
  font-weight: normal;
  letter-spacing: 6px;
  color: #f3e2ff;
  text-shadow:
    0 0 5px rgba(255, 255, 255, 0.85),
    0 0 16px var(--wg-ink),
    0 0 38px rgba(192, 77, 255, 0.85),
    0 0 80px rgba(192, 77, 255, 0.45);
  filter: drop-shadow(0 14px 26px rgba(0, 0, 0, 0.55));
  animation: wgNeonLetters 5.4s steps(1) infinite;
}
#wg-title em { display: none; }
/* the housing: edge + face as painted octagon layers (same trick as the
   chassis), with two rows of marquee bulbs running inside the face */
#wg-title::before {
  content: '';
  position: absolute; inset: 0; z-index: -2;
  clip-path: polygon(20px 0, calc(100% - 20px) 0, 100% 20px, 100% calc(100% - 20px), calc(100% - 20px) 100%, 20px 100%, 0 calc(100% - 20px), 0 20px);
  background: linear-gradient(180deg,
    rgba(226, 150, 255, 0.95),
    rgba(150, 60, 220, 0.6) 45%,
    rgba(100, 32, 165, 0.55) 80%,
    rgba(214, 130, 255, 0.9));
}
#wg-title::after {
  content: '';
  position: absolute; inset: 3px; z-index: -1;
  clip-path: polygon(18px 0, calc(100% - 18px) 0, 100% 18px, 100% calc(100% - 18px), calc(100% - 18px) 100%, 18px 100%, 0 calc(100% - 18px), 0 18px);
  /* Bulb rows retired (owner call): the face is clean plate now, and the
     word sits alone in it. */
  background: linear-gradient(180deg, #1e0b31, #0f0520);
}

/* ---- the countdown, engraved ----
   The bolted gauge is gone (owner call: "it doesn't really feel like it
   should be there" -- a floating box on a cut-metal chassis read as a
   sticky note). The seconds are now engraved straight into the plate's
   top-right: no border, no background, just digits and a dim SEC. The
   digit slot is a fixed 2ch so 15 -> 9 never resizes or shifts anything
   (the other owner note from the same screenshot). Urgency still reads:
   the existing .urgent rule turns the digits risk-red and pulses them. */
#wg-clock {
  position: absolute;
  /* Tucked up into the corner shoulder, clear of the pumped readout --
     it sat low enough to crowd the money at full size (owner screenshot). */
  top: 12px; right: 34px;
  transform: none;
  z-index: 1;
  padding: 0;
  border: none;
  background: transparent;
  clip-path: none;
}
#wg-clock-n {
  font-size: 44px;
  min-width: 2ch;
  text-align: center;
  text-shadow: 0 0 14px rgba(192, 77, 255, 0.45);
}

/* ---- the pot leaves the table (owner call: wagers are ANONYMOUS) ----
   A LIVE pot total sitting next to the locked tally was the one leak the
   anonymity design had left: in a small room, pot minus your own stake IS
   somebody's bet. Hidden rather than deleted -- wagerPaintRoster still
   writes the numbers (same pattern as #cred-hud-k), so a settlement
   screen later can bring the total back by removing one line. The lock
   button's N/M tally stays: how many have decided leaks nothing. */
#wg-pot { display: none; }

/* ---- the dial dresses like a table read-out ---- */
#wg-num-k::before { content: '\25C6\00a0\00a0'; color: rgba(192, 77, 255, 0.5); }
#wg-num-k::after { content: '\00a0\00a0\25C6'; color: rgba(192, 77, 255, 0.5); }

/* ---- MONEY THAT GROWS (owner brief: bigger, better, bouncier) ----
   --pump (0..1, written by wagerPaintNumber) is stake over YOUR max throw,
   so everyone's number reaches full size at the top of their own ladder.
   It scales the readout up to ~1.24x and fattens the glow with it --
   transform, not font-size, so nothing reflows. Each notch also restarts
   the .bump squash (class retriggered in wagerSetStake): starts fat,
   dips under, springs home. The keyframes multiply the same pump scale,
   so the bounce rides the current size instead of resetting it. */
#wg-num {
  /* Sized for its own success: the old 150px cap was set before pump
     existed, and "20,000" at full pump overflowed the slim panel -- the
     body's scroll context then CLIPPED it to "20,00C". Worst case is
     known -- "20,000" is the widest string the dial can show -- and the
     cap is set from MEASURING it at full pump (Windows Courier bold runs
     ~0.69em/glyph, wider than the 0.6em the first guess used): 108px
     keeps it inside the 680 panel with ~7% margin. */
  font-size: clamp(60px, calc(7 * var(--dvw, 1vw)), 108px);
  transform: scale(calc(1 + var(--pump, 0) * 0.24));
  transform-origin: 50% 62%;
  text-shadow: 0 0 calc(26px + var(--pump, 0) * 32px) var(--wg-glow);
}
#wg-num.bump { animation: wgMoneyBump 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); }
/* The last of the daylight between the pumped readout and the sign's chin
   -- measured, not eyeballed: sign raise + panel padding got close, this
   clears it. */
#wg-rig { margin-top: 16px; }

/* ---- THE CLIMB CATCHES FIRE ----
   --heat now follows the THROW (see wagerPaintNumber), so the licks build
   for everyone as the cap rides up: taller than the old risk-only curve,
   and saturating/brightening with height so the fire visibly feeds. */
#wg-heat {
  filter: blur(5px) saturate(calc(1 + var(--heat, 0) * 0.7)) brightness(calc(1 + var(--heat, 0) * 0.4));
}
#wg-heat i { height: calc(var(--heat, 0) * 78%); }
/* Full throw (.maxed, toggled at pump = 1): the money is ON FIRE. Licks at
   full height flickering at double speed, the whole bed roars with a slow
   breathe, and the digits get a white-hot core -- except ALL IN, whose
   risk-red reading outranks the celebration. */
#wg-num-wrap.maxed #wg-heat {
  opacity: 1;
  animation: wgHeatRoar 1.4s ease-in-out infinite;
}
#wg-num-wrap.maxed #wg-heat i { height: 100%; animation-duration: 0.55s; }
#wg-num-wrap.maxed #wg-heat i:nth-child(2n) { animation-duration: 0.45s; }
#wg-num-wrap.maxed #wg-heat i:nth-child(3n) { animation-duration: 0.7s; }
#wg-num-wrap.maxed #wg-num:not(.allin) {
  text-shadow:
    0 0 6px rgba(255, 255, 255, 0.95),
    0 0 30px var(--wg-ink),
    0 0 70px rgba(192, 77, 255, 0.8),
    0 0 130px rgba(192, 77, 255, 0.45);
}
/* the roar rides the same centring transform the bed already carries */
@keyframes wgHeatRoar {
  0%, 100% { transform: translateX(-50%) scaleY(1); }
  50%      { transform: translateX(-50%) scaleY(1.14); }
}

/* ---- VOICE SPEAKS UP WHEN THE MIC FAILS ----
   Mic errors used to report into the retired voice bar (display:none) --
   a denied or missing mic looked like a random self-mute with no
   explanation anywhere. This chip sits just above the speaking HUD,
   bottom-left, and only exists while there is something to say. */
#voice-toast {
  position: fixed;
  left: 22px;
  bottom: calc(var(--hpb-bottom, 16px) + var(--hpb-height, 64px) + 70px);
  z-index: 961;
  padding: 8px 14px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 2px;
  color: #ffb4a0;
  background: rgba(30, 8, 6, 0.92);
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(255, 107, 74, 0.5);
  clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
  pointer-events: none;
}
#voice-toast.hidden { display: none; }
/* A chip carrying an explanation invites the click it asks for. */
#voice-toast.voice-toast-more { cursor: pointer; }
#voice-toast.voice-toast-more:hover { filter: brightness(1.25); }

/* (A pass used to darken the room here; owner revised the call -- a pass
   now gets the same chains-and-LOCKED ceremony as a stake. One decision,
   one ceremony. See wagerChains in wagerPaint.) */

/* ---- THE VOLUME PLATE SERVES EVERY LISTENING ROOM ----
   Body-level now (it lived inside #screen-listen), shown only where it
   means something: the showcase, THE CRATE (which hosts both votes) and
   the pad's beat deck -- everything that plays at bsVolume. Hidden
   everywhere else so a
   fixed plate can't float over screens with nothing to hear (word vote and
   the drop-off play nothing, so they stay off this list). The screen-name
   list in applyScreen's bsPlaceVolume call must match this one. */
#bs-vol { display: none; }
/* The :not(.cart-panning) is on all three rather than as a rule of its own:
   these screens ARRIVE on PAN DEPLOY, and their LEAVE plate is inside the
   flying pane, so a body-level plate sitting at the spot LEAVE has not
   reached yet is half a matched pair floating on its own over a moving
   screen. Written as a separate `body.cart-panning #bs-vol` override it
   would simply lose: :has(#screen-crate.active) carries a second ID,
   so the allowlist outranks any plain body-class rule. The plate appears
   when the pane lands -- the class coming off is the cue, no timer to
   keep in step. (The beat vote needs no entry of its own: it stands in
   #screen-crate now.) */
body:not(.cart-panning):has(#screen-listen.active) #bs-vol,
body:not(.cart-panning):has(#screen-crate.active) #bs-vol,
body:not(.cart-panning):has(#screen-production.active) #bs-vol { display: flex; }
/* The tie blackout takes the volume plate down with the room: the plate is
   body-level at z 690, so the scrim (z 5, inside the screen's stack) can
   never darken it -- it floated full-bright over DEAD HEAT. Dimmed, not
   hidden: the whole screen going down together is the scrim's stated job.
   pointer-events off so a ghost of a control can't still be pressed. */
body.cr-tie-live #bs-vol {
  opacity: 0.1;
  pointer-events: none;
}

/* ---- THE DOOR ----
   LEAVE, top-left. The HOUSE corner plate (.sw-corner-btn-danger in
   index.html), not wager-purple metal -- the exit control looks identical
   everywhere it appears (owner call). Its z 810 already clears the chains
   (z 30): locked or not, you can always walk -- the confirm says what
   walking costs. */

/* ---- THE MIC STAYS IN THE ROOM ----
   The speaking HUD (bottom-left, z 690) vanished under the wager overlay
   (z 940) -- you could not see who was talking while betting, or while
   chained. Voice does not stop for a wager, so neither should its HUD:
   lifted above the overlay only while the wager is actually up, same
   body:has() gate pattern as the mode-select chrome. The rows keep their
   own pointer-events, so muting someone mid-bet works too. */
body:has(#screen-wager:not(.hidden)) #voice-speakers { z-index: 960; }

/* No scroll context on the body any more. The pump's 112% bounce overshoot
   transiently overflowed it, and overflow-y:auto answered with a thin
   purple HORIZONTAL scrollbar above the chin -- the "glow border" strip
   the owner flagged (and its clipping shaved the readout's glow square at
   the edges). The slimmed panel always fits its max-height at the design
   zoom, so the scrolling was vestigial: visible ends the scrollbar, the
   clip, and the layout twitch in one move. */
#wg-body { overflow: visible; }
@keyframes wgMoneyBump {
  0%   { transform: scale(calc((1 + var(--pump, 0) * 0.24) * 1.12)); }
  55%  { transform: scale(calc((1 + var(--pump, 0) * 0.24) * 0.98)); }
  100% { transform: scale(calc(1 + var(--pump, 0) * 0.24)); }
}

/* ---- presets as house chips: octagon cut, ticked edges ---- */
.wg-preset {
  position: relative;
  min-width: 106px;
  padding: 15px 20px 13px;
  clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px);
  background:
    linear-gradient(180deg, rgba(192, 77, 255, 0.12), rgba(192, 77, 255, 0) 60%),
    linear-gradient(180deg, #170a26, #0e0518);
}
.wg-preset::before, .wg-preset::after {
  content: '';
  position: absolute; left: 8px; right: 8px; height: 3px;
  background: repeating-linear-gradient(90deg, rgba(192, 77, 255, 0.45) 0 9px, transparent 9px 17px);
  pointer-events: none;
}
.wg-preset::before { top: 4px; }
.wg-preset::after { bottom: 4px; }
.wg-preset.on { background: var(--wg-ink); }
.wg-preset.on::before, .wg-preset.on::after { background: repeating-linear-gradient(90deg, rgba(18, 6, 28, 0.55) 0 9px, transparent 9px 17px); }
/* ALL IN is the one chip that can end you: hazard stripes, red ticks */
.wg-preset.risky {
  background:
    repeating-linear-gradient(-45deg, rgba(255, 107, 74, 0.1) 0 10px, transparent 10px 20px),
    linear-gradient(180deg, #170a26, #0e0518);
}
.wg-preset.risky::before, .wg-preset.risky::after { background: repeating-linear-gradient(90deg, rgba(255, 107, 74, 0.5) 0 9px, transparent 9px 17px); }
.wg-preset.risky.on {
  background:
    repeating-linear-gradient(-45deg, rgba(21, 6, 3, 0.22) 0 10px, transparent 10px 20px),
    var(--wg-risk);
}
.wg-preset.risky.on::before, .wg-preset.risky.on::after { background: repeating-linear-gradient(90deg, rgba(21, 6, 3, 0.55) 0 9px, transparent 9px 17px); }

/* ---- the fader becomes a table rail: cut ends, notches, grip cap ----
   The ticks are not decoration: one per GAP between rungs, sized off the
   same inner width the JS maps the throw to (clientWidth - 24), so the cap
   lands ON a tick at every legal stake and the rail reads as the detent
   strip it actually is. Two gaps today -- pass, 1k, 5k -- because the table
   is a closed list rather than a range (CONFIG.WAGER_STAKES). */
.wgf-track {
  height: 54px;
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
  background-image:
    linear-gradient(90deg, rgba(192, 77, 255, 0.16) 0 2px, transparent 2px),
    linear-gradient(180deg, rgba(5, 2, 10, 0.92), rgba(20, 9, 34, 0.85));
  /* --wg-notches is written by wagerPaintFader: one per gap in THIS
     player's ladder, so somebody who cannot cover the top rung gets a
     shorter rail with every legal stop still on a tick. */
  background-size: calc((100% - 24px) / var(--wg-notches, 2)) 100%, 100% 100%;
  background-position: 12px 0, 0 0;
  background-repeat: repeat-x, no-repeat;
}
/* the focus ring was a box-shadow, which the clip above now erases */
#wg-fader:focus-visible .wgf-track { border-color: var(--wg-hot); filter: drop-shadow(0 0 10px var(--wg-glow)); }
.wgf-cap {
  width: 30px; height: 40px;
  margin-left: -15px;
  background:
    repeating-linear-gradient(90deg, transparent 0 4px, rgba(238, 214, 255, 0.32) 4px 5px, transparent 5px 8px),
    linear-gradient(180deg, #43305e, #180b28);
}

/* ---- lock in grows into the house slab, top sheen kept on hover ---- */
#wg-lock {
  min-width: 380px;
  padding: 22px 64px 30px;
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 20%),
    var(--wg-plate);
}
#wg-lock:hover:not(:disabled) {
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 20%),
    rgba(192, 77, 255, 0.12);
}

/* ---- SLIMMED AROUND THE DECISION ----
   The HOUSE RULES card is gone from the markup (owner call: the rules move
   to a FAQ), which leaves one decision and four controls -- so the panel
   narrows and the vertical rhythm tightens to match. Clean over busy: the
   chassis keeps its cut, the interior stops competing with it. */
#wg-panel { width: min(680px, 92 * var(--dvw, 1vw)); }
#wg-body { gap: clamp(12px, 1.3 * var(--dvh, 1vh), 20px); }
#wg-pot { padding: 22px 22px 20px; }
#wg-pot-n { font-size: 58px; }
#wg-lock { min-width: 340px; padding: 20px 56px 28px; }

/* Notched throw, not a glide (owner call: "like I'm putting it in
   notches"). The stake already quantises to WAGER_STEP, so the cap only
   ever gets discrete targets -- a fast spring with a touch of overshoot
   makes each one land like a detent clicking home. The fill follows
   straight and slightly quicker so it never overshoots past the cap. */
.wgf-fill { transition: width 0.06s ease-out; }
.wgf-cap { transition: left 0.09s cubic-bezier(0.34, 1.56, 0.64, 1); }
/* ---- ONCE YOU LOCK IT, YOU LOCK IT ----
   The lock ceremony: two chains slam from the top corners into an X, the
   room blurs behind them, and a stamp that says only LOCKED lands in the
   middle. Each chain is anchored AT its corner (left/right edge, origin on
   the corner end) so rotate pivots from the corner and scaleX extends out
   of it; --wgx-rot/--wgx-len come from wagerChains() so the X meets the
   real corners at any aspect ratio. The links are painted gradients: dark
   seams split the bar into links, an oval slot punched per link, metal
   sheen down the height. Blur rides the GLASS perf switch like every other
   backdrop-filter -- low rigs get a plain darkening instead. */
/* The dismissal: the round's opening animation starts FROM this overlay --
   locked chains, stamp, the countdown at 0 -- and the overlay dissolves
   over its first beats instead of cutting to the lobby for a frame. A
   short delay lets the deploy begin underneath before the fade starts. */
#screen-wager.wg-out {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s ease 0.15s;
}

#wg-chained {
  position: fixed;
  inset: 0;
  z-index: 30;
  pointer-events: none;
  background: rgba(8, 4, 14, 0.5);
  backdrop-filter: blur(9px);
  animation: wgxIn 0.25s ease-out;
}
#wg-chained.hidden { display: none; }
body.sw-no-glass #wg-chained { backdrop-filter: none; background: rgba(8, 4, 14, 0.78); }
@keyframes wgxIn { from { opacity: 0; } to { opacity: 1; } }

/* Cuban links (owner call: the gradient bars read as film strips, not
   chains). An inline SVG tile of interlocked capsule links -- each ring
   drawn twice, dark casing then metallic gradient, every link lapping the
   one before it so the run reads as a braid. The holes are transparent,
   so the drop-shadow glow hugs the actual link silhouettes and the blur
   shows through the gaps like it should. */
.wgx-chain {
  position: absolute;
  top: -32px;
  width: var(--wgx-len, 200vmax);
  height: 64px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='44' height='64' viewBox='0 0 44 64'><defs><linearGradient id='m' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='%23e2b7ff'/><stop offset='0.35' stop-color='%238b4fc8'/><stop offset='0.65' stop-color='%234a2178'/><stop offset='1' stop-color='%23b57ae8'/></linearGradient><clipPath id='h'><rect x='25' y='21' width='38' height='22' rx='11'/></clipPath></defs><g fill='none'><rect x='-26' y='14' width='52' height='36' rx='18' stroke='%23120722' stroke-width='14'/><rect x='-26' y='14' width='52' height='36' rx='18' stroke='url(%23m)' stroke-width='9'/><rect x='18' y='14' width='52' height='36' rx='18' stroke='%23120722' stroke-width='14'/><rect x='18' y='14' width='52' height='36' rx='18' stroke='url(%23m)' stroke-width='9'/><g clip-path='url(%23h)'><rect x='-26' y='14' width='52' height='36' rx='18' stroke='%23120722' stroke-width='14'/><rect x='-26' y='14' width='52' height='36' rx='18' stroke='url(%23m)' stroke-width='9'/></g></g></svg>");
  background-size: 44px 64px;
  background-repeat: repeat-x;
  filter: drop-shadow(0 0 18px rgba(192, 77, 255, 0.45)) drop-shadow(0 8px 16px rgba(0, 0, 0, 0.6));
}
.wgx-a {
  left: 0;
  transform-origin: left center;
  transform: rotate(var(--wgx-rot, 30deg));
  animation: wgxSlamA 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.wgx-b {
  right: 0;
  transform-origin: right center;
  transform: rotate(calc(-1 * var(--wgx-rot, 30deg)));
  animation: wgxSlamB 0.42s cubic-bezier(0.22, 1, 0.36, 1) 0.06s both;
}
@keyframes wgxSlamA {
  0%   { transform: rotate(var(--wgx-rot, 30deg)) scaleX(0); }
  80%  { transform: rotate(var(--wgx-rot, 30deg)) scaleX(1.012); }
  100% { transform: rotate(var(--wgx-rot, 30deg)) scaleX(1); }
}
@keyframes wgxSlamB {
  0%   { transform: rotate(calc(-1 * var(--wgx-rot, 30deg))) scaleX(0); }
  80%  { transform: rotate(calc(-1 * var(--wgx-rot, 30deg))) scaleX(1.012); }
  100% { transform: rotate(calc(-1 * var(--wgx-rot, 30deg))) scaleX(1); }
}

#wgx-stamp {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  /* The word truly centred in its plate: the display font leans left (the
     12px letter-spacing trails the last glyph, so text-indent hands it
     back) and rides high (more top padding than bottom evens the optical
     middle). */
  padding: 36px 80px 22px;
  text-align: center;
  text-indent: 12px;
  /* The display font's default line box is ~3x the font size -- the plate
     ran 400 design px tall with the word floating high inside it. line-
     height 1 makes the box the glyphs' size, so the paddings above truly
     centre the word. */
  line-height: 1;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(60px, calc(5 * var(--dvw, 1vw)), 96px);
  letter-spacing: 12px;
  color: #f3e2ff;
  white-space: nowrap;
  /* SOLID, not near-solid: at 0.97 alpha the bright chains behind still
     ghosted through the plate (owner flagged it). A stamp is a slab. */
  background: linear-gradient(180deg, #1a0c2a, #0e0618);
  border: 3px solid var(--wg-ink);
  clip-path: polygon(24px 0, calc(100% - 24px) 0, 100% 24px, 100% calc(100% - 24px), calc(100% - 24px) 100%, 24px 100%, 0 calc(100% - 24px), 0 24px);
  text-shadow:
    0 0 6px rgba(255, 255, 255, 0.9),
    0 0 24px var(--wg-ink),
    0 0 60px rgba(192, 77, 255, 0.7);
  animation: wgxStamp 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) 0.4s both;
}
@keyframes wgxStamp {
  from { transform: translate(-50%, -50%) scale(2.1); opacity: 0; }
  to   { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}

/* The countdown, screen-centred under the stamp: the panel clock is behind
   the blur once the chains land, and a locked player still deserves to
   know how long the wait is. Deliberately calm -- no urgent red, locked
   means there is nothing left to act on. */
#wgx-clock {
  position: absolute;
  left: 50%; top: calc(50% + 120px);
  transform: translateX(-50%);
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-family: 'Courier New', monospace;
  color: #f3e2ff;
  text-shadow: 0 0 18px rgba(192, 77, 255, 0.7);
  animation: wgxClockIn 0.3s ease-out 0.5s both;
}
#wgx-clock b { font-size: 54px; font-weight: bold; line-height: 1; font-variant-numeric: tabular-nums; }
#wgx-clock span { font-size: 15px; letter-spacing: 4px; color: rgba(210, 160, 255, 0.7); }
@keyframes wgxClockIn {
  from { opacity: 0; transform: translateX(-50%) translateY(10px); }
  to   { opacity: 1; transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .wgf-fill, .wgf-cap { transition: none; }
  #wg-num.bump { animation: none; }
  #wg-num-wrap.maxed #wg-heat { animation: none; }
  /* chains and stamp appear settled: their base transforms ARE the final
     frame, so killing the animations skips the ride, not the state */
  #wg-chained, .wgx-chain, #wgx-stamp, #wgx-clock { animation: none; }
  #screen-wager.wg-out { transition: none; }
}

/* The locked gate, stripped the same way: lock, count, bar. The LOCKED
   label and the what-counts explainer left the markup, so the three that
   remain close ranks and shrink to a quiet card. */
#wg-locked { gap: 16px; padding: 26px 0 34px; }
.wg-lock-mark svg { width: 54px; height: 54px; }
#wg-locked-msg { font-size: 24px; letter-spacing: 3px; }
#wg-locked-bar { width: min(400px, 74%); height: 10px; }

/* Flicker the LETTERS, not the sign: these frames touch only color and
   text-shadow, so the tube letters cut out and restrike while the housing
   never moves and never goes see-through. */
@keyframes wgNeonLetters {
  0%, 100% {
    color: #f3e2ff;
    text-shadow:
      0 0 5px rgba(255, 255, 255, 0.85),
      0 0 16px var(--wg-ink),
      0 0 38px rgba(192, 77, 255, 0.85),
      0 0 80px rgba(192, 77, 255, 0.45);
  }
  7% {
    color: rgba(243, 226, 255, 0.35);
    text-shadow: 0 0 4px rgba(192, 77, 255, 0.25);
  }
  7.6% {
    color: #ffffff;
    text-shadow:
      0 0 7px rgba(255, 255, 255, 1),
      0 0 22px var(--wg-ink),
      0 0 52px rgba(192, 77, 255, 1),
      0 0 100px rgba(192, 77, 255, 0.6);
  }
  8.2%, 42.9% {
    color: #f3e2ff;
    text-shadow:
      0 0 5px rgba(255, 255, 255, 0.85),
      0 0 16px var(--wg-ink),
      0 0 38px rgba(192, 77, 255, 0.85),
      0 0 80px rgba(192, 77, 255, 0.45);
  }
  43% {
    color: rgba(243, 226, 255, 0.55);
    text-shadow: 0 0 8px rgba(192, 77, 255, 0.4);
  }
  43.4%, 70.9% {
    color: #f3e2ff;
    text-shadow:
      0 0 5px rgba(255, 255, 255, 0.85),
      0 0 16px var(--wg-ink),
      0 0 38px rgba(192, 77, 255, 0.85),
      0 0 80px rgba(192, 77, 255, 0.45);
  }
  71% {
    color: rgba(243, 226, 255, 0.3);
    text-shadow: none;
  }
  71.5% {
    color: rgba(243, 226, 255, 0.8);
    text-shadow: 0 0 12px rgba(192, 77, 255, 0.5);
  }
  72.2% {
    color: #ffffff;
    text-shadow:
      0 0 7px rgba(255, 255, 255, 1),
      0 0 22px var(--wg-ink),
      0 0 52px rgba(192, 77, 255, 1),
      0 0 100px rgba(192, 77, 255, 0.6);
  }
  73% {
    color: #f3e2ff;
    text-shadow:
      0 0 5px rgba(255, 255, 255, 0.85),
      0 0 16px var(--wg-ink),
      0 0 38px rgba(192, 77, 255, 0.85),
      0 0 80px rgba(192, 77, 255, 0.45);
  }
}
@media (prefers-reduced-motion: reduce) {
  #wg-title { animation: none; }
}

/* ==========================================================================
   THE SHOWCASE HEADER GETS A PLATE

   The title and byline floated raw over the grid with the title's glow
   brushing the top of the visualizer. Same plate language as everything
   else: dark ground, hairline edge, house corner cut -- and breathing room
   below so the header and the visualizer stop touching.
   ========================================================================== */
#bs-head {
  padding: clamp(10px, 1.4 * var(--dvh, 1vh), 22px) clamp(18px, 2 * var(--dvw, 1vw), 44px)
           clamp(12px, 1.6 * var(--dvh, 1vh), 24px);
  background: linear-gradient(170deg, rgba(10, 24, 14, 0.82), rgba(3, 9, 5, 0.92));
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(120, 255, 160, 0.22);
  clip-path: polygon(16px 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%, 0 16px);
  /* Lifted off the visualizer: the plate ends and THEN the stage begins. */
  margin-bottom: clamp(10px, 1.6 * var(--dvh, 1vh), 26px);
}

/* ==========================================================================
   FOUR SMALL ONES, TOGETHER
   ========================================================================== */

/* ---- the lobby menu items, a tad bigger ----
   Same clamp shape so it still scales; every stop up ~12%. The arrow and
   katakana ride the em, so they grow with it. */
.mp-item { font-size: clamp(38px, calc(3.5 * var(--dvw, 1vw)), 65px); }

/* ---- the panel footer note, up with it ---- */
#mp-panel-note {
  font-size: clamp(18px, calc(1.6 * var(--dvw, 1vw)), 24px);
  letter-spacing: 5px;
}

/* ==========================================================================
   STATS, SIMPLER AND LARGER

   The plate was a ledger: five labelled rows, two katakana tags per row, a
   tagline under the title. What anyone actually checks is level, XP to the
   next one, creds, and the five tiles -- so that is what remains, bigger.

   Cut in CSS rather than from the markup: every hidden element is still
   written to by pvStatsRender/statsRender, so the paint path stays whole and
   any row invited back is one deleted line.

   PLAQUES is hidden on its own merits -- it is the dead legacy score creds
   replaced; it spends nowhere and reads as a second currency next to the
   real one. The WAGER row is gone because after unlock it is a permanent
   "UNLOCKED" -- and before unlock, the wager panel itself already shows the
   gate with a progress bar the moment it matters.
   ========================================================================== */
#st-sub { display: none; }
#st-title .st-kata { display: none; }
#st-wager-row { display: none !important; } /* JS toggles .hidden on it */

/* What stays, larger. */
#st-level-n { font-size: clamp(96px, calc(9 * var(--dvw, 1vw)), 150px); }
#st-level-k { font-size: 17px; letter-spacing: 6px; }
#st-xp-row { font-size: 19px; }
#st-xp-bar { height: 14px; }
#st-creds-row { font-size: 21px; }
#st-creds { font-size: clamp(34px, calc(2.6 * var(--dvw, 1vw)), 44px); }

/* The tiles: the numbers are the point, so they carry the size. */
.st-cell b { font-size: clamp(44px, calc(3.6 * var(--dvw, 1vw)), 62px); }
.st-cell span { font-size: 13px; letter-spacing: 4px; }

/* Room: fewer rows in the plate means the two big lines can breathe --
   the XP block and the creds line split the height instead of stacking
   under a ledger. */
#st-level-right { gap: 16px; justify-content: center; }

/* ==========================================================================
   PROFILE GETS THE STATS GROUND, IN ITS OWN AMBER

   Same two layers, same geometry: the glow pooled at the top, falling to
   black by the bottom. Not on #screen-profile itself -- that element is a
   1200px centred column, so the ground would have stopped at the column's
   edges with the page's flat #111 showing either side. The body carries it
   for this screen, spanning the full window like the stats screen's ground
   spans its (uncapped) element.
   ========================================================================== */
body:has(#screen-profile.active) {
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(255, 204, 87, 0.1) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #100b03 0%, #070402 58%, #010100 100%);
}

/* The READY UP warning is a deadline with a consequence, so it reads at
   headline size -- roughly double the resting footer, bold, tighter
   tracking so it still fits the panel on one or two lines. The pulse it
   already carries does the rest. */
#mp-panel-note.mp-note-warn {
  font-size: clamp(26px, calc(2.4 * var(--dvw, 1vw)), 38px);
  font-weight: bold;
  letter-spacing: 3px;
  line-height: 1.35;
}

/* The profile ground rides the hue cycle. The plates cycle because each one
   carries filter: hue-rotate(var(--logo-hue)); the body cannot take that
   filter without rotating every child with it, so the ground's colours are
   instead REBUILT from the variable with relative color syntax -- the same
   rotation applied to the authored amber, recomputed on every JS write to
   --logo-hue exactly like the filters are.

   Kept as a second declaration under the static amber one on purpose: a
   browser without relative color drops this and lands on that, so the worst
   case is the solid colour we had, never a missing ground. hue-rotate() the
   filter is a matrix approximation and hsl rotation is exact, so the two can
   drift a few degrees apart -- at a 10%-alpha glow that is invisible.

   The /1deg is load-bearing: in relative HSL the h channel is a NUMBER, and
   number + angle is invalid AT COMPUTED-VALUE TIME -- which does not fall
   back to the earlier rule (this one still wins the cascade) but resets the
   property to initial: no ground at all. Dividing the angle by 1deg turns
   it into the number the channel arithmetic wants. */
body:has(#screen-profile.active) {
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%,
      hsl(from #ffcc57 calc(h + (var(--logo-hue, 0deg) / 1deg)) s l / 0.1) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%,
      hsl(from #100b03 calc(h + (var(--logo-hue, 0deg) / 1deg)) s l) 0%,
      hsl(from #070402 calc(h + (var(--logo-hue, 0deg) / 1deg)) s l) 58%,
      #010100 100%);
}

/* ==========================================================================
   THE SMALL PHONE IS MONOCHROME, INCLUDING ITS ICONS

   GANG-OS in the handset is a green terminal, and the app glyphs are
   full-colour emoji -- the crown read as an RGB sticker on a mono screen.
   One filter takes the whole tile (vivid gradient and glyph together) to
   green: grayscale flattens it, sepia gives the tint something to grab,
   and the rotate lands it on the phone's phosphor. Each app's gradient
   survives as a different brightness of green, so the grid keeps its
   rhythm without keeping its colours.

   Scoped to the SMALL phone only -- fullscreen GANG-OS is a different
   surface and keeps the iOS-vivid tiles on purpose. */
#screen-clans:not(.phone-full) .pa-icon {
  filter: grayscale(1) sepia(1) hue-rotate(62deg) saturate(2.2) brightness(0.95);
}

/* ==========================================================================
   THE MAIN MENU ROOM

   The lobby menu's backdrop, on home, cycling with the logo. The children
   are the SAME classes #mp-atmos uses, so their paint is identical by
   construction -- this rule only supplies what the different host needs:
   viewport-fixed instead of screen-absolute, behind the page content, and
   one hue-rotate over the whole composition so haze, floor light, fog and
   dust all turn together instead of five layers cycling separately.
   ========================================================================== */
#home-atmos {
  position: fixed;
  inset: 0;
  /* ZERO, not -1, and the reason is CSS painting order: in-flow block
     backgrounds (the body's opaque #111) paint AFTER negative-z positioned
     descendants, so at -1 the room rendered and was then buried under the
     page ground -- present in devtools, invisible on screen. At 0 it paints
     above the body ground, and stays under the screens because they follow
     it in DOM order and stack at the same level. */
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  filter: hue-rotate(var(--logo-hue, 0deg));
}
body:not(:has(#screen-home.active)) #home-atmos { display: none; }

/* ==========================================================================
   THE MENU AS A MARQUEE

   The logo grows (62 -> 72dvw, cap 1200 -> 1420) and the column anchors to
   the TOP of the screen instead of centring -- which is what actually moves
   the logo up; nudging a centred column only re-centres it. The menu grows
   with it (30-48 -> 34-56) so the words keep their proportion to the sign
   they are standing under.

   Home only: mode-select is also a .menu-screen, and it centres its split
   layout on purpose.
   ========================================================================== */
#screen-home.active {
  justify-content: flex-start;
  /* Near-zero, not zero -- the glow bloom needs a sliver of room above the
     linework or the screen's own top edge clips it. */
  padding-top: calc(0.3 * var(--dvh, 1vh));
  gap: calc(1 * var(--dvh, 1vh));
}
/* Dialed back from 92vw/1900px -- at that size the full column (logo + all
   5 menu items + socials) ran past the bottom of shorter/narrower windows,
   with nothing visibly wrong since #screen-home clips overflow instead of
   scrolling (see body:has(#screen-home.active){overflow:hidden}). Still
   meaningfully bigger than the original 72vw/1420px. */
#logo-wrap { width: min(calc(78 * var(--dvw, 1vw)), 1600px); }
/* A little wider than the vector actually draws it -- letters read as
   slightly cramped at this size, and scaleX is the cheap way to get looser
   tracking without hand-editing every path in the SVG. The glow filter
   stretches proportionally along with it (filter is computed in the
   element's own local space, then that whole result is transformed for
   paint) -- at 12% that still reads as looser letters, not a visibly uneven halo. */
#logo-img { transform: translateZ(0) scaleX(1.12); }
#screen-home .menu-item {
  font-size: clamp(34px, calc(2.9 * var(--dvw, 1vw)), 56px);
}

/* ==========================================================================
   THE MENU ROOM, OFF THE MAIN THREAD

   Two invisible changes that are most of the menu lag:

   1. The dust animated background-position, and background-position is NOT
      compositor work -- it repaints a viewport-sized layer every frame (the
      old comment beside it claimed otherwise; it was wrong). The gradients
      move to two oversized pseudo-elements that animate transform, which IS
      compositor work: same two layers, same tile sizes, same velocities,
      same 70s loop, zero repaints. Oversized by one full travel so their
      edges never enter the frame mid-loop.

   2. will-change: filter on the room. The hue cycle writes --logo-hue a few
      times a second, and each write re-rasterized the whole five-layer
      composition through the filter. With its own layer the contents are
      cached once and the hue-rotate becomes a GPU colour matrix on that
      cache -- the write costs the same whether the room is behind it or not.

   Shared classes, so the lobby menu gets the same repaint fix for free.
   ========================================================================== */
.mp-atmos-dust {
  background-image: none;
  animation: none;
}
.mp-atmos-dust::before,
.mp-atmos-dust::after {
  content: '';
  position: absolute;
  /* One full travel of margin on every side, so the element's edge is
     never on screen at any point in the loop. */
  inset: -320px -120px;
  will-change: transform;
}
.mp-atmos-dust::before {
  background-image: radial-gradient(circle, rgba(180, 255, 160, 0.5) 0.9px, transparent 1.4px);
  background-size: 340px 300px;
  animation: mp-dust-a 70s linear infinite;
}
.mp-atmos-dust::after {
  background-image: radial-gradient(circle, rgba(134, 255, 102, 0.35) 0.7px, transparent 1.2px);
  background-size: 210px 260px;
  animation: mp-dust-b 70s linear infinite;
}
@keyframes mp-dust-a { to { transform: translate3d(90px, -300px, 0); } }
@keyframes mp-dust-b { to { transform: translate3d(-70px, -260px, 0); } }
@media (prefers-reduced-motion: reduce) {
  .mp-atmos-dust::before, .mp-atmos-dust::after { animation: none; }
}
#home-atmos { will-change: filter; }

/* ---- and the menu drops to bottom-middle ----
   margin-top:auto takes all the slack in the top-anchored column, so the
   words sit low; the bottom margin keeps them off the social row that
   lives on the floor of this screen. */
#screen-home .menu-list {
  margin-top: auto;
  margin-bottom: calc(12 * var(--dvh, 1vh));
  /* The list is airier down here than it was tucked under the logo --
     these are the only five words on the screen. */
  gap: calc(1.6 * var(--dvh, 1vh));
}

/* ==========================================================================
   THE MENU'S FILTERED SURFACES GET THEIR OWN LAYERS

   The hue cycle writes --logo-hue roughly every other frame, and every
   filtered subtree without its own compositor layer re-rasterizes on every
   write -- and this screen is covered in them: six copies of the logo image,
   the shimmer, the corner plates with their drop-shadow stacks, the socials
   row, the menu list. will-change: filter caches each one's contents once;
   a hue write is then a GPU colour matrix over the cache instead of a
   repaint. Same trade the room already made, extended to the furniture.

   The wrap gets the transform hint instead: the tilt writes its transform
   every frame from a rAF lerp, and the hint keeps that from re-rastering
   the 3D stack.

   Deliberately NOT on every hue-rotated element in the app -- each hint
   pins a texture in GPU memory, so it goes only where something actually
   rewrites filters at frame rate: this screen.
   ========================================================================== */
#logo-img,
.logo-depth,
.logo-fx-img,
#home-profile-badge,
#jukebox,
#home-socials,
#screen-home .menu-item {
  will-change: filter;
}
#logo-wrap { will-change: transform; }

/* The menu WORDS get their own layers too, and for a different reason than
   the hue cycle: their hover pop transitions filter+transform (and the
   mailbox reveal transitions opacity+transform). Without a standing hint,
   Chrome promotes each word to a temporary layer the instant the hover
   transition starts and demotes it when it ends, and every one of those
   promotions rebuilds the layer tree around it, re-rastering textures
   mid-hover. Standing layers mean hover only updates already-composited
   properties: no promotion, no rebuild, no flash. Cost: word-sized
   textures, the cheap end of the trade documented above.
   (.menu-list itself is deliberately NOT in the list above anymore -- the
   hue filter moved off it and onto the items, see .menu-list.) */
#screen-home .menu-item-title {
  will-change: filter, transform;
}
#screen-home .menu-side {
  will-change: opacity, transform;
}

/* ==========================================================================
   THE LOGO'S GLITCH RIG STOPS DOING LAYOUT AND CONSTANT REPAINT

   Measured what actually animates on the menu and checked each property
   against what the compositor can handle. Two offenders, both on the logo:

   1. logo-glitch-move animated top/left. Those are LAYOUT properties -- the
      most expensive kind to animate, since every step reflows the document,
      not just repaints a layer. Now translate3d, which is pure compositor
      work. The z stays at 2px because .logo-fx-img sets translateZ(2px) as
      its resting transform and an animation on `transform` replaces it
      whole -- drop the z and the ghosts fall behind the face in the
      preserve-3d stack.

   2. logo-shimmer-sweep animates clip-path with ease-in-out, i.e. smoothly
      and forever. clip-path is not compositable, so that was a repaint of
      the full-size logo image EVERY FRAME, permanently -- and the shimmer
      carries mix-blend-mode: screen, so each repaint re-composites the
      stack above it too. This is the one continuous per-frame cost left on
      the screen.

      The sweep itself is kept at exactly its old speed; it now runs for the
      first 40% of a 2.5x longer cycle and sits at opacity 0 for the rest.
      Chrome skips painting a fully transparent layer, so the repaint duty
      cycle drops from 100% to ~40%. It reads as a shimmer that passes every
      so often rather than one permanently crawling -- which is closer to
      what a sign actually does.
   ========================================================================== */
@keyframes logo-glitch-move {
  0%   { transform: translate3d(-20px, 0, 2px); }
  15%  { transform: translate3d(10px, 10px, 2px); }
  60%  { transform: translate3d(-10px, 5px, 2px); }
  75%  { transform: translate3d(20px, -5px, 2px); }
  100% { transform: translate3d(5px, 10px, 2px); }
}

#logo-shimmer {
  animation: logo-shimmer-sweep calc(8.5s / var(--logo-speed)) ease-in-out infinite;
}
@keyframes logo-shimmer-sweep {
  0%   { clip-path: inset(0% 0 96% 0); opacity: 0; }
  4%   { opacity: 0.85; }
  20%  { clip-path: inset(46% 0 46% 0); opacity: 0.85; }
  36%  { opacity: 0.85; }
  40%  { clip-path: inset(96% 0 0% 0); opacity: 0; }
  100% { clip-path: inset(96% 0 0% 0); opacity: 0; }
}

/* ==========================================================================
   UNDO MY OWN will-change ON THE LOGO STACK

   I added will-change: filter to every filtered surface on this screen last
   round. On the small plates that is fine. On the logo it was a mistake, and
   probably a net loss:

   logo.webp is 1862x560 -- 4MB decoded -- and it is on screen TEN times (the
   face, five depth copies, the shimmer, three glitch ghosts). will-change
   pins each one as its own compositor texture, so that hint alone asked the
   GPU to hold ~40MB and to blend ten overlapping full-size layers every
   frame, three of them through mix-blend-mode, which forces a backdrop
   read-back per layer. Layer explosion of exactly this shape is a classic
   cause of the symptom it was meant to cure.

   The hint is worth its memory only where the element is small or genuinely
   rewrites its filter at frame rate. The logo layers are neither: their
   filters now change 10x/sec at most (HUE_STEP), and each costs 4MB.

   Kept on: the corner plates, the socials row, the menu list and the room --
   all small, and all still re-filtered by the hue clock.
   ========================================================================== */
#logo-img,
.logo-depth,
.logo-fx-img {
  will-change: auto;
}

/* ==========================================================================
   MENU: THE HEAVY ART COMES OFF (option B)

   Every cut below is MENU-ONLY and independently reversible -- the point is
   to find which layer actually costs the frames, so each is its own block
   with its own "buy it back" line. Nothing here touches another screen.

   What is being removed and why it is expensive:
     depth 3-5   three more 1862x560 (4MB) full-size copies to composite
     ghosts      three more copies, each with mix-blend-mode -> backdrop
                 read-back per layer, plus the only clip-path animations left
     crt-soften  a full-viewport backdrop-filter, which re-samples the ENTIRE
                 screen whenever anything beneath it moves -- structurally
                 the most expensive single thing on the page
   ========================================================================== */

/* -- CUT 1: the far depth copies. Extrusion goes 5 layers deep -> 2, so the
      sign is shallower but still solid (the near layers are the ones that
      read; the far ones only deepen it). Buy back: delete this block. */
#screen-home .ld3,
#screen-home .ld4,
#screen-home .ld5 { display: none; }

/* -- CUT 2: the glitch ghosts. This also removes the last continuously
      animated clip-path on the screen and three mix-blend layers. The logo
      keeps its own slice-glitch (#logo-img) and the shimmer.
      Buy back: delete this block. */
#screen-home .logo-glitch { display: none; }

/* -- CUT 3: the CRT softness. A viewport-sized backdrop-filter re-samples
      everything under it every frame that anything moves -- and something
      always moves here. The tube's blur/saturate/contrast is lost on the
      menu ONLY; every other screen keeps it.
      Buy back: delete this block. */
body:has(#screen-home.active) #crt-overlay .crt-soften {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

/* ==========================================================================
   THE LOGO HOLDS A POSE INSTEAD OF MOVING

   The tilt is gone from JS (see the note there). This is the pose it used to
   rest at, now static: the depth copies sit at negative Z, so without SOME
   rotation they hide directly behind the face and the extrusion disappears.

   Static is the whole point -- a preserve-3d subtree whose transform never
   changes composites once and stays cached, where a moving one re-sorted
   and re-composited every full-size layer on every frame.

   will-change: transform comes off with it. The hint only pays for itself
   on something that actually animates; on a static element it just pins a
   texture, which is the mistake made with the logo's filters last round.
   ========================================================================== */
#logo-wrap {
  transform: rotateX(6deg) rotateY(-7deg);
  will-change: auto;
}

/* ==========================================================================
   THE LOGO SITS STRAIGHT

   The pose was only ever there to reveal the extrusion: the depth copies
   sit at negative Z, so with no rotation they hide exactly behind the face.
   Rotating the whole sign to expose them is a steep price for a shadow.

   So the extrusion stops being 3D. The copies offset down-right in 2D --
   the same direction the house hover-extrusion throws -- which reads as
   depth from straight on and needs no tilt at all.

   transform-style goes back to flat with it, and that is the real win
   hiding in this change: preserve-3d forbids flattening a subtree into one
   cached layer. Without it the browser is free to collapse the whole logo
   stack into a single texture.
   ========================================================================== */
#logo-wrap {
  transform: none;
  transform-style: flat;
}
/* Offsets, not depths. The brightness ramp on .ld1/.ld2 is untouched, so
   the copies still darken as they recede. */
#screen-home .ld1 { transform: translate(5px, 5px); }
#screen-home .ld2 { transform: translate(10px, 10px); }
/* The effect layers rode translateZ(2px) to sit above the face; in a flat
   stack their existing z-index already does that, so the Z is dead weight. */
#screen-home .logo-fx-img { transform: none; }

/* The menu block sits higher and fills its space -- it was hanging low with
   the items bunched. Opening the gaps lets the five words occupy the block
   rather than clustering at the bottom of it. */
#screen-home .menu-list {
  margin-bottom: calc(13 * var(--dvh, 1vh));
  gap: calc(3.2 * var(--dvh, 1vh));
}


/* ==========================================================================
   THE MAIN MENU STOPS GLITCHING

   Five rounds were spent reading "glitchy" as frame stutter. Taken
   literally, this screen really does glitch, by design, and always has:

     crtv-jerkup        a sub-pixel tremble of the whole screen, every 2.4s
     crtv-panel-glitch  a 1.2deg skew shear, every 30s
     logo-glitch-paths  clip-path slices the logo into torn bands
     crt-flicker        an opacity flicker over the viewport

   That is THE FILTER doing its job, and on a screen you sit and stare at it
   reads as the picture never settling. Mode-select and the store already
   opt out of it for the same reason -- this uses the identical pattern
   (ID beats the .screen rule; the :has variant catches the texture layer,
   which spans the viewport and would otherwise keep an effect the screen
   itself dropped).

   Gameplay and every other screen are untouched.

   To put it back: delete this block.
   ========================================================================== */
#screen-home,
body:has(#screen-home.active) #crt-texture,
body:has(#screen-home.active) #crt-overlay .crt-flicker {
  animation: none;
}
/* No scanlines on the main menu AT ALL -- the whole texture layer, not
   just its motion. Per-screen, same :has() pattern as everywhere else:
   the layer spans the viewport, so every other screen keeps its glass
   and the Scanlines setting still governs them. */
body:has(#screen-home.active) #crt-texture { display: none; }
/* The sign holds together instead of tearing itself into bands. */
#screen-home #logo-img { animation: none; }
/* The shimmer sweep's clip-path bands are plain percentages of the box --
   they used to pass mostly through empty margin (the old, untrimmed
   viewBox) with only a sliver of letter caught in transit. Now that the
   crop fills the box edge to edge with actual linework (see logo.svg),
   the same bands sweep directly across dense letter content every cycle
   instead, and the mix-blend-mode:screen brightening reads as a stray lit
   patch rather than a subtle pass-by. Off on home until it's retuned
   against the new crop. */
#screen-home #logo-shimmer { animation: none; opacity: 0; }

/* The furniture glitches too. THE FILTER is applied to #top-bar and
   #home-profile-badge by name, so silencing the screen alone left the two
   corner plates shearing on their own -- which reads worse than everything
   moving together, because now only part of the picture is unstable. */
body:has(#screen-home.active) #top-bar,
body:has(#screen-home.active) #home-profile-badge,
body:has(#screen-home.active) #jukebox {
  animation: none;
}

/* ==========================================================================
   THE HUE ROTATION MOVES OFF THE BLURS, ONTO THEIR PARENT

   #logo-img's filter was `drop-shadow(...) drop-shadow(...) hue-rotate(hue)`
   -- one chain, so every hue tick invalidated the WHOLE chain and both
   drop-shadow blurs of an 1862x560 image were recomputed. Ten times a
   second, for a colour change. The depth copies did the same with their
   brightness/saturate filters.

   Splitting it: the children keep their (now STATIC) blurs, and the single
   hue-rotate goes on #logo-wrap. A hue tick then re-runs one colour matrix
   over the parent's cached result instead of re-blurring a 1.8MP image
   twice. The output is identical -- hue-rotate was already last in the
   chain, so it was always rotating the shadows too; doing it one level up
   rotates exactly the same pixels.

   Safe on this element specifically: a filter creates a containing block
   and flattens 3D, and #logo-wrap has no position:fixed descendants and was
   already set to transform-style: flat when the tilt was removed.
   ========================================================================== */
#logo-wrap { filter: hue-rotate(var(--logo-hue, 0deg)); }
/* Two-layer neon glow, both the SAME base colour (57,255,20) so hue-rotate
   on #logo-wrap recolours the whole sign as one hue, not two slightly
   different greens drifting apart as it rotates:
     - a tight, near-opaque core hugging the linework -- keeps the letters
       reading as a SOLID lit tube, not a soft blur eating the edges
     - a wide, strong bloom -- always on (not just at pulse peaks), the
       soft halo a real neon tube throws into the air around it
   Pulse (live off the music, see main.js) swells both on top of that
   baseline rather than being the only source of any glow at rest. */
/* --logo-boot-tick: an otherwise-inert term, ticked every rAF frame ONLY
   during the first-boot letter trace (bootGlitchIntro in main.js) --
   0.001px is nowhere near visible on its own. It exists because nothing
   else does during that exact window: --logo-pulse only moves while
   music is playing (see jbStartPulse), and --logo-hue's own comment
   above explains it's deliberately quantized/throttled, not written every
   frame. With neither ticking, this filter had no per-frame signal to
   recompute against while its child .lb paths were mid-stroke-dashoffset-
   animation, which is what let a just-finished letter (reported on R and
   the second S -- the two LATEST-drawn, so the ones most likely to land
   between ticks) sometimes render its now-visible stroke without the
   glow catching up to it, differently on every run depending on exactly
   when each letter's draw finished relative to the last recompute. */
#logo-img {
  filter:
    drop-shadow(0 0 calc(6px + 4px * var(--logo-pulse, 0) + 0.001px * var(--logo-boot-tick, 0)) rgba(57, 255, 20, calc(0.9 + 0.1 * var(--logo-pulse, 0))))
    drop-shadow(0 0 calc(30px + 30px * var(--logo-pulse, 0) + 0.001px * var(--logo-boot-tick, 0)) rgba(57, 255, 20, calc(0.7 + 0.25 * var(--logo-pulse, 0))));
}
#screen-home .ld1 { filter: brightness(0.3) saturate(0.85); }
#screen-home .ld2 { filter: brightness(0.25) saturate(0.85); }
#logo-shimmer { filter: brightness(2.2) saturate(1.3); }

/* ==========================================================================
   THE SIGN STRIKES ON

   A real neon tube doesn't fade in -- the transformer arcs, the tube
   half-catches, coughs dark, catches again, then holds. That whole
   performance is opacity steps on #logo-wrap: step-end so every change is
   an instantaneous strike (no tween reads as electrical), opacity so the
   compositor does all of it (the glow, the hue filter and the stack
   underneath are untouched -- a dark sign is the SAME cached layer at a
   different alpha).

   Keyed to .active, so leaving the menu and coming back re-strikes the
   sign -- the class drops, the animation resets, the room powers up again.

   After ignition a second, much longer clock takes over and mostly does
   nothing: twice per ~27s the tube browns out for a frame or two. Rare on
   purpose -- at this cadence it reads as infrastructure, not a loop.

   Both animations drive the same property; CSS gives the LAST one listed
   the win while both are active. The dip is delayed past the ignite, and
   its resting keyframes hold 1, so the order works out: ignite owns the
   strike, the dip owns forever after. */
#screen-home.active #logo-wrap {
  animation:
    logo-neon-ignite 1.4s step-end 1 both,
    logo-neon-dip 27s step-end 1.4s infinite;
}
@keyframes logo-neon-ignite {
  0%   { opacity: 0; }
  8%   { opacity: 0.4;  }
  11%  { opacity: 0.05; }
  22%  { opacity: 0.65; }
  25%  { opacity: 0.1;  }
  34%  { opacity: 0.9;  }
  38%  { opacity: 0.25; }
  47%  { opacity: 1;    }
  56%  { opacity: 0.7;  }
  59%  { opacity: 1;    }
  100% { opacity: 1;    }
}
@keyframes logo-neon-dip {
  0%    { opacity: 1;    }
  37%   { opacity: 1;    }
  37.3% { opacity: 0.55; }
  37.7% { opacity: 1;    }
  38.1% { opacity: 0.75; }
  38.4% { opacity: 1;    }
  82%   { opacity: 1;    }
  82.3% { opacity: 0.6;  }
  82.6% { opacity: 1;    }
  100%  { opacity: 1;    }
}

/* ==========================================================================
   THE SIGN NEVER SITS STILL: CHROMATIC SPLIT

   The always-on loop. Two of the parked glitch ghosts come back (CUT 2
   hides all three; these two override it) as a continuous RGB-split: the
   same white linework, statically tinted to opposite ends of the wheel
   (+180 / +90 off the live hue, since the parent's hue-rotate composes),
   screen-blended, drifting a few px out of register and back. Most of the
   time it reads as the sign not quite holding convergence -- old-tube
   fringing -- and every few seconds one ghost SNAPS wide for two frames.

   Cheap on purpose, which is why this isn't just deleting CUT 2:
     - the ORIGINAL ghost system animated tint (filter: hue+blur, re-blurs
       1.8MP per step) and clip-path. Here the filters are STATIC -- each
       ghost rasterizes once and caches -- and the only animated properties
       are transform and opacity, which never leave the compositor.
     - two ghosts, not three. Prime-ish durations (6.1s / 7.3s) so the pair
       never visibly repeats a combined pose.
   The twitches are baked into the drift keyframes as near-adjacent stops
   (smooth everywhere else, a hard jump there) so each ghost stays one
   animation per property. Buy back CUT 2's savings: delete this block. */
#screen-home #logo-glitch-1,
#screen-home #logo-glitch-2 {
  display: block;
  /* NOT screen-blended here, deliberately (the second half of the hover
     black-band fix -- see "THE SIGN GETS ITS LAYERS BACK"). mix-blend-mode
     forces a backdrop read-back inside the sign's render surface on every
     composited frame, and a hover transition's commits can catch that read
     mid-draw: the stripe then composites WITHOUT the face's glow under it
     (the second reported band -- strokes alive, bloom cut). Over this
     near-black room, plain alpha compositing of bright strokes at 0.24-0.28
     opacity is visually the same thing 'screen' was producing; the only
     place they differ is where a ghost crosses the white-hot core, which is
     exactly the fringing the effect wants anyway. */
  mix-blend-mode: normal;
}
#screen-home #logo-glitch-1 {
  filter: hue-rotate(180deg) brightness(1.9);
  animation:
    logo-chroma-drift-a 11.3s step-end infinite,
    logo-chroma-op-a 11.3s step-end infinite;
}
#screen-home #logo-glitch-2 {
  filter: hue-rotate(90deg) brightness(2.1);
  animation:
    logo-chroma-drift-b 15.7s step-end infinite,
    logo-chroma-op-b 15.7s step-end infinite;
}
/* Every keyframe carries the face's scaleX(1.12) (see #logo-img in the
   marquee section) -- the ghosts must wear the SAME stretch as the letters
   they are copies of, or they only register at dead centre and splay wider
   toward the wingtips (which reads as sloppy misalignment, not fringing).

   step-end EVERYWHERE, drift included: the ghost never travels, it is in
   one registration and then it is in another -- electrical, not floaty.
   Long holds between events; the wide snap is one brief burst per cycle. */
@keyframes logo-chroma-drift-a {
  0%    { transform: translate(2px, 0) scaleX(1.12); }
  34%   { transform: translate(3px, 1px) scaleX(1.12); }   /* small re-seat */
  36%   { transform: translate(2px, 0) scaleX(1.12); }
  68%   { transform: translate(9px, -2px) scaleX(1.12); }  /* snap wide */
  68.8% { transform: translate(4px, 1px) scaleX(1.12); }
  69.4% { transform: translate(2px, 0) scaleX(1.12); }
  100%  { transform: translate(2px, 0) scaleX(1.12); }
}
@keyframes logo-chroma-op-a {
  0%    { opacity: 0.28; }
  68%   { opacity: 0.55; }  /* the snap flashes brighter */
  69.4% { opacity: 0.28; }
  100%  { opacity: 0.28; }
}
@keyframes logo-chroma-drift-b {
  0%    { transform: translate(-2px, 0) scaleX(1.12); }
  47%   { transform: translate(-3px, -1px) scaleX(1.12); } /* small re-seat */
  49%   { transform: translate(-2px, 0) scaleX(1.12); }
  86%   { transform: translate(-8px, 2px) scaleX(1.12); }  /* counter-snap */
  86.6% { transform: translate(-4px, -1px) scaleX(1.12); }
  87.1% { transform: translate(-2px, 0) scaleX(1.12); }
  100%  { transform: translate(-2px, 0) scaleX(1.12); }
}
@keyframes logo-chroma-op-b {
  0%    { opacity: 0.24; }
  86%   { opacity: 0.5; }
  87.1% { opacity: 0.24; }
  100%  { opacity: 0.24; }
}

/* ==========================================================================
   THE SIGN GETS ITS LAYERS BACK (the hover black-band fix)

   Reported as a black horizontal band flashing across the sign whenever a
   menu word is hovered, on large windows. That band is checkerboarding:
   with the will-change hints removed ("UNDO MY OWN will-change" above),
   every --logo-hue step (~10/sec) and every music-driven --logo-pulse
   write re-rasterizes the full-size face through BOTH drop-shadow blurs on
   the main raster path. A hover starts the menu word's filter/transform
   transition, which commits a new frame every 16ms for its duration; when
   a commit lands while the sign's tiles are still re-blurring, Chrome
   presents anyway and the unready tile row draws as the page background
   (#111) -- a sharp-edged, sign-wide black stripe at a tile boundary.
   Invisible to CDP/DevTools screenshots (capture waits for raster), which
   is why it only ever shows on real glass.

   The UNDO block's math was right for the TEN-copy era (10 x 4MB pinned
   textures). Today the stack is the face plus two chroma ghosts, and the
   ghosts already own compositor layers via their standing animations. So
   pinning JUST the wrap (hue matrix) and the face (pulse drop-shadows)
   costs two textures and turns every hue/pulse write into GPU filter work
   over cached content -- nothing re-rasters, nothing checkerboards.
   Scoped to #screen-home and double-id'd so it outranks both undo blocks. */
#screen-home #logo-wrap,
#screen-home #logo-img {
  will-change: filter;
}
/* ...but CHROMIUM ONLY. In Gecko this exact hint is a bug factory, proven
   by A/B screenshots of the real browser: Firefox clips a will-change:
   filter layer's output at the layer boundary, so the sign's drop-shadow
   bloom (which radiates far past the element box) gets CUT at the wrap's
   bottom edge -- a permanent, razor-sharp, sign-wide horizontal seam
   sitting right among the menu words, i.e. the user-reported "black band"
   round three. Firefox never had the Chrome checkerboard problem this
   block fixes (WebRender runs the filter chain on the GPU per-frame
   regardless), so it gets none of the hint and none of the clip.
   @supports (-moz-appearance: none) is true only in Gecko.

   Not just the sign: the SAME clip hits every filter-hinted glow on this
   screen -- each .menu-item hugs its word while the word's 38px bloom
   radiates past the button box, so in Firefox every menu word's halo got
   its own hard-edged cut ("now it's happening with all of them in
   different areas"). The furniture plates too. So Gecko drops the filter
   hint EVERYWHERE on this screen; the titles keep their transform hint
   (transform layers don't clip filter output). */
@supports (-moz-appearance: none) {
  #screen-home #logo-wrap,
  #screen-home #logo-img,
  #home-profile-badge,
  #jukebox,
  #home-socials,
  #screen-home .menu-item {
    will-change: auto;
  }
  /* The words too, and MORE than the hint: any transient layerization
     clips the same way, and the transform spring layerizes the title for
     its duration plus Gecko's ~1s active-layer grace -- which is the
     user-reported "box outline appears on hover, disappears a second
     later": the layer's rectangular boundary slicing the word's glow,
     then vanishing at demotion. So in Gecko the words go FULLY electrical:
     no hint, no transition -- the pop snaps instead of springing, nothing
     layerizes, nothing clips. */
  #screen-home .menu-item-title {
    will-change: auto;
    transition: none;
  }
}

/* ==========================================================================
   THE EXTRUSION BECOMES TWO SHADOWS, NOT TWO MORE COPIES OF THE IMAGE

   No export needed after all. .ld1/.ld2 are the same 1862x560 file again,
   offset a few px and darkened -- which is precisely what a drop-shadow
   with ZERO blur radius is: a hard, offset, alpha-shaped copy. So the depth
   comes from two shadows on the face instead of two more 4MB decodes and
   two more composited layers.

   Alpha-shaped is the part that matters, and the reason box-shadow could
   not do this job: the logo is transparent everywhere but the letters, and
   these have to follow the letters, not the image's rectangle.

   Cheap in a way the old layers were not: blur radius 0 means no blur pass,
   and with hue-rotate hoisted to #logo-wrap this filter is now STATIC, so
   its result caches instead of being recomputed on every hue tick.

   Decoded copies of logo.webp on the menu: 4 -> 2. Composited layers: 4 -> 2.
   ========================================================================== */
#screen-home .logo-depth { display: none; }
/* Extrusion (the offset dark copies below/behind the linework) is GONE --
   it read fine on the old solid-filled logo, where the offset copies just
   deepened the shape's own shadow, but on the thin stroke artwork it just
   showed up as a second dark line trailing the lit one. A neon tube is
   flat against the wall; it doesn't need a fake-3D extrusion under it. */
#logo-img {
  filter:
    /* tight core + strong outer bloom, one neon-tube colour -- see the
       explanation up by #logo-wrap's hue-rotate, this is the same pair */
    drop-shadow(0 0 calc(6px + 4px * var(--logo-pulse, 0)) rgba(57, 255, 20, calc(0.9 + 0.1 * var(--logo-pulse, 0))))
    drop-shadow(0 0 calc(30px + 30px * var(--logo-pulse, 0)) rgba(57, 255, 20, calc(0.7 + 0.25 * var(--logo-pulse, 0))));
}

/* ---- live headcount on each mode tile ----
   Bottom-right of the artwork band, where it reads as a status stamp on the
   mode rather than part of its name. Green when someone is in there, grey
   when nobody is -- EMPTY is honest and still useful, so it is stated rather
   than hidden. Blank until the server answers; see mpPaintCounts. */
.mp-tile-live {
  position: absolute;
  top: calc(clamp(96px, calc(11 * var(--dvw, 1vw)), 140px) - 22px);
  right: 10px;
  padding: 2px 7px;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  letter-spacing: 2px;
  line-height: 1.4;
  color: #0a1a0c;
  background: var(--mp-accent);
  pointer-events: none;
}
.mp-tile-live:empty { display: none; }
/* No .is-empty dress any more: a zero count renders as NO label (see
   mpPaintCounts) -- "EMPTY" was the death-spiral word, a verdict that kept
   itself true. */

/* ==========================================================================
   NO CORNER BRACKETS ON THE MODE TILES

   They were eight background layers per tile -- an L at each corner -- and
   duplicated again on the selected tile's own stack. Both are restated here
   without them, since a background is one property: it cannot be partly
   overridden, so the survivors have to be relisted rather than the brackets
   subtracted.

   Everything else is untouched and in the same order: spotlight, horizon,
   vignette, grid, ground.
   ========================================================================== */
.mp-tile-art {
  background:
    radial-gradient(38% 60% at 50% 46%, color-mix(in srgb, var(--mp-accent) 16%, transparent), transparent 70%),
    linear-gradient(180deg, transparent calc(72% - 1px), color-mix(in srgb, var(--mp-accent) 30%, transparent) calc(72% - 1px) 72%, transparent 72%),
    radial-gradient(120% 120% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.5) 100%),
    repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.05) 22px 23px),
    linear-gradient(150deg, rgba(24, 60, 34, 0.75) 0%, rgba(6, 16, 9, 0.9) 100%);
}
/* The selected tile keeps its brighter grid and ground. */
.mp-tile-on .mp-tile-art {
  background:
    radial-gradient(38% 60% at 50% 46%, color-mix(in srgb, var(--mp-accent) 16%, transparent), transparent 70%),
    linear-gradient(180deg, transparent calc(72% - 1px), color-mix(in srgb, var(--mp-accent) 30%, transparent) calc(72% - 1px) 72%, transparent 72%),
    radial-gradient(120% 120% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.5) 100%),
    repeating-linear-gradient(90deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
    repeating-linear-gradient(0deg, transparent 0 22px, rgba(120, 255, 160, 0.08) 22px 23px),
    linear-gradient(150deg, rgba(38, 90, 50, 0.8) 0%, rgba(8, 22, 12, 0.92) 100%);
}

/* ==========================================================================
   THE DEV SCREEN

   Was a fixed drawer over whatever you were on; it is a screen now, and the
   remastered #dev-panel (see THE DEV CONSOLE) is a static plate, so all this
   owns is the room: centred, amber-lit, filling the window so the CRT glass
   never runs out of page below a short tab (THE GREY BAR rule -- body
   padding is dropped here, so the screen gets the full --vph budget). */
body:has(#screen-dev.active) { max-width: none; padding: 0; }
#screen-dev.active {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  min-height: var(--vph, 100vh);
  padding: clamp(20px, 3 * var(--dvh, 1vh), 56px) clamp(16px, 2 * var(--dvw, 1vw), 40px);
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(255, 179, 71, 0.08) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #0d0a05 0%, #070502 58%, #010100 100%);
}

/* ---- the beat library ---- */
/* #dev-jb-list is the JUKEBOX tab's song list -- same chrome as the library,
   shared here rather than copied. */
#dev-beat-list,
#dev-jb-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* The library will outgrow the screen long before it outgrows usefulness,
     so it scrolls inside its own block rather than stretching the page.
     Taller than the old 240px cap: the list has a whole tab to itself now. */
  max-height: min(48vh, 560px);
  overflow-y: auto;
}
.dev-beat-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 10px;
  background: #0f0d0a;
  border: 1px solid #2c2820;
  border-radius: 2px;
}
.dev-beat-row .dev-btn { padding: 2px 9px; font-size: 12px; }
.dev-beat-genre {
  flex: 0 0 auto;
  font-size: 11px;
  letter-spacing: 2px;
  color: #ffb347;
  min-width: 92px;
}
.dev-beat-name {
  flex: 1;
  min-width: 0;
  font-size: 13px;
  color: #ddd;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#dev-beat-file, #dev-jb-file { flex: 1; min-width: 160px; color: #aaa; font-size: 12px; }
#dev-beat-msg { margin: 0; font-size: 12px; min-height: 1em; }

/* ---- bulk upload progress ---- */
#dev-beat-bar {
  height: 4px;
  background: #1c1c1f;
  border: 1px solid #2a2a2e;
  overflow: hidden;
}
#dev-beat-bar.hidden { display: none; }
#dev-beat-fill {
  display: block;
  height: 100%;
  width: 0;
  background: #8df59a;
  transition: width 0.18s ease-out;
}
#dev-beat-failed {
  list-style: none;
  margin: 0;
  padding: 6px 8px;
  border: 1px solid #4a2320;
  background: #1a0f0e;
  color: #ff8a7b;
  font-size: 11px;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
#dev-beat-failed.hidden { display: none; }

/* ---- right-click menu on a beat row ---- */
.dev-beat-row { cursor: context-menu; }
#dev-beat-ctx {
  position: fixed;
  z-index: 990;
  min-width: 190px;
  background: #171310;
  border: 1px solid rgba(255, 179, 71, 0.35);
  border-radius: 3px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
  font-family: 'Courier New', monospace;
}
.dev-beat-ctx-title {
  padding: 8px 10px;
  font-size: 11px;
  letter-spacing: 1px;
  color: #999;
  border-bottom: 1px solid #2a2a2e;
  /* a long beat name must not stretch the menu off the screen */
  max-width: 260px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dev-beat-ctx-del {
  display: block;
  width: 100%;
  padding: 9px 10px;
  background: none;
  border: none;
  text-align: left;
  font-family: inherit;
  font-size: 12px;
  letter-spacing: 2px;
  color: #ff6b4a;
  cursor: pointer;
}
.dev-beat-ctx-del:hover { background: #2a1512; color: #ff8a7b; }
/* Credit actions in the same menu: green, because they attach a name rather
   than destroy a file. */
.dev-beat-ctx-act {
  display: block;
  width: 100%;
  padding: 9px 10px;
  background: none;
  border: none;
  text-align: left;
  font-family: inherit;
  font-size: 12px;
  letter-spacing: 2px;
  color: #8df59a;
  cursor: pointer;
}
.dev-beat-ctx-act:hover { background: #12241a; color: #aef7c1; }

/* ---- the producer picker: who a beat gets credited to ---- */
/* #dev-jb-results is the JUKEBOX tab's artist picker -- the same control on
   its own ids, so the two panes can't fight over one result list. */
#dev-beat-prod-results,
#dev-jb-results {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
#dev-beat-prod-results.hidden,
#dev-jb-results.hidden { display: none; }
#dev-beat-prod-results li,
#dev-jb-results li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  background: #0f0d0a;
  border: 1px solid #2c2820;
  border-radius: 2px;
  cursor: pointer;
  font-size: 13px;
}
#dev-beat-prod-results li:hover,
#dev-jb-results li:hover { border-color: #ffb347; }
#dev-beat-prod-results .dev-r-name,
#dev-jb-results .dev-r-name { flex: 1; color: #eee; }
#dev-beat-prod-results .dev-prod-id,
#dev-jb-results .dev-prod-id {
  color: #666;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
#dev-beat-prod-chip,
#dev-jb-chip { color: #8df59a; }
#dev-beat-prod-chip.hidden,
#dev-jb-chip.hidden { display: none; }

/* The cover the vinyl will wear, as a row swatch -- same fallback chain as
   jbPaint, so the list can never disagree with the player. */
.dev-jb-swatch {
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  border: 1px solid #2c2820;
  border-radius: 2px;
  background: #0a0806 center / cover no-repeat;
}

/* The credited artist on a jukebox row. Green when linked (the ✓ says an
   account is behind it), same voice as the credit chip above. */
.dev-jb-artist {
  flex: 0 0 auto;
  max-width: 240px;
  font-size: 12px;
  letter-spacing: 1px;
  color: #8df59a;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The credit tag on a library row. */
.dev-beat-prod {
  flex: 0 0 auto;
  font-size: 11px;
  color: #8df59a;
  max-width: 160px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---- the rights attestation ----
   Sits with the control it gates, not in a policy page nobody opens. Small,
   but never so small it reads as fine print -- the whole point is that it is
   actually read before it is agreed to. */
.sw-rights {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 10px 0;
  padding: 10px 12px;
  max-width: 620px;
  background: rgba(6, 16, 8, 0.6);
  border: 1px solid rgba(57, 255, 20, 0.28);
  font-family: 'Courier New', monospace;
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0.5px;
  color: rgba(215, 240, 220, 0.85);
  cursor: pointer;
  text-align: left;
}
.sw-rights.hidden { display: none; }
.sw-rights input[type="checkbox"] {
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 1px 0 0;
  accent-color: #39ff14;
  cursor: pointer;
}
/* A disabled submit has to LOOK disabled, or the checkbox reads as broken. */
#btn-submit-track:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  filter: saturate(0.3);
}

/* ==========================================================================
   THE BEAT MAILBOX
   ========================================================================== */

/* The screen. Reads as an ask, so it is a column you scroll rather than a
   dense form: pitch, then what you get, then terms, then the drop. */
body:has(#screen-mailbox.active) { max-width: none; padding: 0; }
#screen-mailbox.active {
  display: flex;
  justify-content: center;
  min-height: calc(100 * var(--dvh, 1vh));
  padding: clamp(20px, 3 * var(--dvh, 1vh), 56px) clamp(16px, 2 * var(--dvw, 1vw), 40px);
  background:
    radial-gradient(ellipse 70% 55% at 50% -8%, rgba(255, 210, 87, 0.1) 0%, transparent 62%),
    radial-gradient(ellipse 120% 100% at 50% 0%, #100b03 0%, #070402 58%, #010100 100%);
}
#mb-panel {
  width: min(760px, 100%);
  display: flex;
  flex-direction: column;
  gap: 22px;
  padding: clamp(22px, 3 * var(--dvh, 1vh), 40px) clamp(20px, 2.4 * var(--dvw, 1vw), 40px);
  background: rgba(12, 9, 4, 0.9);
  border: 1px solid rgba(255, 210, 87, 0.3);
  clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px);
  font-family: 'Courier New', monospace;
  color: #e8dcc4;
}
#mb-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(30px, calc(3 * var(--dvw, 1vw)), 46px);
  letter-spacing: 4px;
  color: #ffd257;
  text-shadow: 0 0 16px rgba(255, 210, 87, 0.5);
}
#mb-title .jp-sub { font-size: 14px; letter-spacing: 4px; opacity: 0.6; }
#mb-pitch {
  margin: 10px 0 0;
  font-size: 15px;
  line-height: 1.6;
  color: rgba(232, 220, 196, 0.8);
  max-width: 56ch;
}
.mb-block { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid rgba(255, 210, 87, 0.16); padding-top: 16px; }
.mb-block.hidden { display: none; }
.mb-k { font-size: 11px; letter-spacing: 4px; color: rgba(255, 210, 87, 0.65); }
#mb-perks, #mb-terms {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 7px;
  font-size: 13px;
  line-height: 1.55;
  color: rgba(232, 220, 196, 0.82);
}
#mb-perks li, #mb-terms li { position: relative; padding-left: 16px; }
#mb-perks li::before, #mb-terms li::before {
  content: '';
  position: absolute;
  left: 0; top: 8px;
  width: 5px; height: 5px;
  background: #ffd257;
}
/* The withdrawal rule gets its own line -- it is the one that surprises
   people, so it is stated twice and looks different the second time. */
#mb-headline {
  margin: 4px 0 0;
  padding: 10px 12px;
  font-size: 13px;
  line-height: 1.5;
  color: #ffd257;
  background: rgba(255, 210, 87, 0.08);
  border-left: 3px solid #ffd257;
}
.mb-row { display: flex; gap: 10px; flex-wrap: wrap; }
.mb-row input {
  flex: 1;
  min-width: 140px;
  background: #14100a;
  border: 1px solid rgba(255, 210, 87, 0.3);
  color: #f3e8cf;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  padding: 10px 12px;
  outline: none;
}
.mb-row input:focus { border-color: #ffd257; }
#mb-file { color: #b8a888; font-size: 12px; }
#screen-mailbox .sw-rights {
  background: rgba(255, 210, 87, 0.06);
  border-color: rgba(255, 210, 87, 0.3);
  color: rgba(232, 220, 196, 0.88);
  max-width: none;
}
#screen-mailbox .sw-rights input[type="checkbox"] { accent-color: #ffd257; }
#mb-send { align-self: flex-start; }
#mb-send:disabled { opacity: 0.4; cursor: not-allowed; filter: saturate(0.3); }
#mb-msg { margin: 0; font-size: 13px; min-height: 1em; }

/* Your own submissions, and what happened to them. */
#mb-mine { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.mb-sub {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 9px 12px;
  background: rgba(255, 210, 87, 0.05);
  border: 1px solid rgba(255, 210, 87, 0.16);
  font-size: 13px;
}
.mb-sub-name { flex: 1; min-width: 120px; color: #f3e8cf; }
.mb-sub-genre { font-size: 11px; letter-spacing: 2px; color: rgba(255, 210, 87, 0.7); }
.mb-sub-status { font-size: 11px; letter-spacing: 2px; }
.mb-sub-note { flex-basis: 100%; font-size: 12px; color: rgba(232, 220, 196, 0.6); }
.mb-sub-pull {
  background: none;
  border: 1px solid rgba(255, 107, 74, 0.5);
  color: #ff6b4a;
  font-family: inherit;
  font-size: 11px;
  letter-spacing: 2px;
  padding: 4px 10px;
  cursor: pointer;
}
.mb-sub-pull:hover { border-color: #ff6b4a; background: rgba(255, 107, 74, 0.1); }

/* The review queue in the dev screen. */
#dev-queue { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; max-height: 300px; overflow-y: auto; }
.dev-queue-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 8px 10px;
  background: #0f0d0a;
  border: 1px solid #2c2820;
}
.dev-queue-row .dev-btn { padding: 3px 10px; font-size: 11px; }
.dev-queue-name { flex: 1; min-width: 120px; font-size: 13px; color: #ddd; }
.dev-queue-by { font-size: 11px; color: #ffb347; }
.dev-queue-meta { font-size: 11px; color: #777; }


/* ==========================================================================
   MAILBOX, BESIDE PLAY

   It IS a .menu-item, so the display face, the neon glow and the hover
   extrusion all arrive from the existing rules -- nothing about the look is
   restated here, which is what keeps it identical to PLAY if PLAY ever
   changes. This block only positions it and slides it out.

   A wrapper, not a sixth row in the list: the column was tuned deliberately
   and an extra item would reshuffle it. And the WRAPPER owns the hover, not
   PLAY -- with the hover on the word itself it would vanish the moment the
   pointer left PLAY to travel toward it.
   ========================================================================== */
.menu-slot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}
#screen-home .menu-side {
  position: absolute;
  left: 100%;
  top: 50%;
  /* Smaller than PLAY because it is secondary to it -- same face, same glow,
     same extrusion, just not competing with the thing it hangs off.

     NOT 0.6em. em resolves against the PARENT, and the parent here is the
     wrapper at its inherited 16px -- .menu-item sets its own size, so 0.6em
     rendered this at 9.6px rather than 60% of PLAY. Same clamp shape as
     .menu-item, scaled: 34/56 of its ceiling. */
  font-size: clamp(20px, calc(1.75 * var(--dvw, 1vw)), 34px);
  /* The left padding is the BRIDGE: the box starts at PLAY's edge so there is
     no dead gap for the pointer to fall through on the way over. */
  padding: 6px 0 6px 22px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translate(-14px, -50%);
  transition: opacity 0.18s ease-out, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* SCOPED TO #screen-home TOO, and that is not decoration. The resting rule
   above is `#screen-home .menu-side` -- it carries an ID. This one did not,
   so the hidden state outranked the reveal and hovering did nothing at all:
   the element was there, positioned correctly, permanently at opacity 0.
   Matching the specificity is the entire fix. */
#screen-home .menu-slot:hover .menu-side,
#screen-home .menu-slot:focus-within .menu-side {
  opacity: 1;
  pointer-events: auto;
  transform: translate(0, -50%);
}
/* Narrow windows have nowhere to put it, and a word hanging off the right
   edge is worse than reaching it another way. */
@media (max-width: 900px) {
  #screen-home .menu-side { display: none; }
}

/* ==========================================================================
   THE MAILBOX, BIGGER AND TIGHTER

   Everything on this page was set at 13-15px, which is fine-print size for a
   page whose whole job is to be read before someone agrees to it. Type goes
   up roughly 45%, and the gaps come DOWN to pay for it -- otherwise a bigger
   page is just a longer page, and the terms fall below the fold where nobody
   reads them.
   ========================================================================== */
#mb-panel {
  width: min(1000px, 100%);
  gap: 16px;
  padding: clamp(24px, 3 * var(--dvh, 1vh), 40px) clamp(26px, 2.6 * var(--dvw, 1vw), 48px);
}
#mb-title { font-size: clamp(38px, calc(3.6 * var(--dvw, 1vw)), 58px); }
#mb-title .jp-sub { font-size: 17px; }
#mb-pitch {
  font-size: 19px;
  line-height: 1.55;
  /* Wider measure than the old 56ch: at this size a narrow column turns four
     lines into seven. */
  max-width: 68ch;
  color: rgba(232, 220, 196, 0.9);
}
.mb-block { gap: 8px; padding-top: 13px; }
.mb-k { font-size: 14px; letter-spacing: 5px; }
#mb-perks, #mb-terms { font-size: 17px; line-height: 1.45; gap: 6px; }
#mb-perks li, #mb-terms li { padding-left: 20px; }
#mb-perks li::before, #mb-terms li::before { top: 10px; width: 6px; height: 6px; }
#mb-headline { font-size: 17px; padding: 12px 14px; border-left-width: 4px; }
.mb-row input { font-size: 17px; padding: 12px 14px; }
#mb-file { font-size: 15px; }
#screen-mailbox .sw-rights { font-size: 16px; padding: 13px 15px; }
#screen-mailbox .sw-rights input[type="checkbox"] { width: 20px; height: 20px; }
#mb-send { font-size: 22px; padding: 12px 34px; }
#mb-msg { font-size: 16px; }
.mb-sub { font-size: 16px; padding: 11px 14px; }
.mb-sub-genre, .mb-sub-status { font-size: 13px; }
.mb-sub-note { font-size: 14px; }
.mb-sub-pull { font-size: 13px; padding: 5px 12px; }

/* ---- beat or sample ----
   Two plates rather than a dropdown: the choice changes what the rest of the
   page means, so it should be visible at a glance rather than folded away. */
#mb-kind { display: flex; gap: 12px; flex-wrap: wrap; }
.mb-kind-btn {
  flex: 1;
  min-width: 220px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px 16px;
  text-align: left;
  background: rgba(255, 210, 87, 0.04);
  border: 1px solid rgba(255, 210, 87, 0.22);
  color: rgba(232, 220, 196, 0.7);
  font-family: 'Courier New', monospace;
  cursor: pointer;
  transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.mb-kind-btn b { font-size: 20px; letter-spacing: 3px; color: rgba(255, 210, 87, 0.75); }
.mb-kind-btn i { font-style: normal; font-size: 14px; line-height: 1.35; }
.mb-kind-btn:hover { border-color: rgba(255, 210, 87, 0.5); }
.mb-kind-btn.is-on {
  background: rgba(255, 210, 87, 0.11);
  border-color: #ffd257;
  color: #f3e8cf;
}
.mb-kind-btn.is-on b { color: #ffd257; text-shadow: 0 0 12px rgba(255, 210, 87, 0.5); }

/* The opening line carries the ask, so it leads rather than blending into
   the sentence that follows it. */
#mb-pitch b {
  color: #ffd257;
  font-weight: normal;
  letter-spacing: 1px;
}

/* ==========================================================================
   MAILBOX: BIGGER AGAIN

   Still reading as fine print at 17-19px. Up another ~30%: the paragraph and
   the terms are the two things a producer must actually read before agreeing,
   so they lead. Section labels grow with them or they stop reading as
   headings at all.

   The panel widens to match -- type this size in a 1000px column starts
   wrapping every few words, which undoes the readability the size bought.
   ========================================================================== */
#mb-panel {
  width: min(1180px, 100%);
  gap: 20px;
}
#mb-title { font-size: clamp(46px, calc(4.4 * var(--dvw, 1vw)), 72px); }
#mb-title .jp-sub { font-size: 20px; }
#mb-pitch { font-size: 25px; line-height: 1.5; max-width: 72ch; }
.mb-k { font-size: 17px; letter-spacing: 6px; }
#mb-perks, #mb-terms { font-size: 22px; line-height: 1.45; gap: 9px; }
#mb-perks li, #mb-terms li { padding-left: 26px; }
#mb-perks li::before, #mb-terms li::before { top: 13px; width: 7px; height: 7px; }
#mb-headline { font-size: 22px; padding: 15px 18px; }
.mb-kind-btn b { font-size: 27px; }
.mb-kind-btn i { font-size: 18px; }
.mb-kind-btn { padding: 18px 20px; min-width: 260px; }
.mb-row input { font-size: 21px; padding: 15px 17px; }
#mb-file { font-size: 18px; }
#screen-mailbox .sw-rights { font-size: 20px; padding: 16px 18px; }
#screen-mailbox .sw-rights input[type="checkbox"] { width: 24px; height: 24px; }
#mb-msg { font-size: 19px; }
.mb-sub { font-size: 20px; padding: 14px 16px; }
.mb-sub-genre, .mb-sub-status { font-size: 15px; }
.mb-sub-note { font-size: 17px; }
.mb-sub-pull { font-size: 15px; padding: 7px 14px; }

/* SEND IT IN was inheriting .jp-btn's decoration, which is built for a
   different panel and rendered here as small green text under a stray rule.
   Stated outright instead -- this is the button the whole page is for. */
#mb-send {
  align-self: flex-start;
  padding: 16px 44px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 30px;
  letter-spacing: 4px;
  color: #150d02;
  background: #ffd257;
  border: none;
  clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
  cursor: pointer;
  box-shadow: 0 0 18px rgba(255, 210, 87, 0.45);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.18s;
}
#mb-send::before, #mb-send::after { content: none; }
#mb-send:hover:not(:disabled) {
  transform: translate(-2px, -2px);
  box-shadow: 3px 3px 0 rgba(150, 96, 20, 0.9), 0 0 26px rgba(255, 210, 87, 0.6);
}
#mb-send:disabled {
  opacity: 0.35;
  cursor: not-allowed;
  box-shadow: none;
  transform: none;
}

/* ---- the walk-through ----
   One step visible at a time. Not a scroll-and-hope page: the terms are now
   their own step directly above the button that agrees to them, instead of
   six paragraphs up. */
.mb-step { display: none; flex-direction: column; gap: 12px; border-top: 1px solid rgba(255, 210, 87, 0.16); padding-top: 16px; }
.mb-step.is-on { display: flex; }
#mb-steps { display: flex; gap: 8px; margin-top: 14px; }
#mb-steps i {
  width: 42px;
  height: 4px;
  background: rgba(255, 210, 87, 0.18);
  transition: background 0.2s;
}
#mb-steps i.is-on { background: #ffd257; }
.mb-nav { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
.mb-next, .mb-back {
  padding: 13px 32px;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 24px;
  letter-spacing: 3px;
  cursor: pointer;
  clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
  transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mb-next { background: #ffd257; border: none; color: #150d02; }
.mb-back { background: none; border: 1px solid rgba(255, 210, 87, 0.4); color: rgba(255, 210, 87, 0.8); }
.mb-next:hover, .mb-back:hover { transform: translate(-2px, -2px); }
.mb-back:hover { border-color: #ffd257; color: #ffd257; }

/* The genre picker is a select now -- style it like the inputs beside it so
   the row does not read as two different kinds of control. */
#mb-genre {
  flex: 1;
  min-width: 140px;
  background: #14100a;
  border: 1px solid rgba(255, 210, 87, 0.3);
  color: #f3e8cf;
  font-family: 'Courier New', monospace;
  font-size: 21px;
  padding: 15px 17px;
  outline: none;
  cursor: pointer;
}
#mb-genre:focus { border-color: #ffd257; }
#mb-genre option { background: #14100a; color: #f3e8cf; }

/* ==========================================================================
   MAILBOX LAYOUT: HEADER BAND, RULE, BODY

   Per the sketch. The title gets its own band across the top with a hard rule
   under it, and everything else lives in one roomy area below -- rather than
   a left-aligned title bleeding into a stack of hairline-separated blocks.

   The internal dividers come OFF with it: one strong rule reads as structure,
   six faint ones read as clutter, and the steps already do the separating.
   ========================================================================== */
#mb-panel {
  gap: 0;
  padding: 0;
  overflow: hidden;
}
#mb-head {
  padding: clamp(18px, 2.2 * var(--dvh, 1vh), 30px) clamp(24px, 2.4 * var(--dvw, 1vw), 44px);
  text-align: center;
  border-bottom: 3px solid rgba(255, 210, 87, 0.55);
}
#mb-title {
  font-size: clamp(52px, calc(5 * var(--dvw, 1vw)), 84px);
  letter-spacing: 6px;
}
/* The pitch belongs in the body, not the band -- the band is the title. */
#mb-head #mb-pitch { display: none; }
#mb-steps { justify-content: center; margin-top: 16px; }

/* One body area, generous, holding the paragraph and whichever step is up. */
#mb-body {
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: clamp(24px, 3 * var(--dvh, 1vh), 40px) clamp(24px, 2.4 * var(--dvw, 1vw), 44px)
           clamp(28px, 3.4 * var(--dvh, 1vh), 46px);
  /* The body is the tall half, as sketched -- it never looks like a caption
     under a heading even when a step is short. */
  min-height: clamp(340px, 46 * var(--dvh, 1vh), 620px);
}
#mb-body #mb-pitch {
  display: block;
  margin: 0;
  font-size: 26px;
  line-height: 1.5;
  max-width: none;
}
/* No internal hairlines: the steps separate themselves. */
.mb-step, .mb-block { border-top: none; padding-top: 0; }
.mb-step { gap: 16px; }
#mb-mine-block { border-top: 1px solid rgba(255, 210, 87, 0.18); padding-top: 18px; }

/* Section labels were reading as fine print pressed against the rule above
   them. They are the question being asked -- they get the room to look it. */
.mb-k {
  font-size: 30px;
  letter-spacing: 7px;
  line-height: 1.3;
  margin-top: 14px;
  margin-bottom: 6px;
}
/* The first label in a step carries the gap from the rule, so the top of the
   body never sits flush against it. */
.mb-step > .mb-k:first-child { margin-top: clamp(10px, 1.6 * var(--dvh, 1vh), 24px); }

#mb-kind { gap: 22px; }
.mb-kind-btn { padding: 22px 26px; gap: 10px; }
.mb-kind-btn b { font-size: 34px; letter-spacing: 3px; }
.mb-kind-btn i { font-size: 21px; line-height: 1.45; }

/* The divider is the structure in this layout, so it wins outright rather
   than losing to whichever earlier #mb-head rule set a thinner border. */
#mb-panel > #mb-head { border-bottom-width: 3px; }

/* ==========================================================================
   REVIEW QUEUE: BIG ENOUGH TO ACTUALLY JUDGE A BEAT ON

   This section was built at library-row scale -- 11px labels in a 300px box --
   but it does a different job. The library is a list you scan; the queue is
   where you sit and listen to a stranger's track before deciding whether it
   goes into the game. It gets the room for that.
   ========================================================================== */
#dev-queue {
  gap: 14px;
  /* Was 300px, which showed two rows and hid the rest behind a scroll. */
  max-height: min(60vh, 720px);
  padding-right: 6px;
}
.dev-queue-row {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 18px 20px;
  border-radius: 6px;
}
/* The row being played reads at a glance -- there is one audio element, so
   which row owns it is not otherwise obvious. */
.dev-queue-row.is-playing {
  border-color: rgba(255, 210, 87, 0.75);
  background: rgba(255, 210, 87, 0.07);
}

.dev-queue-top, .dev-queue-bottom {
  display: flex;
  align-items: center;
  gap: 16px;
}
.dev-queue-top { flex-wrap: wrap; }

.dev-queue-row .dev-btn { padding: 10px 20px; font-size: 17px; }
/* The play button is a target you hit repeatedly while skimming, so it is a
   square you cannot miss rather than a text-sized arrow. */
.dev-queue-top .dev-btn:first-child {
  min-width: 58px;
  font-size: 22px;
  padding: 10px 0;
}
.dev-queue-name { font-size: 26px; letter-spacing: 1px; flex: 1 1 240px; }
.dev-queue-by   { font-size: 17px; }
.dev-queue-meta { font-size: 17px; color: #9a9a9a; }

/* ---- the scrubber ---- */
.dev-scrub-wrap {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  gap: 14px;
  min-width: 0;
}
.dev-scrub {
  -webkit-appearance: none;
  appearance: none;
  flex: 1 1 auto;
  min-width: 0;
  height: 26px;               /* the grab area, not the visible bar */
  background: transparent;
  cursor: pointer;
}
.dev-scrub::-webkit-slider-runnable-track {
  height: 10px;
  border-radius: 5px;
  background: rgba(255, 210, 87, 0.16);
  border: 1px solid rgba(255, 210, 87, 0.3);
}
.dev-scrub::-moz-range-track {
  height: 10px;
  border-radius: 5px;
  background: rgba(255, 210, 87, 0.16);
  border: 1px solid rgba(255, 210, 87, 0.3);
}
.dev-scrub::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 22px;
  height: 22px;
  margin-top: -7px;           /* centres the thumb on a 10px track */
  border-radius: 50%;
  background: #ffd257;
  border: 2px solid #1a1408;
  box-shadow: 0 0 10px rgba(255, 210, 87, 0.7);
}
.dev-scrub::-moz-range-thumb {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #ffd257;
  border: 2px solid #1a1408;
  box-shadow: 0 0 10px rgba(255, 210, 87, 0.7);
}
.dev-scrub-time {
  font-size: 18px;
  color: #ffd257;
  /* Fixed width so the row does not twitch every time a digit changes. */
  min-width: 132px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

#dev-queue-stats { font-size: 19px; margin-bottom: 10px; }

/* The scrubber lost the row to the two decision buttons -- 78px of bar is not
   something you can skim a four-minute beat on. It gets its own line, full
   width, and the buttons sit under it. */
.dev-queue-row { align-items: stretch; }
.dev-queue-bottom { flex-wrap: wrap; }
.dev-scrub-wrap {
  flex: 1 0 100%;      /* own line, whole width */
  order: -1;
}
.dev-queue-bottom .dev-btn { flex: 1 1 0; }

/* ---- bulk upload: one row per file ----
   A single progress bar is fine for five files and useless for a hundred --
   you cannot tell a stall from a slow file, or see which ones are failing
   until the whole run is over. */
#dev-bulk-list {
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-height: 340px;
  overflow-y: auto;
  font-size: 15px;
}
.dev-bulk-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 6px 10px;
  border-left: 3px solid rgba(255, 210, 87, 0.2);
  background: rgba(255, 255, 255, 0.02);
}
.dev-bulk-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: #ddd;
}
.dev-bulk-genre { color: #ffb347; font-size: 14px; }
.dev-bulk-status { min-width: 110px; text-align: right; color: #888; font-size: 14px; }
.dev-bulk-row.is-up  { border-left-color: #ffd257; }
.dev-bulk-row.is-up  .dev-bulk-status { color: #ffd257; }
.dev-bulk-row.is-ok  { border-left-color: #8df59a; }
.dev-bulk-row.is-ok  .dev-bulk-status { color: #8df59a; }
.dev-bulk-row.is-bad { border-left-color: #ff6b4a; background: rgba(255, 107, 74, 0.07); }
.dev-bulk-row.is-bad .dev-bulk-status { color: #ff6b4a; }

/* Detected tempo/key on a bulk row. A guess is styled differently from a
   value the producer wrote in the filename, because one of them is a guess. */
.dev-bulk-info { color: #9ad7ff; font-size: 14px; min-width: 130px; }
.dev-bulk-info.is-guess { color: #7fa8c4; font-style: italic; }

/* ---- creator credits ---- */
/* A credit that leads somewhere looks like it does. One that doesn't stays
   exactly as it was -- a link that goes nowhere is worse than no link. */
.credit-link { text-decoration: underline; text-underline-offset: 3px; }
.credit-link:hover, .credit-link:focus-visible { color: #fff; text-shadow: 0 0 10px rgba(255, 210, 87, 0.9); }

#creator-card {
  position: fixed;
  inset: 0;
  z-index: 9000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.72);
  backdrop-filter: blur(4px);
}
#creator-card.hidden { display: none; }
#cc-panel {
  position: relative;
  width: min(560px, 92vw);
  max-height: 80vh;
  overflow-y: auto;
  padding: 30px 34px 34px;
  background: #0d0b06;
  border: 2px solid rgba(255, 210, 87, 0.55);
  border-radius: 8px;
  box-shadow: 0 0 60px rgba(255, 210, 87, 0.18);
}
#cc-close {
  position: absolute;
  top: 12px; right: 14px;
  background: none;
  border: 0;
  color: #ffd257;
  font-size: 30px;
  line-height: 1;
  cursor: pointer;
}
#cc-head { display: flex; align-items: center; gap: 16px; margin-bottom: 22px; }
#cc-av {
  width: 56px; height: 56px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: rgba(255, 210, 87, 0.16);
  border: 2px solid rgba(255, 210, 87, 0.5);
  color: #ffd257;
  font-size: 26px;
}
#cc-name { margin: 0; font-size: 32px; letter-spacing: 2px; color: #ffd257; }
.cc-k { font-size: 17px; letter-spacing: 5px; color: #8a7f5f; margin-bottom: 12px; }
#cc-works { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
#cc-works li {
  display: flex; flex-direction: column; gap: 3px;
  padding: 12px 14px;
  background: rgba(255, 255, 255, 0.03);
  border-left: 3px solid rgba(255, 210, 87, 0.45);
}
#cc-works b { font-size: 20px; color: #eee; }
#cc-works i { font-size: 14px; color: #9a8f6f; font-style: normal; letter-spacing: 2px; }
#cc-msg { margin: 14px 0 0; font-size: 16px; color: #9a8f6f; }

/* ==========================================================================
   CREDS, MADE TO LOOK LIKE MONEY

   It read as a status line: small, flat, the same weight as everything around
   it. It is the only spendable thing in the game and the whole wager economy
   runs on it, so it gets to look like it matters.
   ========================================================================== */
#cred-hud {
  height: 62px;
  padding: 0 20px 0 15px;
  gap: 12px;
  /* A vein of light across the plate rather than a flat fill -- the number
     should look lit from within, not printed on. */
  background:
    linear-gradient(180deg, rgba(57, 255, 20, 0.13) 0%, rgba(6, 16, 8, 0.9) 55%),
    rgba(6, 16, 8, 0.9);
  border-color: rgba(57, 255, 20, 0.65);
  box-shadow:
    0 0 22px rgba(57, 255, 20, 0.22),
    inset 0 1px 0 rgba(160, 255, 130, 0.35);
}
#cred-hud-n {
  font-size: 32px;
  letter-spacing: 2px;
  color: #fff;
  /* Layered rather than one big blur: a tight core keeps the digits crisp
     while the wide pass does the glowing. One blur wide enough to carry the
     glow would smear the numerals into each other. */
  text-shadow:
    0 0 4px rgba(215, 255, 205, 0.95),
    0 0 14px rgba(57, 255, 20, 0.8),
    0 0 34px rgba(57, 255, 20, 0.45);
}
#cred-hud-mark {
  width: 26px;
  height: 26px;
  stroke-width: 1.8;
  opacity: 1;
  filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.8));
}
#cred-hud-k { font-size: 12px; letter-spacing: 4px; }

/* Earning something should be felt. The plate swells and the glow blooms,
   then settles -- the number arriving is the payoff of a whole match. */
@keyframes cred-bank {
  0%   { transform: scale(1);    filter: hue-rotate(var(--logo-hue)) brightness(1); }
  22%  { transform: scale(1.09); filter: hue-rotate(var(--logo-hue)) brightness(1.7); }
  100% { transform: scale(1);    filter: hue-rotate(var(--logo-hue)) brightness(1); }
}
#cred-hud.credited { animation: cred-bank 0.75s cubic-bezier(0.2, 0.9, 0.25, 1); }

/* The profile plate's balance, same treatment at that scale. */
#pv-st-creds {
  color: #fff;
  font-size: clamp(20px, calc(1.9 * var(--dvw, 1vw)), 30px);
  letter-spacing: 3px;
  text-shadow: 0 0 4px rgba(215, 255, 205, 0.9), 0 0 16px rgba(57, 255, 20, 0.7);
}
#pv-st-creds b {
  font-size: 1.35em;
  font-weight: bold;
  font-variant-numeric: tabular-nums;
}

@media (max-width: 900px) {
  #cred-hud { height: 52px; padding: 0 13px 0 10px; gap: 8px; }
  #cred-hud-n { font-size: 25px; }
  #cred-hud-mark { width: 21px; height: 21px; }
}

/* The balance sits hard right on the level plate, opposite LVL -- the two
   numbers that say who you are, one at each end, rather than the balance
   crowding the level. */
#pv-st-creds {
  margin-left: auto;
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  font-size: clamp(11px, calc(1.1 * var(--dvw, 1vw)), 15px);
  letter-spacing: 4px;
  color: rgba(234, 255, 227, 0.72);
  text-shadow: none;
}
#pv-st-creds b {
  font-size: clamp(26px, calc(2.6 * var(--dvw, 1vw)), 40px);
  font-weight: bold;
  color: #fff;
  font-variant-numeric: tabular-nums;
  letter-spacing: 1px;
  /* Same layered glow as the HUD: tight core for legible digits, wide pass
     for the bloom. */
  text-shadow:
    0 0 4px rgba(215, 255, 205, 0.95),
    0 0 14px rgba(57, 255, 20, 0.75),
    0 0 32px rgba(57, 255, 20, 0.4);
}

/* ==========================================================================
   THE BALANCE, AS A PLATE

   A number with a word after it is a readout. This is the only spendable
   thing in the game, so it gets treated as an object: its own angular plate,
   a struck emblem, and light that moves across the digits.
   ========================================================================== */
#pv-st-creds {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 8px 16px 8px 12px;
  font-size: clamp(10px, calc(1 * var(--dvw, 1vw)), 13px);
  letter-spacing: 4px;
  color: rgba(180, 255, 170, 0.75);
  text-shadow: none;

  background:
    linear-gradient(180deg, rgba(57, 255, 20, 0.16) 0%, rgba(4, 14, 6, 0.92) 60%),
    rgba(4, 14, 6, 0.92);
  border: 1px solid rgba(57, 255, 20, 0.55);
  /* Cut corners on opposite ends -- the plate reads as struck metal rather
     than a rounded chip, matching the rest of the UI. */
  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
  box-shadow: 0 0 20px rgba(57, 255, 20, 0.18);
  position: relative;
  overflow: hidden;
}

/* The emblem. A struck diamond, not an icon file -- one element, no request,
   and it inherits the glow. */
#pv-st-creds::before {
  content: '';
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  background: linear-gradient(135deg, #d9ffcf, #39ff14);
  transform: rotate(45deg);
  box-shadow: 0 0 12px rgba(57, 255, 20, 0.9);
}

/* A slow sweep of light across the plate. Long gap between passes on
   purpose: a shimmer that never stops stops being noticed, and starts being
   irritating. Transform-only, so it composites and costs no layout. */
#pv-st-creds::after {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 45%;
  height: 100%;
  pointer-events: none;
  background: linear-gradient(100deg,
    transparent 0%,
    rgba(215, 255, 205, 0.22) 45%,
    rgba(215, 255, 205, 0.42) 50%,
    rgba(215, 255, 205, 0.22) 55%,
    transparent 100%);
  transform: translateX(-160%) skewX(-18deg);
  animation: creds-sweep 6.5s ease-in-out infinite;
}
@keyframes creds-sweep {
  0%, 62%  { transform: translateX(-160%) skewX(-18deg); }
  86%, 100% { transform: translateX(420%) skewX(-18deg); }
}

#pv-st-creds b {
  font-size: clamp(24px, calc(2.4 * var(--dvw, 1vw)), 38px);
  font-weight: bold;
  font-variant-numeric: tabular-nums;
  letter-spacing: 1px;
  /* Gradient digits: bright at the top, cooling toward the base, so the
     number looks lit rather than filled. */
  background: linear-gradient(180deg, #ffffff 0%, #d7ffcd 42%, #4dff2a 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* A drop-shadow, not a text-shadow: text-shadow paints behind the element
     box, and with a transparent (clipped) fill there is nothing to sit
     behind, so the glow would vanish. drop-shadow follows the glyph shapes. */
  filter:
    drop-shadow(0 0 4px rgba(215, 255, 205, 0.9))
    drop-shadow(0 0 14px rgba(57, 255, 20, 0.7));
}

/* Someone who does not want motion should not get a light sweeping past
   their balance forever. */
@media (prefers-reduced-motion: reduce) {
  #pv-st-creds::after { animation: none; opacity: 0; }
}

/* Dialled back. The plate was blowing out -- the digit glow, the plate's own
   bloom and the gradient's white top were all stacking, so the number read as
   a light source rather than a number on a lit plate. */
#pv-st-creds {
  background:
    linear-gradient(180deg, rgba(57, 255, 20, 0.07) 0%, rgba(4, 12, 6, 0.94) 60%),
    rgba(4, 12, 6, 0.94);
  border-color: rgba(57, 255, 20, 0.3);
  box-shadow: 0 0 10px rgba(57, 255, 20, 0.08);
}
#pv-st-creds b {
  /* Starts below white so the top of the digits is a highlight, not a flare. */
  background: linear-gradient(180deg, #cdf5c4 0%, #7ceb62 55%, #3fbf28 100%);
  -webkit-background-clip: text;
  background-clip: text;
  filter: drop-shadow(0 0 5px rgba(57, 255, 20, 0.3));
}
#pv-st-creds::before {
  background: linear-gradient(135deg, #a8e89c, #35b524);
  box-shadow: 0 0 5px rgba(57, 255, 20, 0.35);
}
/* The sweep was reading as a second light source. Barely there now -- enough
   to catch the eye in motion, not enough to notice sitting still. */
#pv-st-creds::after {
  background: linear-gradient(100deg,
    transparent 0%,
    rgba(215, 255, 205, 0.07) 45%,
    rgba(215, 255, 205, 0.13) 50%,
    rgba(215, 255, 205, 0.07) 55%,
    transparent 100%);
}

/* ==========================================================================
   THE BALANCE PLATE, MATCHED TO THE STAT TILES

   It was its own object with its own brightness sitting above a row of quiet
   tiles, so it read as pasted on. Same fill, same border, same corner cut,
   same typeface as #pv-st-grid li -- it is one of them now, just wider.
   ========================================================================== */
#pv-st-creds {
  padding: 10px 14px 10px 12px;
  background: rgba(134, 255, 102, 0.06);
  border: 1px solid rgba(134, 255, 102, 0.25);
  /* The tiles' cut, not the opposite-corner one this had. */
  clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
  box-shadow: none;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.55);
}
#pv-st-creds b {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  /* Bigger than a tile's number, because it is the headline figure -- but the
     same family and the same flat fill, so the difference is scale and not
     treatment. */
  font-size: clamp(20px, calc(2 * var(--dvw, 1vw)), 30px);
  font-weight: normal;
  letter-spacing: 1px;
  background: none;
  -webkit-background-clip: border-box;
  background-clip: border-box;
  color: #eaffe3;
  filter: none;
}
#pv-st-creds::before {
  width: 10px;
  height: 10px;
  background: rgba(134, 255, 102, 0.75);
  box-shadow: none;
}
/* No sweep. The tiles do not shimmer, so neither does this. */
#pv-st-creds::after { display: none; }

/* The level is the headline number on this plate -- it was competing with the
   balance instead of leading it. */
#pv-st-lvl {
  font-size: clamp(72px, calc(7.2 * var(--dvw, 1vw)), 118px);
  /* The glyph's own box carries a lot of slack at this size; a slight
     negative margin keeps the plate from growing a band of empty space
     above and below the digit. */
  margin: -0.06em 0;
}
#pv-st-lvl-k {
  font-size: 15px;
  letter-spacing: 5px;
  /* Sits on the digit's baseline rather than floating at its mid-height,
     which is what a label twice as short as its number looks wrong doing. */
  align-self: flex-end;
  padding-bottom: 0.16em;
}

/* The pulse now finishes with the count instead of hanging on after it. */
#cred-hud.credited { animation-duration: 0.34s; }

/* ==========================================================================
   THE STATS WIPE, SMOOTHED

   WHY IT WAS CHOPPY: clip-path is not a compositable property. transform,
   opacity and filter can be handed to the GPU and animated without the main
   thread; clip-path cannot -- every frame is a fresh paint of the whole pane,
   including its text, borders and hue-rotate filter. On a busy frame those
   paints get dropped and the sweep stutters.

   It cannot be made free without giving up the wipe (a true reveal needs
   clip-path, width or overflow, none of them composited), so this makes each
   frame cheaper and the motion less demanding instead:

     - promoted to its own layer up front, so the paint is confined to the
       pane rather than re-compositing what sits under it
     - the dead hold at 40-50% is gone. Holding a clip still for 95ms reads as
       a stall, not a charge -- the eye cannot tell "held on purpose" from
       "dropped frames", and this animation had both
     - one continuous curve across the sweep instead of two spliced ones. The
       old pair met at an abrupt velocity change, which is the exact thing
       that looks like a hitch
   ========================================================================== */
#pv-stats {
  /* Held permanently rather than toggled: this pane is small and there are
     only ever two of them, so the memory is trivial next to setting up a
     fresh layer at the moment the animation starts -- which is itself a
     stutter on the first frame. */
  will-change: clip-path;
  backface-visibility: hidden;
}
.pv-stats-on #pv-stats {
  animation: pv-ignite-smooth 0.82s both;
}
@keyframes pv-ignite-smooth {
  0% {
    clip-path: inset(100% calc(100% - 3px) 0 0);
    animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);   /* the rise */
  }
  34% {
    clip-path: inset(0 calc(100% - 3px) 0 0);                     /* full height */
    /* Straight into the sweep. No hold: the beam reaching full height IS the
       charge, and a pause after it only reads as lag. */
    animation-timing-function: cubic-bezier(0.4, 0, 0.15, 1);
  }
  100% { clip-path: inset(0 0 0 0); }
}
/* Closing gets the same treatment -- it was the harsher of the two, sweeping
   back with a curve that starts fast and stops dead. */
#pv-stats { transition: clip-path 0.42s cubic-bezier(0.4, 0, 0.15, 1); }

/* ==========================================================================
   CLOSING, DRIVEN PROPERLY

   The shake on close was two things driving clip-path at once. Removing
   .pv-stats-on removes the ignite animation, so the element snaps to the base
   rule's clip in one frame -- and the transition then starts from that snapped
   value rather than from where the pane actually was. Animation off, jump,
   transition in: read as a rigid stutter.

   Closing now has its OWN animation, ending exactly on the resting clip so
   there is nothing left to jump to, and the transition is suppressed while it
   runs so only one thing is ever moving the pane.
   ========================================================================== */
.pv-stats-closing #pv-stats {
  transition: none;
  animation: pv-douse 0.4s both;
  pointer-events: none;
}
@keyframes pv-douse {
  0% {
    clip-path: inset(0 0 0 0);
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  }
  /* Back to the lit edge -- the mirror of the sweep that opened it. */
  62% {
    clip-path: inset(0 calc(100% - 3px) 0 0);
    animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0);
  }
  /* And down, landing exactly on the resting clip so the class coming off
     changes nothing visible. */
  100% { clip-path: inset(100% calc(100% - 3px) 0 0); }
}

/* ==========================================================================
   THE OUTER FRAME

   Every section on this screen already sits in its own bordered .jp-panel,
   but nothing held the SET of them -- title, panels, showcase and the
   save/back row read as loose pieces floating on black rather than one
   screen. This wraps them in the same corner-tick/segmented-edge language
   .jp-panel already uses, at screen scale instead of section scale, so the
   page finally has the container its parts are missing.
   ========================================================================== */
.jp-frame {
  position: relative;
  margin: 0 auto 40px;
  max-width: 1040px;
  padding: 42px 46px 38px;
}
.jp-frame::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* The border itself, undrawn -- the frame's spacing (padding pulling the
     inner panels into a shared column) stays; only the drawn edge is gone. */
  display: none;
  /* Same construction as .jp-panel::before -- solid top, one gap per side
     edge, one on the bottom, corner ticks -- just drawn at a heavier weight
     so the outer edge doesn't read as just another inner panel. */
  background:
    linear-gradient(rgba(134, 255, 102, 0.85), rgba(134, 255, 102, 0.85)) top left / 100% 3px,
    linear-gradient(90deg, rgba(134, 255, 102, 0.85) 0 18%, transparent 18% 21%, rgba(134, 255, 102, 0.85) 21% 100%) bottom left / 100% 3px,
    linear-gradient(180deg, rgba(134, 255, 102, 0.85) 0 28%, transparent 28% 37%, rgba(134, 255, 102, 0.85) 37% 100%) top left / 3px 100%,
    linear-gradient(180deg, rgba(134, 255, 102, 0.85) 0 52%, transparent 52% 61%, rgba(134, 255, 102, 0.85) 61% 100%) top right / 3px 100%,
    linear-gradient(#86ff66, #86ff66) top left / 32px 5px,
    linear-gradient(#86ff66, #86ff66) top left / 5px 32px,
    linear-gradient(#86ff66, #86ff66) bottom right / 32px 5px,
    linear-gradient(#86ff66, #86ff66) bottom right / 5px 32px;
  background-repeat: no-repeat;
  filter: hue-rotate(var(--ui-hue, 0deg));
}
/* The title sits half-in, half-out of the frame's own top edge -- same
   "breaks the corner" move #profile-preview-box already makes on the
   identity panel, so the frame reads as a container the title is escaping
   rather than a box drawn around a page that ignores it. */
#profile-title { margin: -20px 0 18px; }
@media (max-width: 720px) {
  .jp-frame { padding: 26px 20px 22px; }
}

/* ==========================================================================
   INVENTORY: THE PULL-OUT BESIDE THE PROFILE

   Opening shifts the STAGE, not the frame. The inventory is a child sitting
   at left:100%, so one transform on the parent does both jobs at once --
   the profile slides left and the inventory rides in from off the right
   edge, permanently locked to each other's spacing.

   transform, deliberately: it is one of the few properties the compositor
   can animate without the main thread (unlike width, max-width, clip-path
   or margin, any of which would re-lay-out the whole screen every frame of
   a 1500px-wide panel with a textured background inside it).
   ========================================================================== */
/* PLAIN FLEXBOX ROW. No transform math, no viewport measurement, no JS.
   The profile and the inventory are just two flex children; opening sets
   the inventory's flex-basis from 0 to its real width and the gap from 0
   to 40px, and flexbox centering (justify-content) does the rest --
   moving the profile left and revealing the inventory are the SAME event
   (the row getting wider while staying centred), not two things kept in
   sync by hand.

   This replaces an earlier version that computed the shift from --dvw (a
   FIXED 2560px design-grid unit, not the real window) and then from a
   JS-measured pixel offset applied as a transform to a position:fixed
   panel. Both were solving a problem plain flow does not have: a flex row
   cannot overflow the window in a way that produces a phantom scrollbar,
   because its width IS its content's width, not a number computed
   separately and hoped to match. */
/* align-items: flex-start is the actual fix for "the toggle goes down".
   Flex's default (stretch) makes every item in a row match the TALLEST
   item's height -- and the inventory's own natural content (a dozen grid
   cells, tabs, blurb) is taller than the profile form. So opening it did
   not just widen the row, it STRETCHED #profile-frame taller to match,
   which moved the frame's own centre down -- and the toggle, centred on
   that centre, moved with it. The toggle was never miscomputing anything;
   the thing it was centring ON kept changing size under it.
   flex-start makes every item keep its own natural height regardless of
   its siblings, so the frame's height (and centre) is now the same whether
   the drawer is open, closed, or has a thousand items in it. */
#profile-stage {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  gap: 0;
  transition: gap 0.5s ease;
}
#profile-frame {
  /* Was 100% of #profile-stage, which was itself capped at 1040px. Same
     cap, now on the frame directly since the stage no longer sets one. */
  flex: 0 0 auto;
  width: min(1040px, 100%);
  /* .jp-frame sets margin:0 auto -- auto margins on a flex item absorb the
     row's free space THEMSELVES, which would self-centre the frame no
     matter what the parent's justify-content says, silently overriding it
     the moment the inventory sibling changes the row's width. The parent
     does the centring now; this item just keeps its own bottom spacing. */
  margin: 0 0 40px;
}
/* geometry replaced below */
#profile-inventory .jp-panel { margin-top: 0; }

/* ---- the pull-tab ---- */
/* A flex item of #profile-stage, not absolutely positioned against the
   frame -- align-self:center centres it on the ROW's height automatically,
   so it stays put wherever it lands regardless of how tall the form gets.
   flex:0 0 auto keeps it from stretching or shrinking with the row. */
#inv-toggle {
  flex: 0 0 auto;
  align-self: center;
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 26px 12px;
  background: rgba(8, 18, 8, 0.85);
  border: 2px solid rgba(134, 255, 102, 0.55);
  border-left: none;
  /* Points right at rest: the direction it will move things. */
  clip-path: polygon(0 0, 100% 14px, 100% calc(100% - 14px), 0 100%);
  color: #86ff66;
  cursor: pointer;
  filter: hue-rotate(var(--ui-hue, 0deg));
  transition: background 0.16s ease-out, border-color 0.16s ease-out;
}
#inv-toggle:hover, #inv-toggle:focus-visible {
  background: rgba(30, 66, 20, 0.95);
  border-color: #b6ffa0;
}
#inv-toggle-arrow {
  width: 0;
  height: 0;
  /* A real triangle from borders, so it inherits nothing that could blur it
     and needs no extra element or image. */
  border-top: 11px solid transparent;
  border-bottom: 11px solid transparent;
  border-left: 15px solid currentColor;
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.30, 1);
}
#screen-profile.inv-open #inv-toggle-arrow { transform: rotate(180deg); }
#inv-toggle-k {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: 17px;
  letter-spacing: 4px;
  /* Reads bottom-to-top up the tab, so the tab stays narrow. */
  writing-mode: vertical-rl;
  transform: rotate(180deg);
}

/* ---- what's inside ---- */
#inv-tabs { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.inv-tab {
  padding: 9px 16px;
  background: rgba(134, 255, 102, 0.06);
  border: 1px solid rgba(134, 255, 102, 0.3);
  clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  color: rgba(157, 255, 176, 0.7);
  cursor: pointer;
  transition: color 0.16s ease-out, border-color 0.16s ease-out, background 0.16s ease-out;
}
.inv-tab:hover { color: #d8ffd0; border-color: rgba(134, 255, 102, 0.6); }
.inv-tab.is-on {
  color: #eaffe3;
  border-color: #86ff66;
  background: rgba(40, 88, 26, 0.5);
}
#inv-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  max-height: 620px;
  overflow-y: auto;
  /* SPELLED OUT, because `visible` here does not mean visible. Setting a
     non-visible overflow on ONE axis computes the other to `auto`, so
     overflow-y:auto quietly made this scrollable sideways too -- and the
     page runs under a fractional root `zoom` (0.335 on a 1080p window),
     where a column track landing on 133.6px is enough drift to trip that
     scrollbar. It is always a lie: the grid is a fixed-column vertical
     list, so there is nothing to its right at any width. Same call, same
     reason, as the sub-pixel clipping on #screen-home. */
  overflow-x: hidden;
}
/* Bottom-right of the panel, below the grid -- a flex sibling of it inside
   the same column (.jp-panel), so it never scrolls away with the grid's
   own overflow and doesn't need separate positioning to land in the
   corner. Same page-size/pager for every filter (see INV_PAGE_SIZE in
   main.js), so this is hidden outright rather than shown disabled when a
   filter's whole catalog fits on one page -- a pager with nowhere to go
   is just noise. */
.inv-pager {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
}
.inv-page-btn {
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(134, 255, 102, 0.06);
  border: 1px solid rgba(134, 255, 102, 0.3);
  clip-path: polygon(0 0, calc(100% - 6px) 0, 100% 6px, 100% 100%, 6px 100%, 0 calc(100% - 6px));
  color: rgba(157, 255, 176, 0.85);
  font-family: 'Courier New', monospace;
  font-size: 16px;
  cursor: pointer;
  transition: color 0.16s ease-out, border-color 0.16s ease-out, background 0.16s ease-out;
}
.inv-page-btn:hover:not(:disabled) { color: #d8ffd0; border-color: rgba(134, 255, 102, 0.6); background: rgba(40, 88, 26, 0.4); }
.inv-page-btn:disabled { opacity: 0.3; cursor: default; }
.inv-page-readout {
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 2px;
  color: rgba(157, 255, 176, 0.7);
  min-width: 4ch;
  text-align: center;
}
.inv-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 16px 8px;
  min-height: 116px;
  text-align: center;
  background: rgba(134, 255, 102, 0.05);
  border: 1px solid rgba(134, 255, 102, 0.22);
  clip-path: polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 9px 100%, 0 calc(100% - 9px));
  cursor: pointer;
  transition: border-color 0.16s ease-out, background 0.16s ease-out;
}
.inv-cell:hover { border-color: rgba(134, 255, 102, 0.65); background: rgba(40, 88, 26, 0.4); }
.inv-cell.is-equipped { border-color: #ffd257; background: rgba(255, 210, 87, 0.1); }
.inv-glyph { font-size: 32px; line-height: 1; color: #9fe88a; }
.inv-cell.is-equipped .inv-glyph { color: #ffd257; }
.inv-name {
  font-family: 'Courier New', monospace;
  font-size: 12px;
  letter-spacing: 1px;
  color: #d8ffd0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}
/* Same dashed-and-empty language as .sv-add: the shape of the panel reads
   the same whether it holds nothing or twelve things, rather than the grid
   vanishing and leaving a wall of text in its place. */
.inv-cell-empty {
  cursor: default;
  border-style: dashed;
  border-color: rgba(134, 255, 102, 0.16);
  background: transparent;
}
.inv-cell-empty:hover { border-color: rgba(134, 255, 102, 0.16); background: transparent; }
/* A font you don't own yet: full preview (the preview IS the pitch), but
   visibly for-sale -- dimmed name, dashed frame, and the price plate in
   the corner. Gold on hover: the buy language, distinct from the green
   equip language of owned cells. */
.inv-cell-font.is-locked { border-style: dashed; }
.inv-cell-font.is-locked .inv-name { opacity: 0.55; }
.inv-cell-font.is-locked:hover { border-color: rgba(255, 210, 87, 0.7); background: rgba(255, 210, 87, 0.06); }
.inv-cell-font.is-locked:hover .inv-name { opacity: 0.85; }
.inv-price {
  position: absolute;
  right: 8px;
  bottom: 6px;
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 1px;
  color: #ffd257;
}
.inv-cell-font { position: relative; }
#inv-empty { text-align: center; padding: 14px 10px 4px; }

/* Font cells: no glyph icon, no EQUIP/EQUIPPED caption, no credit line --
   just the player's own name set in that font, as big as it can be while
   still fitting (see invFitFontCell). The name IS the whole point of the
   cell, so nothing should compete with it for space; equip state and
   attribution both moved to the cell's title tooltip instead. Sized by
   JS per-cell, but line-height/wrapping/padding are CSS's job. */
.inv-cell-font { padding: 10px; }
/* Rectangles, not squares, for the FONT tab specifically -- 2 wide
   columns instead of 3 gives a decorative display font more width to
   breathe in, on top of the 85% safety margin invFitFontCell already
   leaves for ink that draws outside its own layout box. */
/* #profile-inventory #inv-grid (below) is 2 ids -- higher specificity
   than a bare #inv-grid.inv-grid-font, so it would win over this
   regardless of source order without matching that. */
#profile-inventory #inv-grid.inv-grid-font { grid-template-columns: repeat(2, 1fr); }
.inv-cell-font .inv-name {
  display: flex;
  flex: 1 1 auto;
  width: 100%;
  align-items: center;
  justify-content: center;
  white-space: normal;
  line-height: 1.05;
  overflow: hidden;
  /* No word-break here on purpose -- a name is one unbreakable word most
     of the time, and letting the browser split it mid-word (DRAC / O)
     was the actual bug. Only allowed to wrap at real whitespace
     (white-space: normal above already does that); if it still doesn't
     fit as a whole word, invFitFontCell shrinks it instead. */
  text-align: center;
}

/* Narrow windows cannot fit profile AND inventory side by side, so the
   drawer covers the profile instead of sitting beside it -- the stage stops
   moving and the panel comes in over the top.
   800px, NOT the ~1556px the pair actually occupies: media queries evaluate
   against REAL viewport pixels, but this app lays out in a zoomed design
   canvas (see swApplyZoom) pinned to 2560 wide. Layout width only falls
   below 2560 once zoom clamps at its 0.5 floor, where layout = 2x real --
   so the pair stops fitting at 1556 layout px, i.e. 778 real px. A
   breakpoint written in layout px would fire on every normal window and
   cover the profile with a drawer that had room to sit beside it. */


/* ==========================================================================
   THE PROFILE SCREEN GOES FULL-BLEED, SO ITS CORNER PLATE HITS THE CORNER

   .screen carries a permanent CRT animation (crtv-panel-glitch / crtv-jerkup)
   whose keyframes set `transform` -- and a transformed element becomes the
   containing block for its own position:fixed descendants. So LEAVE, which is
   position:fixed top/left 16px, was anchoring to #screen-profile's box rather
   than the window; with the shared 1200px centred cap that landed it inside
   the content column, sitting on top of the PROFILE title.

   The stylesheet's own note above that animation says these ancestors are
   "all full-bleed viewport-sized when it matters" -- the profile screen was
   the exception. Making it full-bleed restores that assumption instead of
   fighting it, and costs no layout: #profile-stage was already the thing
   doing the centring (width: min(1040px, 100%); margin: 0 auto).
   ========================================================================== */
#screen-profile.active {
  --sw-body-pad: 16px;   /* body's own horizontal padding */
  max-width: none;
  /* Cancel body's own side padding so the plate reaches the true corner
     rather than stopping at the page gutter. */
  margin-left: calc(-1 * var(--sw-body-pad, 0px));
  margin-right: calc(-1 * var(--sw-body-pad, 0px));
  padding-left: var(--sw-body-pad, 0px);
  padding-right: var(--sw-body-pad, 0px);
}


/* A plain flex child now, back in #profile-stage. No position:fixed, no
   viewport measurement, no containing-block bookkeeping -- it does not care
   where the stage sits or whether the screen is full-bleed, because normal
   flow never needs to know that. Collapsed to zero width at rest via
   flex-basis, which is what makes the row narrow enough to sit centred as
   just the profile; opening grows the basis and the row widens around it,
   with justify-content:center on the parent keeping the pair centred as one
   the whole time. */
/* The stage's own gap stays 0 permanently -- with three flex items now
   (frame, toggle, inventory) a single row-wide gap would space BOTH sides
   of the toggle equally, detaching it from the frame it's meant to sit
   flush against. The growing space belongs to the inventory alone, as its
   own margin, so only the toggle-to-inventory side ever opens up. */
/* THE MOTION IS TRANSFORM + OPACITY ONLY. NOT flex-basis/width/margin/
   max-height -- those are LAYOUT properties: the browser must recompute
   the whole row's geometry on every single frame of the animation, and the
   panel's own border is eight layered gradients (see .jp-panel::before)
   repainting at a new size each time too. That combination is exactly what
   reads as choppy rather than smooth, no matter how the easing curve is
   tuned -- the jank is in WHAT is animating, not how.

   So the actual box size is set INSTANTLY (no transition on flex-basis,
   width, margin-left or max-height at all -- the row snaps to its final
   layout in one reflow, the instant the class toggles) and the perceived
   reveal is carried entirely by transform (compositor, off the main
   thread) and opacity: a short slide-and-fade over the already-final box.
   This is the same house rule the rest of the game already follows for
   motion (screen transitions animate transform+filter, hover glows use
   drop-shadow) -- compositor properties for anything that has to look
   smooth, layout properties only where the box genuinely must resize. */
#profile-inventory {
  flex: 0 0 0;
  width: 0;
  margin-left: 0;
  max-height: 0;
  overflow: hidden;
  opacity: 0;
  transform: translateX(-24px);
  transition: opacity 0.28s ease-out, transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
#screen-profile.inv-open #profile-inventory {
  /* 480 -> 800px. var(--dvw), not raw vw, for the cap -- --dvw follows the
     app's zoomed layout space (see swApplyZoom); raw vw is real-viewport
     pixels and drifts from it the moment zoom is not 1:1. */
  flex-basis: min(800px, 54 * var(--dvw, 1vw));
  width: min(800px, 54 * var(--dvw, 1vw));
  margin-left: 40px;
  max-height: 2000px;
  opacity: 1;
  transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
  #profile-inventory { transition: none; }
}
/* No explicit height: align-items defaults to stretch, so this simply
   matches whatever height the profile frame ends up at -- one less number
   to keep in sync by hand. */
#profile-inventory .jp-panel {
  margin-top: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
}
#profile-inventory #inv-grid {
  /* The grid takes whatever height is left after the tag, blurb and tabs --
     max-height alone would leave it short on a tall window and overflowing
     on a short one. */
  flex: 1 1 auto;
  max-height: none;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  align-content: start;
}

/* The decorative gap in .jp-panel's right/left border is a PERCENTAGE of the
   panel's height (58-68%, 34-44%) -- sized for the small ~250px panels it
   was drawn for, where that is a 20-30px notch. The inventory panel is
   stretched to match the profile frame's full height (over 1000px in this
   layout), so the same percentage opened into a gap 5x as tall: not a
   stylistic notch anymore, just the line missing for a few hundred pixels.
   Solid edges here instead of trying to rescale the notch to fit. */
#profile-inventory .jp-panel::before {
  background:
    linear-gradient(rgba(134, 255, 102, 0.85), rgba(134, 255, 102, 0.85)) top left / 100% 2px,
    linear-gradient(90deg, rgba(134, 255, 102, 0.85) 0 24%, transparent 24% 26.5%, rgba(134, 255, 102, 0.85) 26.5% 100%) bottom left / 100% 2px,
    linear-gradient(rgba(134, 255, 102, 0.85), rgba(134, 255, 102, 0.85)) top left / 2px 100%,
    linear-gradient(rgba(134, 255, 102, 0.85), rgba(134, 255, 102, 0.85)) top right / 2px 100%,
    linear-gradient(#86ff66, #86ff66) top left / 20px 4px,
    linear-gradient(#86ff66, #86ff66) top left / 4px 20px,
    linear-gradient(#86ff66, #86ff66) bottom right / 20px 4px,
    linear-gradient(#86ff66, #86ff66) bottom right / 4px 20px;
  background-repeat: no-repeat;
}

/* Bigger, to match "make the font bigger" -- the inventory's own tag,
   blurb, tabs and cell text were all a size below the profile panels
   beside them. */
#profile-inventory .jp-panel-tag { font-size: 29px; }
#profile-inventory .gang-empty { font-size: 18px; }
.inv-tab { font-size: 16px; padding: 10px 18px; }
.inv-cell { min-height: 140px; padding: 20px 10px; }
.inv-glyph { font-size: 40px; }
.inv-name { font-size: 15px; }
#inv-empty { font-size: 18px; }

/* The "?" placeholder tag on an empty slot -- see invRender in main.js.
   Dim on purpose: it marks a slot whose kind is not decided yet, not a
   real glyph, so it should read as absence rather than as content. */
.inv-cell-empty .inv-glyph { color: rgba(134, 255, 102, 0.35); }

/* ==========================================================================
   CLICK-TO-EDIT NAME

   No permanent label, no permanent input chrome -- it reads as a NAME at
   rest (bigger, no border, no fill), and clicking it is the whole edit
   affordance. Specificity here beats #screen-profile's shared input rule
   (attribute selector there vs class here, both single-class weight, this
   one wins on source order -- but written with !important on the visual
   resets anyway so it can never silently lose that fight if the shared
   rule is edited later).
   ========================================================================== */
/* The autosize trick: .name-autosize is an inline-grid with a single cell.
   Its ::after mirrors the typed value (kept in sync by profileNameSync in
   main.js) as an invisible copy in the SAME font -- that sizes the grid
   cell to the text's actual width. The input shares that cell (grid-area
   1/1, width:100%) so it's stretched to match instead of falling back to
   the browser's ~20-char default input width, which used to be wider than
   the panel at this font size and forced the pencil onto its own line
   below the name instead of sitting right after it. */
.name-autosize {
  display: inline-grid;
  vertical-align: middle;
  max-width: 100%;
  /* Containing block for the absolutely-positioned input below. */
  position: relative;
}
.name-autosize::after {
  content: attr(data-value) ' ';
  grid-area: 1 / 1;
  visibility: hidden;
  white-space: pre;
  /* Deliberately NOT the equipped font: this mirror is what sizes the grid
     cell, so if it followed the equipped font the box (and the pencil
     anchored after it) resized on every equip -- 111px to 316px for the
     same name. It measures in the house font at a fixed size instead, so
     the box is a function of the NAME only. applyProfileNameFontPreview
     normalises the equipped font's size to roughly this same width, so
     the real text still fills the box it's given.
     The override for the equipped font is set on #profile-name itself
     (not this element's parent) precisely so it does NOT inherit here. */
  font-family: var(--player-name-font);
  font-size: 48px;
  font-weight: bold;
  letter-spacing: 1px;
  padding: 4px 8px 4px 0;
}
#profile-name.click-to-edit {
  /* Taken OUT OF FLOW so the mirror is the only thing that sizes the box.
     An in-flow <input> contributes its own intrinsic width (roughly its
     `size` attribute in the current font at the current size) to the grid
     track, and no combination of width/min-width suppresses that -- a
     percentage min-width falls back to the input's min-content during
     intrinsic track sizing, which is exactly the number we were trying to
     avoid. That contribution is what still widened the field on one font
     out of 22 after the mirror was pinned to the house font. Absolutely
     positioned against the grid area, it stretches to whatever the mirror
     decided and contributes nothing back. */
  position: absolute;
  inset: 0;
  width: auto;
  min-width: 0;
  padding: 4px 8px 4px 0 !important;
  background: transparent !important;
  border: none !important;
  /* Same font a player's name renders in everywhere else (--player-name-font,
     :root) -- was inheriting the shared #screen-profile input rule's plain
     monospace instead, which is a form-field font, not a NAME font. */
  font-family: var(--player-name-font) !important;
  /* Normalised per equipped font by applyProfileNameFontPreview -- at a
     flat 48px the same name rendered anywhere from 111px to 316px wide
     depending on the font, so the field (and the pencil beside it)
     jumped every time a font was picked. */
  font-size: var(--player-name-size, 48px) !important;
  font-weight: bold;
  letter-spacing: 1px;
  cursor: pointer;
  transition: background 0.14s ease-out, padding 0.14s ease-out;
}
#profile-name.click-to-edit:hover {
  background: rgba(134, 255, 102, 0.06) !important;
}
#profile-name.click-to-edit:focus {
  padding: 4px 12px !important;
  background: rgba(4, 10, 4, 0.85) !important;
  border-left: 4px solid rgba(134, 255, 102, 0.6) !important;
  box-shadow:
    2px 2px 0 rgba(20, 110, 10, 0.55),
    0 0 10px rgba(57, 255, 20, 0.25);
}
/* The pencil: shown at rest and on hover, hidden while actually editing --
   once the field has focus the cursor and the border already say "editing",
   a pencil next to it is noise. Sits right after .name-autosize (no
   whitespace between them in the markup) so it reads as part of the name
   instead of below it, and hue-rotates with --ui-hue like every other UI
   glyph on the panel instead of staying fixed green. */
#profile-id-fields { position: relative; display: flex; align-items: center; }
.name-edit-hint {
  margin-left: 8px;
  font-size: 20px;
  color: rgba(134, 255, 102, 0.75);
  filter: hue-rotate(var(--ui-hue, 0deg));
  pointer-events: none;
}
/* The input is now nested inside .name-autosize (the autosize wrapper),
   so it's no longer a direct sibling of the pencil -- :focus-within on the
   wrapper stands in for the old #profile-name:focus adjacent-sibling
   selector. */
.name-autosize:focus-within + .name-edit-hint { opacity: 0; }

/* ==========================================================================
   THE SAVED-TRACK VOLUME DIAL (sv-vol-wrap)

   Restored -- this was deleted by mistake, believing it was dead code after
   finding a DIFFERENT thing sharing the bare .sv-vol name (the trim-stage's
   plain <input type=range>) and assuming that was the only live one. It
   wasn't: this wrapper (speaker icon + thin slider + %) is what actually
   renders under a SAVED track, which is what was on screen when it broke --
   an oversized unstyled svg where a 15px icon used to be, and a bare native
   slider where a styled one used to be, both because their sizing rules had
   just been deleted out from under them.

   Renamed to .sv-vol-wrap (was .sv-vol) so it can never again collide with
   the trim stage's unrelated .sv-vol input -- see the comment in svRender.
   Base size here is the ORIGINAL, unscoped -- this also renders inside the
   small read-only mini card (#pv-showcase) on other players' profiles,
   which was never meant to grow. The bigger version is scoped to
   #prof-showcase below, same as every other showcase control already is.
   ========================================================================== */
.sv-vol-wrap {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 8px;
  padding: 6px 10px;
  border: 1px solid rgba(57, 255, 20, 0.16);
  background: rgba(6, 16, 8, 0.5);
}
.sv-vol-wrap svg {
  width: 15px; height: 15px;
  flex: 0 0 auto;
  fill: none;
  stroke: #86ff66;
  stroke-width: 1.6;
  stroke-linejoin: round;
  opacity: 0.8;
}
.sv-vol-wrap input[type="range"] {
  flex: 1;
  min-width: 0;
  height: 3px;
  appearance: none;
  -webkit-appearance: none;
  background: rgba(57, 255, 20, 0.22);
  outline: none;
  cursor: pointer;
}
.sv-vol-wrap input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 11px; height: 11px;
  background: #86ff66;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.7);
  cursor: pointer;
}
.sv-vol-wrap input[type="range"]::-moz-range-thumb {
  width: 11px; height: 11px;
  border: none;
  border-radius: 0;
  background: #86ff66;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.7);
  cursor: pointer;
}
.sv-vol-wrap b {
  flex: 0 0 3ch;
  font-family: 'Courier New', monospace;
  font-size: 12px;
  text-align: right;
  color: #86ff66;
  font-variant-numeric: tabular-nums;
}

/* ---- bigger, in the profile editor only (matches every sibling override
   just above it: .sv-row, .sv-title, .sv-play, .sv-del, .sv-add, .sv-vol) --
   the mini card everywhere else stays at the original size above. */
#prof-showcase .sv-vol-wrap { gap: 12px; padding: 9px 14px; margin-top: 10px; }
#prof-showcase .sv-vol-wrap svg { width: 22px; height: 22px; }
#prof-showcase .sv-vol-wrap input[type="range"] {
  height: 20px;
  background: transparent;
}
/* The visible track is drawn separately from the input's own height, so the
   HIT AREA (20px, easy to land a scroll/drag on) and the drawn line (5px)
   can differ -- a slider only grabbable by hitting a 3px line is one very
   few people can actually grab. */
#prof-showcase .sv-vol-wrap input[type="range"]::-webkit-slider-runnable-track {
  height: 5px;
  background: rgba(57, 255, 20, 0.22);
}
#prof-showcase .sv-vol-wrap input[type="range"]::-moz-range-track {
  height: 5px;
  background: rgba(57, 255, 20, 0.22);
}
#prof-showcase .sv-vol-wrap input[type="range"]::-webkit-slider-thumb {
  width: 18px; height: 18px;
  margin-top: -6.5px;   /* centres an 18px thumb on a 5px track */
  border-radius: 50%;
}
#prof-showcase .sv-vol-wrap input[type="range"]::-moz-range-thumb {
  width: 18px; height: 18px;
  border-radius: 50%;
}
#prof-showcase .sv-vol-wrap b { font-size: 17px; flex-basis: 4ch; }

/* ==========================================================================
   THE PLATES LOSE THEIR BACKS -- OUTLINE ONLY

   The corner furniture (profile plate, jukebox, social links) was a SOLID
   neon silhouette with a dark panel inset 3px, the leftover band reading as
   the lit edge. The dark panel goes; the band stays. Same numbers, opposite
   mechanism: instead of the inner pseudo PAINTING the interior dark, the
   interior is punched out of the neon shape itself with an evenodd
   clip-path -- outer loop, bridge, inner loop -- so the room shows through
   the middle of every plate. The bridge segment is traversed once in each
   direction, which cancels to zero width.

   The drop-shadow glows on the hosts are untouched and now bloom off the
   band alone -- thinner light source, same halo mechanism as the logo's
   linework. Buy the backs back: delete this block.
   ========================================================================== */
#hpb-plate {
  clip-path: polygon(evenodd,
    0 0,
    var(--hpb-break, 230px) 0,
    calc(var(--hpb-break, 230px) + 56px) 44px,
    100% 44px,
    calc(100% - 14px) 100%,
    0 100%,
    0 0,
    3px 3px,
    calc(var(--hpb-break, 230px) - 1px) 3px,
    calc(var(--hpb-break, 230px) + 54px) 44px,
    calc(100% - 3px) 44px,
    calc(100% - 16px) calc(100% - 3px),
    3px calc(100% - 3px),
    3px 3px
  );
}
#hpb-plate::after { display: none; }

#jb-shell {
  clip-path: polygon(evenodd,
    0 var(--jb-step),
    var(--jb-diag-start) var(--jb-step),
    var(--jb-diag-end) 0,
    100% 0,
    100% calc(100% - 14px),
    calc(100% - 14px) 100%,
    0 100%,
    0 var(--jb-step),
    3px calc(var(--jb-step) + 1px),
    calc(var(--jb-diag-start) - 1px) calc(var(--jb-step) + 1px),
    calc(var(--jb-diag-end) - 1px) 3px,
    calc(100% - 3px) 3px,
    calc(100% - 3px) calc(100% - 17px),
    calc(100% - 17px) calc(100% - 3px),
    3px calc(100% - 3px),
    3px calc(var(--jb-step) + 1px)
  );
}
#jb-shell::after { display: none; }

/* The social buttons are a later, SIMPLER construction than the plates by
   the time the cascade settles (see NO CUT CORNERS ON THE SOCIAL BUTTONS):
   plain squares -- host carries only the box-shadow glow, ::before is the
   solid neon shell, ::after the dark panel, svg above both. So no clip-path
   ring here -- clipping the HOST would take the glyph and the box-shadow
   glow with it (clip-path clips children and erases box-shadow). Instead
   the shell hollows itself: transparent fill, its 3px of neon as border.
   The dark panel just goes. Dead links keep their dim fill untouched. */
.hs-link:not(.hs-dead)::before {
  background: transparent;
  border: 1.5px solid #86ff66;
}
.hs-link:not(.hs-dead)::after { display: none; }

/* ==========================================================================
   THE CORNER FURNITURE IS MADE OF THE SAME NEON AS THE SIGN

   The logo and the menu words are WHITE linework with a green halo; the
   plates were still drawn in green-on-green (#86ff66 structure under the
   same green glow), which read as a different sign shop's work. This
   retints the STRUCTURE -- bands, borders, frames, primary text -- to
   white, and touches nothing about the glows: the halos stay authored
   green and keep cycling with --logo-hue while the white cores ignore the
   rotation, exactly how the logo and menu behave. Secondary/dim text keeps
   its green -- the hierarchy (hot core vs supporting glow) is the point.
   ========================================================================== */
/* profile plate */
#hpb-plate { background: #fff; }
#home-profile-badge .player-icon { border-color: #fff; }
#hpb-text { color: #fff; }
#hpb-xp-bar::before { border-color: #fff; }
/* jukebox */
#jb-shell { background: #fff; }
#jb-disc { border-color: #fff; }
#jb-title { color: #fff; }
/* socials */
.hs-link:not(.hs-dead) { color: #fff; }
.hs-link:not(.hs-dead)::before { border-color: #fff; }
/* Bare glyphs (user call, two rounds): first the resting glow went, then
   the boxes themselves -- no shell, no panel, no border, just the icons
   sitting in the room. The host's box-shadow goes at rest AND hover (a
   rectangular halo around an invisible rectangle reads as a ghost box);
   hover ignites the GLYPH instead, white core + green halo, same neon
   language as everything else. Dead links lose their dim box too --
   four bare icons and one boxed placeholder would read as a mistake. */
.hs-link:not(.hs-dead)::before,
.hs-link:not(.hs-dead)::after,
.hs-dead::before,
.hs-dead::after { display: none; }
.hs-link:not(.hs-dead), .hs-link:not(.hs-dead):hover { box-shadow: none; }
.hs-link:not(.hs-dead):hover svg {
  filter:
    drop-shadow(0 0 6px rgba(255, 255, 255, 0.95))
    drop-shadow(0 0 18px rgba(57, 255, 20, 0.8));
}

/* ==========================================================================
   FIRST BOOT TIMELINE

   Page-load only (body.first-boot, set by main.js and removed on the first
   LEAVE of the menu -- see the note there). The intro itself is a WebGL
   shader pass (bootGlitchIntro in main.js): the logo is rasterized into a
   full-screen glitch shader -- analog displacement, chromatic aberration,
   digital block corruption, CRT warp -- whose glitch amount eases smoothly
   from 1 to 0, so the whole picture settles into place like a camera
   finding its frame, then the canvas crossfades away over the real DOM
   logo. The rules here are the DOM side of that timeline:

   Every number is the JS envelope's pre-stretch value x INTRO_STRETCH
   (1.5, set in bootGlitchIntro's frame loop -- change it there,
   re-multiply everything here):

     0.0 - 2.1s  shader at full violence, easing down to a simmer
     2.1 - 7.0s  the letters stroke-trace themselves in (lb-draw) under a
                 lingering artifact overlay (the canvas is screen-blended,
                 so its black is transparent and the DOM shows through) --
                 decelerating: quick tight letters first, the last ones
                 slower and further apart, and the closing S swoosh at a
                 2s crawl of its own (see the lb rules below)
     4.0 - 5.5s  the settled static holds ALONE for ~1.4s (user call)
     5.5 - 9.5s  the letters trace in -- UNIFORM: 0.57s apart, 1.2s draws
     9.5 - 12.9s the closing S draws at a 3.4s crawl
     11.2 - 13.4s the ROOM fades in (#home-atmos + the base, below) from
                 the S's MIDPOINT -- it starts alone in the void, and
                 halfway through its draw the world unfolds around it;
                 the music un-muffles on this same window (see begin())
     13s         logo-boot-bloom: one swell as the sign completes
     12.35-13.1s the glitch simmer dissolves ON the S landing (12.89s) --
                 same beat as the surge/bloom; canvas gone 13.4s
     13.4s       the rest of the UI fades up (ui-fade-in below)
     14.8s       the brown-out dip clock starts
   (chroma ghosts return when first-boot drops at 15.5s -- rule below)
   ========================================================================== */
body.first-boot #screen-home.active #logo-wrap {
  /* logo-r-surge rides the R (lb6: starts 5.34s, draws 1.58s): the whole
     sign's glow intensifies slowly over 1s (0 -> 62.5% of the 1.6s run),
     then releases over the remaining 0.6s, landing with the R's last
     stroke. Listed AFTER the bloom on purpose -- same property (filter),
     and the later list entry wins while active; its fill is none, so
     outside its window the bloom's own both-fill governs exactly as
     before. */
  animation:
    logo-neon-dip 27s step-end 14.8s infinite,
    logo-boot-bloom 0.8s ease-out 13s both,
    logo-r-surge 4s ease-in-out 8.92s,
    logo-r-zoom 4s ease-in-out 8.92s;
}
/* 4.2s, not the original 1.6: the rise to the crest is the same 1s
   (24% of the run), but the RELEASE now stretches across the closing
   S's entire build, landing at ~10.94s -- right on the S's 10.89s
   finish -- instead of snapping off 0.6s after the peak ("the last
   keyframe is very fast, like the effect is just over"). The effect
   breathes out WITH the finale. */
/* Every keyframe carries the drop-shadow (transparent at the ends) so the
   filter LIST LENGTH never changes mid-animation -- a length mismatch
   makes Chrome renormalize the filter chain on every interpolated frame
   instead of just sliding values, one more per-frame cost inside the
   intro's busiest window. */
@keyframes logo-r-surge {
  0% { filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1) drop-shadow(0 0 26px rgba(57, 255, 20, 0)); }
  24% {
    filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1.45)
            drop-shadow(0 0 26px rgba(57, 255, 20, 0.55));
  }
  100% { filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1) drop-shadow(0 0 26px rgba(57, 255, 20, 0)); }
}
/* The camera move, rebuilt as a real push-in rather than a symmetric
   swell (user call: "more of a zoom and more natural camera movement
   than smooth outro"): a faster, stronger push that lands just before
   the glow crest, a small recoil off the overshoot, a slow drift while
   the surge holds, then a gentle settle home. Per-segment easings do
   the character work -- the springy pop curve on the push, long shallow
   ramps on the drift -- so nothing about it reads as one mirror-image
   ease. Transform is free on the wrap (surge/bloom own filter, the dip
   owns opacity). */
@keyframes logo-r-zoom {
  0%   { transform: scale(1);     animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); }
  18%  { transform: scale(1.035); animation-timing-function: ease-out; }
  26%  { transform: scale(1.026); animation-timing-function: linear; }
  60%  { transform: scale(1.018); animation-timing-function: ease-in-out; }
  100% { transform: scale(1); }
}
/* The CRT glare -- a white radial sheen anchored top-centre of the glass
   -- is invisible over normal content but reads as a stray glowing spot
   on the intro's pure black void. It sits the first boot out and returns
   with everything else when the class drops; no timing to keep in sync. */
body.first-boot #crt-overlay .crt-glare { display: none; }
/* THE SHAKE, in three acts (user call -- "music video shake"): a tight
   fast MICRO-JITTER kicks in on its own ~0.7s BEFORE the glow (0-15% of
   the run: flat 0.8px, snaps every ~70ms), keeps ticking while the glow
   builds (15-36%: amplitude grows 0.8 -> 3px into the crest), and that
   hands off seamlessly into the big shake, which decays to nothing
   across the S's build (36-100%). One animation, envelope baked into
   the frames, so the lead-in LEADS INTO the shake rather than being a
   separate effect that swaps in. Still step-end (positions snap, never
   glide -- the "no tween reads as electrical" house rule) and still
   irregular pseudo-random offsets, not a metronome. Every frame carries
   the face's resting translateZ(0) scaleX(1.12) (see #logo-img in the
   marquee section) or the sign would visibly unstretch for the duration.
   Specificity note: #screen-home #logo-img { animation: none } silences
   the sign's old tear animation -- body.first-boot adds a class, so
   this rule outweighs it while the intro runs. */
body.first-boot #screen-home #logo-img {
  animation: logo-r-shake 4.72s step-end 8.2s;
}
@keyframes logo-r-shake {
  0%    { transform: translateZ(0) scaleX(1.12) translate(0.00px, 0.00px); }
  1.5%  { transform: translateZ(0) scaleX(1.12) translate(0.72px, 0.39px); }
  3%    { transform: translateZ(0) scaleX(1.12) translate(-0.44px, 0.33px); }
  4.5%  { transform: translateZ(0) scaleX(1.12) translate(0.53px, 0.19px); }
  6%    { transform: translateZ(0) scaleX(1.12) translate(-0.61px, -0.01px); }
  7.5%  { transform: translateZ(0) scaleX(1.12) translate(-0.41px, -0.55px); }
  9%    { transform: translateZ(0) scaleX(1.12) translate(-0.44px, -0.43px); }
  10.5% { transform: translateZ(0) scaleX(1.12) translate(0.73px, 0.03px); }
  12%   { transform: translateZ(0) scaleX(1.12) translate(-0.52px, -0.27px); }
  13.5% { transform: translateZ(0) scaleX(1.12) translate(-0.40px, -0.36px); }
  15%   { transform: translateZ(0) scaleX(1.12) translate(-0.43px, -0.15px); }
  16.5% { transform: translateZ(0) scaleX(1.12) translate(0.53px, -0.53px); }
  18%   { transform: translateZ(0) scaleX(1.12) translate(0.57px, 0.42px); }
  19.5% { transform: translateZ(0) scaleX(1.12) translate(1.12px, -0.09px); }
  21%   { transform: translateZ(0) scaleX(1.12) translate(-0.79px, 1.00px); }
  22.5% { transform: translateZ(0) scaleX(1.12) translate(-1.11px, -0.51px); }
  24%   { transform: translateZ(0) scaleX(1.12) translate(0.73px, 1.19px); }
  25.5% { transform: translateZ(0) scaleX(1.12) translate(1.05px, -0.75px); }
  27%   { transform: translateZ(0) scaleX(1.12) translate(-1.13px, -1.23px); }
  28.5% { transform: translateZ(0) scaleX(1.12) translate(-1.22px, 0.28px); }
  30%   { transform: translateZ(0) scaleX(1.12) translate(-1.30px, -1.01px); }
  31.5% { transform: translateZ(0) scaleX(1.12) translate(-1.39px, 0.22px); }
  33%   { transform: translateZ(0) scaleX(1.12) translate(1.48px, -0.44px); }
  34.5% { transform: translateZ(0) scaleX(1.12) translate(-1.44px, 0.96px); }
  36%   { transform: translateZ(0) scaleX(1.12) translate(1.28px, -0.67px); }
  38.5% { transform: translateZ(0) scaleX(1.12) translate(-1.59px, -0.47px); }
  41%   { transform: translateZ(0) scaleX(1.12) translate(-2.10px, 0.91px); }
  43.5% { transform: translateZ(0) scaleX(1.12) translate(1.37px, -1.59px); }
  46%   { transform: translateZ(0) scaleX(1.12) translate(-1.39px, 1.64px); }
  48.5% { transform: translateZ(0) scaleX(1.12) translate(-1.33px, -0.57px); }
  51%   { transform: translateZ(0) scaleX(1.12) translate(-1.48px, -0.39px); }
  53.5% { transform: translateZ(0) scaleX(1.12) translate(-1.41px, -0.16px); }
  56%   { transform: translateZ(0) scaleX(1.12) translate(1.92px, -0.34px); }
  58.5% { transform: translateZ(0) scaleX(1.12) translate(1.02px, -1.30px); }
  61%   { transform: translateZ(0) scaleX(1.12) translate(-1.27px, -0.62px); }
  63.5% { transform: translateZ(0) scaleX(1.12) translate(-1.40px, 1.06px); }
  66%   { transform: translateZ(0) scaleX(1.12) translate(0.84px, 0.55px); }
  68.5% { transform: translateZ(0) scaleX(1.12) translate(1.41px, 1.00px); }
  71%   { transform: translateZ(0) scaleX(1.12) translate(0.75px, 0.12px); }
  73.5% { transform: translateZ(0) scaleX(1.12) translate(-1.22px, 0.92px); }
  76%   { transform: translateZ(0) scaleX(1.12) translate(-0.48px, 0.39px); }
  78.5% { transform: translateZ(0) scaleX(1.12) translate(-0.67px, -0.25px); }
  81%   { transform: translateZ(0) scaleX(1.12) translate(-0.59px, 0.01px); }
  83.5% { transform: translateZ(0) scaleX(1.12) translate(-0.36px, -0.35px); }
  86%   { transform: translateZ(0) scaleX(1.12) translate(0.36px, 0.21px); }
  88.5% { transform: translateZ(0) scaleX(1.12) translate(0.46px, -0.07px); }
  91%   { transform: translateZ(0) scaleX(1.12) translate(0.23px, -0.07px); }
  93.5% { transform: translateZ(0) scaleX(1.12) translate(0.11px, 0.09px); }
  96%   { transform: translateZ(0) scaleX(1.12) translate(0.19px, 0.07px); }
  98.5% { transform: translateZ(0) scaleX(1.12) translate(-0.03px, 0.01px); }
  100%  { transform: translateZ(0) scaleX(1.12) translate(0.00px, 0.00px); }
}
/* The stroke trace: every path is normalized to pathLength=1, so one dash
   rule hides them all and one keyframe draws them all -- the stroke starts
   at the path's first point and chases itself around the contour. A letter
   and its counter share the group's delay and, both being length 1, finish
   together.

   Undrawn is supposed to be invisible on dasharray/dashoffset alone, but
   that hiding depends on the browser having resolved the path's geometry
   for stroke-dash purposes -- occasionally (reported on R and the second
   S, the two latest-drawn letters) a letter flashes its raw, undashed
   outline for a frame before that resolves, instead of staying hidden.
   opacity:0 here is a second, independent hide with no geometry dependency
   at all -- lb-draw's own 0%->1% step flips it to 1 the instant the trace
   starts, so there is nothing for it to visibly do under normal
   conditions; it only matters as the fallback for whatever the dasharray
   race was doing without it.

   THE REVEAL LIVES INSIDE lb-draw, NOT A SECOND ANIMATION (the invisible-
   intro bug, do not regress this): opacity used to be flipped by its own
   tiny companion animation (lb-reveal, 0.01s step-end both, same delay).
   In current Chrome that animation finishes 10ms into the trace and its
   forwards-fill value then simply stops being APPLIED while the sibling
   lb-draw is still running on the same element -- getAnimations() reports
   it finished/fill:both with the right keyframes, but computed opacity
   stays at the base 0. Result: the ENTIRE sign traced invisibly through
   every first boot, then popped in fully formed when .first-boot dropped
   (15.5s) -- ~2s AFTER the menu faded up, which was exactly the
   first-visit report. Nobody testing as a returning account ever saw it,
   because returning accounts skip the cinematic. So: one animation owns
   both properties -- the 0%/1% pair below IS the old step-reveal, and the
   dashoffset fill (which Chrome applies fine) and opacity now share one
   fill that provably sticks. Never split a hold-after-finish property
   back out into its own short animation here. */
/* Trace starts at 5.5s: the shader settles to full frame ~4.0s and the
   static then holds ALONE for ~1.4s before the first letter (user call) -- see
   bootGlitchIntro's frame loop).

   The sequence DECELERATES on purpose: both the gap between letters and
   each letter's own draw time grow with its index (--lb 0..7), so the
   first letters snap in quick and tight and the last ones stretch out --
   the spell slowing down as it finishes rather than stamping along at
   one fixed rate. The quadratic term in the delay is what grows the
   gaps: consecutive spacing runs ~0.28s at the start up to ~0.55s at the
   end, and the draw itself grows 0.55s -> 1.18s across the sign. Last
   letter: starts ~5.0s, lands ~6.2s -- the bloom (6.3s) and everything
   after are timed off that landing. */
body.first-boot #screen-home #logo-img .lb path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
  /* UNIFORM pacing (user call, replacing the old accelerating ramp): every
     letter gets the same 0.57s spacing and the same unhurried 1.2s draw --
     "all paced pretty slow, but the whole thing is the same speed". The
     constants are the old ramp's AVERAGES, so the overall span is
     untouched: first letter still 5.5s, the closing S still starts at
     9.49s (5.5 + 7 x 0.57) and keeps its own 3.4s finale crawl below. */
  animation: lb-draw 1.2s ease-out both;
  animation-delay: calc(5.5s + var(--lb, 0) * 0.57s);
}
/* The closing S gets its own, slower clock (3.4s, vs the ~1.7s the growth
   formula gives it): it's the sign's big swoosh and the finale of the
   whole trace, so it draws at a deliberate crawl. Only the DURATION is
   overridden -- start stays at the formula's ~6.1s, so it lands ~9.5s.
   The room's fade (below) starts at this letter's MIDPOINT (7.8s), so the
   sequence reads: the S begins alone in the void, and halfway through its
   draw the world unfolds around it. Bloom/UI/dip re-timed off the 9.5s
   landing. */
body.first-boot #screen-home #logo-img .lb:last-of-type path {
  animation-duration: 3.4s;
}
/* The OPENING S gets a slower clock too (2.4s vs the uniform 1.2s): its
   path is the sign's other giant swoosh, several times the length of the
   small letters', so at the uniform duration its stroke swept visibly
   faster than everything after it. Start time untouched -- it simply
   draws while O and N begin beside it, which reads as the big wing
   unfurling under the smaller letters rather than racing them. */
body.first-boot #screen-home #logo-img .lb:first-of-type path {
  animation-duration: 2.4s;
}
/* Same reasoning for the W (5th group): a wide multi-peak zigzag whose
   path runs several small letters long -- at the uniform 1.2s its stroke
   raced. 2s keeps its px/sec in family with the two S swooshes. */
body.first-boot #screen-home #logo-img .lb:nth-of-type(5) path {
  animation-duration: 2s;
}
@keyframes lb-draw {
  /* 0% -> 1% is the old lb-reveal step (see the note above): hidden through
     the delay via backwards fill, on the moment the trace begins. 1% of
     even the slowest letter (3.4s) is 34ms -- reads as a hard step. */
  0%   { stroke-dashoffset: 1; opacity: 0; }
  1%   { stroke-dashoffset: 0.99; opacity: 1; }
  100% { stroke-dashoffset: 0; opacity: 1; }
}

/* THE ROOM ARRIVES WITH THE FINAL LETTER. During the trace the sign has a
   pure black void to itself -- #home-atmos (the haze/ceiling/floor/fog/
   dust room, a body-level layer under the screen) holds at opacity 0, and
   the base behind everything is forced to true black instead of html's
   near-black #111. Then the environment fades up across exactly the
   closing S's 2s draw (5.0 -> 7.0s): the room resolves into place as the
   sign finishes writing itself. Reuses ui-fade-in (a plain 0->1 opacity
   ramp; the 2s duration here is what makes it a slow arrival). */
/* The base is #000 through the trace, then fades to the app's normal #111
   ACROSS THE S TRANSITION (5 -> 7s), synchronized with the atmos fade
   below. It used to sit at a flat #000 until .first-boot dropped (9.6s)
   and then SNAP to #111 -- a ~7% lift across the entire screen in one
   frame, which read as the whole environment "instantly switching" out
   of a crushed, high-contrast look. Faded on the same clock as the room,
   there is nothing left for the class drop to visibly change.

   The black is a FIXED PSEUDO fading OUT, not body background-color
   fading in place: animating background-color repaints the root layer on
   the main thread every frame, and its 11.2-13.4s window is the single
   busiest stretch of the whole intro (S trace + surge + zoom + shake +
   atmos fade + the shader all live) -- re-rastering full-viewport tiles
   there is the same losing race as the hover black-band saga, and it
   showed up as flicker/lag over the last letters on a first Chrome run.
   Opacity on the pseudo is compositor-only. z-index 0, not -1: body's
   own in-flow background paints OVER negative-z positioned descendants
   (the #home-atmos lesson above); at 0 it slots by DOM order below the
   atmos and screens, which is exactly "the void behind everything". */
body.first-boot::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  background: #000;
  pointer-events: none;
  animation: boot-void-fade 2.2s ease-out 11.2s both;
}
@keyframes boot-void-fade {
  from { opacity: 1; }
  to   { opacity: 0; }
}
body.first-boot #home-atmos {
  animation: ui-fade-in 2.2s ease-out 11.2s both;
}

/* The chroma ghosts sit the WHOLE first boot out -- NO delay to tune.
   They are complete raster copies of the sign, and every attempt to
   re-introduce them mid-timeline with a hand-tuned delay eventually put
   the full grey outline back over letters still being drawn, because
   that delay had to be re-synced by hand every time the trace was
   re-paced (and a stale cached stylesheet re-broke it even when the
   numbers were right -- "every time I make it longer, it appears more").
   So there is no number here at all anymore: animations off, base
   .logo-glitch opacity 0 keeps them invisible, and they return
   AUTOMATICALLY the moment bootGlitchIntro drops .first-boot at the end
   of the timeline -- which also means this can never desync from a
   re-paced trace again, whatever the trace's numbers become. (The old
   "off forever" bug this once caused is gone for the same reason: the
   class now drops on its own timer instead of waiting for a
   navigation.) */
body.first-boot #screen-home #logo-glitch-1,
body.first-boot #screen-home #logo-glitch-2 {
  animation: none;
}


/* ==========================================================================
   FIRST BOOT: THE SIGN GETS THE ROOM TO ITSELF

   While the sign spells itself out, it is the only thing on screen -- the
   menu, both corner plates, the social row and the top bar hold at zero.
   Once the last letter lands (2.38s), the rest of the UI fades up together.

   One shared keyframe with `both` fill does all the sequencing: backwards
   fill holds everything invisible through the delay, the 0.6s ease lands
   it, forwards fill keeps it there. When main.js drops .first-boot on the
   first leave of the menu, the animation just stops applying -- resting
   opacity is what it always was, so nothing on screen moves.
   ========================================================================== */
body.first-boot #screen-home .menu-list,
body.first-boot:has(#screen-home.active) #home-profile-badge,
body.first-boot:has(#screen-home.active) #jukebox,
body.first-boot:has(#screen-home.active) #home-socials,
body.first-boot:has(#screen-home.active) #social-toggle,
body.first-boot:has(#screen-home.active) #top-bar {
  animation: ui-fade-in 0.6s ease-out 13.4s both;
}
@keyframes ui-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ==========================================================================
   PRESS START GATE (main.js: bootGlitchIntro)

   Deliberately plain and cheap to paint -- this is what's on screen while
   the shader is still compiling and the logo texture is still uploading,
   so the LESS it asks the browser to do, the sooner it can actually show
   up. No blur, no layered gradients, nothing that would itself compete
   with that setup work for the frames it's trying to protect. html's own
   #111 background (see the top of this file) already covers the instant
   before this element exists at all, so there is never a flash of
   anything else underneath.
   ========================================================================== */
#boot-gate {
  position: fixed;
  inset: 0;
  z-index: 9500; /* above every other layer this app has, on purpose */
  background: #000;
  display: flex;
  /* Column, so the loader and the button each sit dead-centre alone --
     as a row they shared the axis and PRESS START drifted off-centre. */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* The cover is NOT the button -- only the PRESS START text is. */
  cursor: default;
  opacity: 1;
  transition: opacity 0.3s ease;
}
#boot-gate-prompt {
  /* A real <button>, stripped of its chrome -- padding kept generous so
     the hit target is comfortably bigger than the glyphs alone. */
  background: none;
  border: none;
  padding: 18px 34px;
  cursor: pointer;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(22px, calc(2.4 * var(--dvw, 1vw)), 40px);
  letter-spacing: 6px;
  color: rgba(134, 255, 102, 0.85);
  /* Tight core plus a WIDE bloom: the words are the only light source in
     the room, and a lamp throws light past its own glass. Bright numbers
     on purpose -- the breath below spends most of its cycle far from
     peak, so the peak has to be worth arriving at. */
  text-shadow:
    0 0 16px rgba(134, 255, 102, 0.75),
    0 0 60px rgba(57, 255, 20, 0.5),
    0 0 140px rgba(57, 255, 20, 0.3);
  animation: boot-gate-pulse 4.6s ease-in-out infinite;
  position: relative;
}
/* The light POOL: a big soft radial wash the text throws into the black
   around it. A pseudo of the button, so the pulse animation's opacity
   breathes the whole pool with the glyphs -- one light, one flicker. */
#boot-gate-prompt::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 130vmin;
  aspect-ratio: 1;
  transform: translate(-50%, -50%);
  pointer-events: none;
  background: radial-gradient(circle,
    rgba(57, 255, 20, 0.16),
    rgba(57, 255, 20, 0.06) 38%,
    transparent 66%);
}
/* While the preloader owns the screen the button doesn't exist at all --
   the bar IS the gate until everything is warm, then they swap. */
#boot-gate-prompt:disabled { display: none; }
/* While the rig question is up PRESS START stays hidden even once the
   preloader opens the gate -- answer first, then the door. */
#boot-gate.rig-ask #boot-gate-prompt { display: none; }
/* ---- the rig question (first boot only; main.js builds it) ----
   Same discipline as the rest of the gate: plain and cheap to paint.
   Flat colour, hairline borders, one modest text-shadow -- nothing that
   competes with the shader warmup happening behind it. */
#boot-rig {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  margin-bottom: 30px;
  transition: opacity 0.3s ease;
}
#boot-rig-q {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(16px, calc(1.6 * var(--dvw, 1vw)), 26px);
  letter-spacing: 5px;
  color: rgba(134, 255, 102, 0.85);
  text-shadow: 0 0 14px rgba(57, 255, 20, 0.5);
  margin-bottom: 8px;
}
.boot-rig-btn {
  background: none;
  border: 1px solid rgba(57, 255, 20, 0.4);
  padding: 12px 26px;
  min-width: min(360px, 80vw);
  cursor: pointer;
  font-family: 'Courier New', monospace;
  font-size: clamp(13px, calc(1 * var(--dvw, 1vw)), 17px);
  letter-spacing: 3px;
  color: rgba(134, 255, 102, 0.8);
  transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
}
.boot-rig-btn:hover,
.boot-rig-btn:focus-visible {
  border-color: rgba(134, 255, 102, 0.9);
  color: rgb(134, 255, 102);
  background: rgba(57, 255, 20, 0.08);
  text-shadow: 0 0 10px rgba(57, 255, 20, 0.6);
}
.boot-rig-sub {
  display: block;
  margin-top: 4px;
  font-size: 0.72em;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.45);
}
#boot-rig-note {
  font-family: 'Courier New', monospace;
  font-size: 11px;
  letter-spacing: 2px;
  color: rgba(134, 255, 102, 0.35);
  margin-top: 6px;
}
#boot-gate-prompt:hover,
#boot-gate-prompt:focus-visible {
  color: rgb(134, 255, 102);
  text-shadow: 0 0 22px rgba(134, 255, 102, 0.8);
}
/* The lamp BREATHES, slow and from black: each cycle starts as near
   nothing -- a room you'd believe is empty -- and swells over seconds to
   full intensity before sinking back. ease-in-out keeps both ends of the
   swell unhurried. */
@keyframes boot-gate-pulse {
  0%, 100% { opacity: 0.04; }
  55% { opacity: 1; }
}
/* The intro is a cutscene: no cursor from the PRESS START click until the
   menu UI fades up (main.js adds this at begin() and removes it at 10s,
   the ui-fade-in beat -- deliberately NOT tied to .first-boot, which
   outlives the moment the cursor is needed again). !important because
   plenty of controls set their own cursor: pointer. */
body.boot-nocursor,
body.boot-nocursor * { cursor: none !important; }
@media (prefers-reduced-motion: reduce) {
  #boot-gate-prompt { animation: none; }
}

/* ==========================================================================
   FOG ROLLS THROUGH THE MENU ROOM

   The lobby fog is a static band that hides the horizon seam; this adds
   MOVING weather on top of it, home only. Two banks of mist drift through
   the middle distance in opposite directions at different speeds -- a
   bright wisp layer and a darker, slower mass behind it -- so their
   overlap never repeats and the room reads as having air in it.

   Same discipline as the dust rebuild: each bank is one oversized layer
   whose background TILES, animated by transform through exactly one tile
   width -- the end frame is pixel-identical to the start, so the loop has
   no visible reset, and transform keeps the whole effect on the
   compositor (no per-frame repaints). Oversized by one full travel so an
   edge never enters the frame mid-loop; #home-atmos's overflow:hidden
   swallows the excess. Scoped to the home clone -- the lobby's own
   backdrop is untouched.
   ========================================================================== */
#home-atmos .mp-atmos-fog::before,
#home-atmos .mp-atmos-fog::after {
  content: '';
  position: absolute;
  top: -60%;
  height: 220%;
  pointer-events: none;
}
#home-atmos .mp-atmos-fog::before {
  left: -1400px;
  right: 0;
  background-image: radial-gradient(ellipse 45% 55% at 50% 52%,
    rgba(110, 220, 120, 0.07), transparent 72%);
  background-size: 1400px 100%;
  background-repeat: repeat-x;
  animation: home-fog-roll-a 75s linear infinite;
}
#home-atmos .mp-atmos-fog::after {
  left: 0;
  right: -2300px;
  /* The dark bank's ellipse must finish its fade INSIDE the box (the
     ::before bank always did): at the old 65%-radius / 46%-centre the box
     top sat at 70.8% of the radius -- before the 75% stop -- so the
     gradient was CUT with alpha still on it, printing a hard horizontal
     seam across the room at 17.4% of screen height. That seam is the
     "black band" reported over the sign: static, width-bounded by the
     lobe, drifting with this layer's 115s roll (which is why it seemed to
     come and go). 60%-radius / 50%-centre puts every box edge past the
     75% stop, so the fade reaches zero before the cut. */
  background-image: radial-gradient(ellipse 55% 60% at 50% 50%,
    rgba(30, 110, 55, 0.11), transparent 75%);
  background-size: 2300px 100%;
  background-repeat: repeat-x;
  animation: home-fog-roll-b 115s linear infinite;
}
@keyframes home-fog-roll-a { to { transform: translateX(1400px); } }
@keyframes home-fog-roll-b { to { transform: translateX(-2300px); } }



/* The resolution swell: one breath of bloom when the sign completes. The
   hue-rotate must ride along in every frame -- this animation temporarily
   owns the wrap's filter, and dropping the rotation would snap the sign
   back to authored green for a second. */
@keyframes logo-boot-bloom {
  0% {
    filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1);
  }
  30% {
    filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1.65)
            drop-shadow(0 0 34px rgba(57, 255, 20, 0.7));
  }
  100% {
    filter: hue-rotate(var(--logo-hue, 0deg)) brightness(1);
  }
}

/* ==========================================================================
   THE TRACE LANDINGS' DOM SIDE (see bootTraceLandings in main.js)

   The letter's SEAL FLASH -- a one-beat neon stutter on the group the
   instant its last path closes (opacity is free on the group; the paths'
   own opacity is held by lb-draw's fill) -- and the room glow the
   landings pulse. The glow div sits directly after #home-atmos in the DOM
   with no z-index, so the screens that follow it paint above it by plain
   DOM order; its OPACITY is owned entirely by WAAPI pulses from main.js
   (rests at 0, so it is invisible any time the module isn't driving it).
   ========================================================================== */
body.first-boot #screen-home #logo-img .lb.lb-sealed {
  animation: lb-seal-flash 0.16s step-end;
}
@keyframes lb-seal-flash {
  0%  { opacity: 1; }
  40% { opacity: 0.55; }
  70% { opacity: 1; }
}
#boot-roomglow {
  position: fixed;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  /* Centered on the sign (top third), not the screen -- the light has to
     read as thrown by the letters. */
  background: radial-gradient(ellipse 62% 46% at 50% 26%,
    rgba(57, 255, 20, 0.55), transparent 72%);
}

/* ==========================================================================
   THE GATE IS A PRELOADER (see the boot gate setup in main.js)

   PRESS START holds as a disabled LOADING until the intro's dependencies
   (shader texture, static sfx, fonts) are warm -- so the press is a
   promise the intro can keep on a cold first visit. The bar under the
   button fills per dependency; both it and the disabled state are plain
   and quiet on purpose: this screen is a black void with two words on it,
   and a busy loader would be a bigger event than the loading.
   ========================================================================== */
#boot-gate-load {
  width: min(280px, 50vw);
  height: 3px;
  background: rgba(201, 255, 214, 0.12);
  transition: opacity 0.4s ease;
  box-shadow: 0 0 24px rgba(57, 255, 20, 0.12);
}
#boot-gate-load > div {
  width: 0%;
  height: 100%;
  background: #c9ffd6;
  box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
  transition: width 0.3s ease;
}

/* ==========================================================================
   THE CORNER PLATES: ONLY THE OUTLINE GLOWS

   The neon lives in the plate's BAND, nothing else. Two moves:

   1. The hosts' drop-shadow glow comes OFF #home-profile-badge / #jukebox
      and moves onto #hpb-plate / #jb-shell. On the host, drop-shadow
      blooms around every opaque child -- and with the plates hollow now,
      that lit the icon frame, the name, the XP bar and the volume line
      like their own little signs. On the shell it blooms off the band
      alone (drop-shadow, not box-shadow: the shells are clipped, and clip
      erases box-shadow but not filters). hue-rotate STAYS on the hosts so
      everything inside still cycles together.

   2. Every inner element's own glow goes dark: shadows off, the linework
      and text carry themselves. Hover keeps its pop (transform lives on
      the base rules untouched) with the heat going into the BAND, plus
      the house extrusion step on the shell instead of the host.
   ========================================================================== */
#home-profile-badge,
#home-profile-badge:hover {
  filter: hue-rotate(var(--logo-hue));
}
#hpb-plate {
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
}
#home-profile-badge:hover #hpb-plate {
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(var(--sw-glow-core-hot))
    drop-shadow(var(--sw-glow-bloom-hot));
}
#home-profile-badge .player-icon,
#home-profile-badge:hover .player-icon { box-shadow: none; }
#hpb-text,
#home-profile-badge:hover #hpb-text { text-shadow: none; }
#hpb-xp-bar,
#home-profile-badge:hover #hpb-xp-bar,
#hpb-xp-bar::before,
#hpb-xp-bar::after,
#hpb-xp-fill { box-shadow: none; }

#jukebox,
#jukebox:hover {
  filter: hue-rotate(var(--logo-hue));
}
#jb-shell {
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
}
#jukebox:hover #jb-shell {
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(var(--sw-glow-core-hot))
    drop-shadow(var(--sw-glow-bloom-hot));
}
#jb-disc,
#jukebox:hover #jb-disc,
#jukebox:focus-within #jb-disc { box-shadow: none; }
#jb-disc-label { box-shadow: none; }
#jb-title,
#jukebox:hover #jb-title { text-shadow: none; }
#jb-vol-bar,
#jukebox:hover #jb-vol-bar,
#jb-vol-bar::before,
#jb-vol-bar::after { box-shadow: none; }

/* ==========================================================================
   THE SOCIAL ROW JOINS THE HOUSE GLOW

   The last holdout: these buttons still wore a four-layer box-shadow from
   an earlier pass -- a different, foggier light than everything around
   them. Now the ring (::before, the outline that IS the button) blooms
   with the same two tokens as the sign, the menu words and the plates.
   Glow on the RING's silhouette, not the host box: box-shadow on the host
   is rectangle-shaped light, and the glyph stays unlit like every other
   interior. Hover heats the same tokens, same as everywhere.
   ========================================================================== */
.hs-link:not(.hs-dead),
.hs-link:not(.hs-dead):hover {
  box-shadow: none;
}
.hs-link:not(.hs-dead)::before {
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
}
.hs-link:not(.hs-dead):hover::before {
  filter:
    drop-shadow(2px 2px 0 rgba(24, 140, 10, 0.9))
    drop-shadow(var(--sw-glow-core-hot))
    drop-shadow(var(--sw-glow-bloom-hot));
}
.hs-link:not(.hs-dead) svg { filter: drop-shadow(0 0 5px rgba(57, 255, 20, 0.75)); }
.hs-link:not(.hs-dead):hover svg { filter: drop-shadow(0 0 7px rgba(57, 255, 20, 1)); }

/* ==========================================================================
   THE BANDS GLOW AGAIN -- clip and filter must live on DIFFERENT elements

   Moving the glow onto #hpb-plate / #jb-shell silently killed it: filter
   is applied BEFORE clip-path on the same element, so the shells' own
   evenodd ring clip sliced away the bloom the drop-shadow had just
   painted. (The computed style still showed the filter -- the pixels were
   the casualty.)

   So the construction splits: the ring's PAINT (white fill + evenodd
   clip) moves down onto a ::before, and the shell element itself -- now
   unclipped -- keeps the token filter. Child clips first, parent blooms
   off the clipped result: glow off the band alone, exactly the original
   intent. The --hpb-break / --jb-* vars the polygons read inherit from
   the hosts, so the pseudos resolve them the same as the shells did.
   ========================================================================== */
#hpb-plate,
#jb-shell {
  background: none;
  clip-path: none;
}
/* NO extra filter hint on the hosts here. One was briefly added for the
   Firefox white-band report (plates rendering as pure halo-colored rings,
   no white core, on Windows 125%/150% display scaling): once the pinned
   texture rasterized wrong during menu entry, hue writes -- pure filter-
   matrix updates over the cached raster -- could never heal it. Wrong
   medicine: in Gecko will-change:filter is the POISON (it clips filter
   output at the layer boundary -- see the @supports (-moz-appearance:
   none) revert block and CLAUDE.md), and the hosts already carry the
   Chrome hint in THE MENU'S FILTERED SURFACES block, which that revert
   strips for Firefox. An unscoped duplicate HERE, after the revert in the
   cascade, would quietly re-pin the layer in Firefox and bring the band
   loss back. */
#hpb-plate::before,
#jb-shell::before {
  content: '';
  position: absolute;
  inset: 0;
  background: #fff;
}
#hpb-plate::before {
  clip-path: polygon(evenodd,
    0 0,
    var(--hpb-break, 230px) 0,
    calc(var(--hpb-break, 230px) + 56px) 44px,
    100% 44px,
    calc(100% - 14px) 100%,
    0 100%,
    0 0,
    3px 3px,
    calc(var(--hpb-break, 230px) - 1px) 3px,
    calc(var(--hpb-break, 230px) + 54px) 44px,
    calc(100% - 3px) 44px,
    calc(100% - 16px) calc(100% - 3px),
    3px calc(100% - 3px),
    3px 3px
  );
}
#jb-shell::before {
  clip-path: polygon(evenodd,
    0 var(--jb-step),
    var(--jb-diag-start) var(--jb-step),
    var(--jb-diag-end) 0,
    100% 0,
    100% calc(100% - 14px),
    calc(100% - 14px) 100%,
    0 100%,
    0 var(--jb-step),
    3px calc(var(--jb-step) + 1px),
    calc(var(--jb-diag-start) - 1px) calc(var(--jb-step) + 1px),
    calc(var(--jb-diag-end) - 1px) 3px,
    calc(100% - 3px) 3px,
    calc(100% - 3px) calc(100% - 17px),
    calc(100% - 17px) calc(100% - 3px),
    3px calc(100% - 3px),
    3px calc(var(--jb-step) + 1px)
  );
}

/* ==========================================================================
   BIGGER SOCIALS

   The bare glyphs read too timid at 21px next to the plates. Icons go up
   to 30px; the (invisible) host square grows with them so the hit target
   keeps pace and the row's spacing still comes from the buttons, not the
   art.
   ========================================================================== */
.hs-link { width: 54px; height: 54px; }
.hs-link svg { width: 30px; height: 30px; }

/* ==========================================================================
   MODE PICKER, REMASTERED: QUIET TILES

   Owner call: same features, none of the theatre. The picker had grown a
   deal-in stagger, a springy five-shadow hover pop, icon spins, animated
   EQ bars, a rotating vinyl, a cycling roulette strip, circuit-grid
   wallpaper and holo captions -- each fine alone, deafening together.

   The remaster keeps every feature (selection flag, live counts, locked
   state, teasers, keyboard) and swaps the presentation: flat plates, one
   hairline, one static glyph per mode drawn as an SVG mask so it wears
   the family accent, and a hover that lifts 3px and brightens the edge.
   Nothing on this panel moves unless the player moves it.
   ========================================================================== */
/* the six-bar art rig retires, and with it every per-mode animation zoo */
#mp-modal-grid .mp-art { display: none !important; }
#mp-modal-grid .mp-tile-art::before { content: none !important; }

/* tiles: flat plate, hairline, the house cut -- and no deal-in stagger;
   the modal's own fade is the entrance */
.mp-tile {
  animation: none;
  background: linear-gradient(180deg, rgba(10, 18, 12, 0.96), rgba(6, 11, 7, 0.96));
  border: 1px solid rgba(150, 255, 180, 0.14);
  transition: transform 0.16s ease-out, border-color 0.16s ease-out, filter 0.16s ease-out;
}
.mp-tile:hover,
.mp-tile:focus-visible {
  transform: translateY(-3px);
  border-color: var(--mp-accent);
  filter:
    drop-shadow(0 10px 22px rgba(0, 0, 0, 0.5))
    drop-shadow(0 0 14px var(--mp-accent-dim, rgba(120, 255, 160, 0.25)));
}
.mp-tile:active { transform: translateY(-1px); }
.mp-tile:hover .mp-tile-art i,
.mp-tile:hover .mp-tile-art::after { transform: none; }

/* art plate: flat and dark, one hairline -- the grid wallpaper goes */
.mp-tile-art,
.mp-tile-on .mp-tile-art {
  background: rgba(4, 8, 5, 0.85);
  border-bottom: 1px solid rgba(150, 255, 180, 0.1);
}

/* one static glyph per live mode, masked so it wears the family accent.
   Scoped to [data-gamemode] tiles only -- the SOON teasers already carry
   their own inline icons and must not grow a second one. */
.mp-tile[data-gamemode] .mp-tile-art::after {
  content: '';
  /* Explicit neutral geometry: the retired roulette pointer styled this
     same ::after (absolute, left 50%, translateX(-50%), border triangle),
     and what leaked through made the roulette glyph sit off-grid and JUMP
     sideways on hover when the transform got cleared. Every property the
     pointer set is reset here, not trusted to cascade away. */
  position: static;
  top: auto; left: auto;
  transform: none;
  border: 0;
  filter: none;
  width: clamp(56px, calc(6 * var(--dvw, 1vw)), 84px);
  height: clamp(56px, calc(6 * var(--dvw, 1vw)), 84px);
  background: var(--mp-accent, #9fdb95);
  opacity: 0.92;
  -webkit-mask: var(--mp-icon) center / contain no-repeat;
  mask: var(--mp-icon) center / contain no-repeat;
}
.mp-tile[data-gamemode="beat_wars"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='white'><rect x='10' y='38' width='10' height='20' rx='5'/><rect x='27' y='26' width='10' height='44' rx='5'/><rect x='44' y='14' width='10' height='68' rx='5'/><rect x='61' y='30' width='10' height='36' rx='5'/><rect x='78' y='40' width='10' height='16' rx='5'/></g></svg>"); }
.mp-tile[data-gamemode="sample_flip"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='none' stroke='white' stroke-width='7'><circle cx='48' cy='48' r='36'/><path d='M48 26a22 22 0 0 1 22 22'/></g><circle cx='48' cy='48' r='7' fill='white'/></svg>"); }
.mp-tile[data-gamemode="genre_roulette"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='none' stroke='white' stroke-width='7'><circle cx='48' cy='48' r='36'/><path d='M48 12v18M79 30 66 41M79 66 66 55M48 84V66M17 66l13-11M17 30l13 11'/></g><circle cx='48' cy='48' r='7' fill='white'/></svg>"); }
.mp-tile[data-gamemode="word_play"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='none' stroke='white' stroke-width='7'><rect x='36' y='10' width='24' height='42' rx='12'/><path d='M22 44a26 26 0 0 0 52 0M48 70v16M34 86h28'/></g></svg>"); }
/* SONG WARS: a finished song, so the glyph is the OBJECT a song arrives on
   rather than the act of making one -- a cassette. Deliberately not another
   waveform: BEAT SHOWCASE already owns bars, and the two tiles that mean
   "bring the thing you already made" must not read as the same tile in two
   colours. */
.mp-tile[data-gamemode="song_wars"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='none' stroke='white' stroke-width='7'><rect x='8' y='22' width='80' height='52' rx='9'/><circle cx='34' cy='46' r='9'/><circle cx='62' cy='46' r='9'/><path d='M27 74l6-13h30l6 13'/></g></svg>"); }
/* TOPIC ROULETTE: a subject, not a sound -- so a speech bubble, not the
   wheel. The pairing with GENRE ROULETTE is carried by the NAME; giving it
   the same glyph in a different accent would make two tiles that have to be
   told apart by colour alone, which is the one thing tile art exists to
   prevent. The three dots read at any size, which is what the frames' 40px
   law says about fine detail. */
.mp-tile[data-gamemode="topic_roulette"] { --mp-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'><g fill='none' stroke='white' stroke-width='7'><path d='M48 16c-19 0-34 12-34 27 0 15 15 27 34 27h3l17 12-4-16c11-5 18-14 18-23 0-15-15-27-34-27z'/></g><g fill='white'><circle cx='34' cy='43' r='5'/><circle cx='48' cy='43' r='5'/><circle cx='62' cy='43' r='5'/></g></svg>"); }

/* selection: the accent edge and the tag -- no slide-in */
.mp-tile-flag { transform: none; transition: opacity 0.15s ease-out; }
.mp-tile-on { background: linear-gradient(180deg, rgba(14, 24, 16, 0.97), rgba(7, 13, 9, 0.97)); }

@media (prefers-reduced-motion: reduce) {
  .mp-tile, .mp-tile:hover, .mp-tile:focus-visible { transition: none; transform: none; }
}


/* ==========================================================================
   A TICKED MODE HAS TO LOOK TICKED

   Multi-select broke the old treatment. When exactly one tile could ever be
   chosen, a slightly lighter plate plus a corner tag was enough -- there was
   nothing to compare it against. Now that three can be on at once, "slightly
   lighter" is invisible: the panel reads as though nothing is selected, and
   the one thing the player has to be sure of before pressing QUICK PLAY is
   which modes they are about to queue for.

   So a ticked tile leaves the plane of the grid: scaled up, ringed in the
   family accent, haloed, and carrying a filled tick box.

   THE HALO NEEDS THE WRAPPER (.mp-cell, built by mpCell in main.js).
   .mp-tile carries the house notch as a clip-path, and on a single element
   the filter is computed BEFORE the clip -- so a drop-shadow put here would
   be sliced off at the tile's own edges, which is exactly the trap CLAUDE.md
   records from the menu sign. The cell is unclipped; the glow lives there.
   ========================================================================== */
.mp-cell {
  display: flex;
  min-width: 0;
  /* Stacking context, so a scaled-up tile sits OVER its neighbours instead
     of being overlapped by whichever happens to paint later. */
  position: relative;
  transition: filter 0.18s ease-out;
}
.mp-cell > .mp-tile { flex: 1 1 auto; width: 100%; }

/* The halo. Two shadows on purpose: a tight core that draws the edge, and a
   wide bloom that reads as light in the room -- the same core+bloom pairing
   the house glow uses, in the family accent rather than the menu green. */
.mp-cell:has(.mp-tile-on) {
  z-index: 2;
  filter:
    drop-shadow(0 0 6px var(--mp-accent-soft, rgba(120, 255, 160, 0.55)))
    drop-shadow(0 0 22px var(--mp-accent-dim, rgba(120, 255, 160, 0.3)));
}

/* The lift. transform, never width/height: a selected tile must not reflow
   the grid, or ticking one would shove the other two around. */
.mp-tile-on,
.mp-tile-on:hover,
.mp-tile-on:focus-visible {
  transform: scale(1.05);
  border-color: var(--mp-accent);
}
.mp-tile-on:hover,
.mp-tile-on:focus-visible { transform: scale(1.05) translateY(-3px); }
.mp-tile-on:active { transform: scale(1.03); }
/* Hover on a ticked tile must not re-issue its own drop-shadow -- that is
   the clipped kind, and it would fight the cell's. The cell already glows. */
.mp-tile-on:hover, .mp-tile-on:focus-visible { filter: none; }

/* ---- the tick box ----
   Top LEFT, which is the square corner: the notch is cut top-right and the
   SELECTED tag lives in it. Purely an indicator -- see the note in
   mpModalRender on why it is not a real checkbox. */
.mp-tile-check {
  position: absolute;
  top: 12px;
  left: 12px;
  z-index: 3;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: 2px solid rgba(150, 255, 180, 0.32);
  background: rgba(3, 8, 4, 0.82);
  transition: background-color 0.16s ease-out, border-color 0.16s ease-out;
}
.mp-tile:hover .mp-tile-check { border-color: var(--mp-accent); }
/* The tick: two borders of an empty box, rotated. Cheaper than an SVG and it
   inherits nothing that could drift. Dark ink on the accent fill, the same
   pairing .mp-tile-live already uses. */
.mp-tile-check::after {
  content: '';
  width: 11px;
  height: 6px;
  margin-top: -4px;
  border-left: 3px solid #06120a;
  border-bottom: 3px solid #06120a;
  opacity: 0;
  transform: rotate(-45deg) scale(0.4);
  transition: opacity 0.1s ease-out, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mp-tile-on .mp-tile-check {
  background: var(--mp-accent);
  border-color: var(--mp-accent);
}
.mp-tile-on .mp-tile-check::after { opacity: 1; transform: rotate(-45deg) scale(1); }
/* A teaser is not a mode you can tick, so it gets no box to tick. */
.mp-tile-teaser .mp-tile-check { display: none; }

/* Glow Bloom off (SETTINGS > VISUALS): keep the core, drop the wide halo --
   the same trade the house tokens make, so a weak machine still sees which
   tiles are on. */
body.sw-no-bloom .mp-cell:has(.mp-tile-on) {
  filter: drop-shadow(0 0 4px var(--mp-accent-soft, rgba(120, 255, 160, 0.55)));
}

/* Reduced motion keeps the SIZE -- it is a state, not an animation -- and
   drops the springs. */
@media (prefers-reduced-motion: reduce) {
  .mp-cell { transition: none; }
  .mp-tile-on, .mp-tile-on:hover, .mp-tile-on:focus-visible { transform: scale(1.05); }
  .mp-tile-check, .mp-tile-check::after { transition: none; }
}


/* ==========================================================================
   AVATAR FRAMES  //  アバター枠
   ==========================================================================
   The plate a player wears around their picture, everywhere a picture is
   drawn: the lobby roster, the party rail, the friends list, the mini
   profile card, your own profile screen and the home badge. Bought in the
   store, equipped from the profile inventory's FRAME tab, and carried to
   every other client on the roster payload (see `frame` in Lobby.js's
   toSharedState) -- so these class names come from a CLOSED list
   server-side (FRAMES in server/profile.js). A frame id arrives from other
   players; it must never be able to name a class it wasn't meant to.

   ONE RING, REUSED BY EVERY LAYER. --fr-ring is a single polygon that
   traces the outer contour clockwise, seams across, then traces the inner
   contour counter-clockwise -- a ring from one path, with the default
   nonzero fill rule and no mask-composite anywhere. That is the whole
   geometry budget: every frame below is the same two divs, differing only
   in what paints through them. It is SQUARE, and the note on --fr-ring
   says why: a frame goes around a photograph, and any corner cut in the
   band is a corner cut out of somebody's face.

   FILTER AND CLIP LIVE ON DIFFERENT ELEMENTS, always. A filter applies
   BEFORE clip-path on the same element, so a self-clipped plate slices off
   its own glow (the same rule #hpb-plate and #jb-shell were written
   against). So: drop-shadow on the wrapper, clip-path on its children.

   NOTHING ANIMATES A FILTER, AND NOTHING ANIMATES A CLIPPED ELEMENT. Three
   of the four frames move (SHEEN's rotating highlight, TICKS' crawl,
   MISREGISTER's drifting plates) and all three animate ONLY `transform`, on
   an element that is NOT the one carrying the clip-path -- a pseudo-element
   inside the clipped band for the first two, the wrapper's own pseudos for
   the third. That keeps every frame's motion compositor work over a cached
   texture: nothing re-rasterises, nothing re-runs a filter, and the chamfer
   never moves. Animate the clipped element itself and its ring turns with
   it, which reads as the plate wobbling out of true.

   SETTINGS > VISUALS > Avatar Frames collapses the lot to a flat one-pixel
   plate for weak machines; the frame stays visible (it says who owns what)
   and stops costing anything -- including every animation above, since the
   switch hides the elements they run on.
   ========================================================================== */

:root {
  /* THE RING IS SQUARE. It used to chamfer top-left / bottom-right, to
     rhyme with the angular HUD plates #pv-pfp and #profile-preview-box are
     drawn as, and it was wrong for one reason that outweighs the rhyme: a
     frame goes around a PHOTOGRAPH. Chamfering the band means chamfering
     the picture with it, so the two corners the eye lands on first each
     lost a triangle of somebody's face -- the frame eating the thing it is
     supposed to present. Bevel the chrome all you like; never bevel the
     content. (Both attempts to have it both ways failed the same way: a
     square photo inside a chamfered ring pokes its corner out past the
     band, and a photo clipped to match loses the corner. There is no third
     option -- the shape has to leave the picture alone.)
     Still ONE polygon: outer contour clockwise, seam, inner contour
     counter-clockwise, hollow by nonzero winding. Reverse either and the
     ring fills in solid over the player's face. */
  /* THE HOUSE RING: a plain square band. It is the FALLBACK, not the
     uniform -- every frame that ships overrides --fr-ring on itself with
     one of the silhouettes below, because the outline is half of what
     tells two plates apart at 40px, where the material barely reads.
     ALL FOUR VARY THE OUTER CONTOUR AND NONE TOUCHES THE INNER ONE, which
     stays this same 9% square everywhere: outer-edge geometry costs the
     picture nothing, inner-edge geometry is paid for in somebody's face
     (see the chamfer that had to be taken back out). */
  --fr-ring: polygon(
    0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* TICKS -- a staircase into each corner, two steps deep. The frame is a
     meter, so its outline is machined: right angles only, all four corners
     identical, nothing organic. Steps bottom out at 7%, inside the 9%
     band, so the ring never pinches through. */
  --fr-ring-steps: polygon(
    14% 0%, 86% 0%, 86% 2.5%, 93% 2.5%, 93% 7%, 97.5% 7%, 97.5% 14%, 100% 14%,
    100% 86%, 97.5% 86%, 97.5% 93%, 93% 93%, 93% 97.5%, 86% 97.5%, 86% 100%,
    14% 100%, 14% 97.5%, 7% 97.5%, 7% 93%, 2.5% 93%, 2.5% 86%, 0% 86%,
    0% 14%, 2.5% 14%, 2.5% 7%, 7% 7%, 7% 2.5%, 14% 2.5%, 14% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* SLAB -- the bevelled plate, and the ONE place the old chamfer idea is
     actually safe: cut all four corners at 45 degrees on the OUTSIDE while
     the hole stays square, and you get the machined-block silhouette
     without taking a bite out of the photograph. 12% leaves ~4% of band
     across the diagonal, so the corner reads bevelled rather than clipped
     off. */
  --fr-ring-bevel: polygon(
    12% 0%, 88% 0%, 100% 12%, 100% 88%, 88% 100%, 12% 100%, 0% 88%, 0% 12%, 12% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* HAZARD TAPE -- cut across ONE diagonal only (top-right and
     bottom-left), which is the opposite pair to nothing else on the shelf
     and reads as tape torn off at an angle. SLAB bevels all four and stays
     symmetrical; this one is deliberately lopsided. */
  --fr-ring-tear: polygon(
    0% 0%, 82% 0%, 100% 18%, 100% 100%, 18% 100%, 0% 82%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* BAD COPY -- a torn edge, and the only silhouette here with NO repeating
     unit at all. Every other plate is machined, stamped or cut on a period;
     this one is eaten away at irregular depths (2-6%) and irregular widths,
     because the whole reference is a photocopy of a photocopy: nothing in
     it survived the third pass cleanly. Regularise these numbers and it
     immediately reads as a decorative border again. */
  --fr-ring-torn: polygon(
    0% 2%, 9% 0%, 17% 4%, 28% 1%, 34% 5%, 46% 0%, 53% 3%, 64% 0%, 71% 4%, 83% 1%, 92% 5%, 100% 0%,
    98% 11%, 100% 19%, 96% 28%, 100% 39%, 97% 48%, 100% 57%, 95% 66%, 100% 74%, 97% 84%, 100% 93%,
    91% 100%, 84% 96%, 73% 100%, 66% 95%, 55% 100%, 47% 96%, 36% 100%, 27% 95%, 16% 100%, 8% 96%, 0% 100%,
    3% 91%, 0% 82%, 4% 73%, 0% 63%, 5% 54%, 0% 45%, 3% 36%, 0% 27%, 4% 17%, 0% 9%, 0% 2%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* ICED OUT -- milgrain. A fine bead all the way round: many small teeth
     (2% deep on a 6.25% period) rather than a few big ones, which is what
     a stone-set edge actually looks like and what stops the outline
     competing with the pavé inside it. Shallowest silhouette on the shelf
     on purpose -- this plate's interest is its SURFACE. */
  --fr-ring-bead: polygon(
    0% 0%, 3.1% 2%, 6.2% 0%, 9.3% 2%, 12.5% 0%, 15.6% 2%, 18.7% 0%, 21.8% 2%, 25% 0%,
    28.1% 2%, 31.2% 0%, 34.3% 2%, 37.5% 0%, 40.6% 2%, 43.7% 0%, 46.8% 2%, 50% 0%,
    53.1% 2%, 56.2% 0%, 59.3% 2%, 62.5% 0%, 65.6% 2%, 68.7% 0%, 71.8% 2%, 75% 0%,
    78.1% 2%, 81.2% 0%, 84.3% 2%, 87.5% 0%, 90.6% 2%, 93.7% 0%, 96.8% 2%, 100% 0%,
    98% 6.2%, 100% 12.5%, 98% 18.7%, 100% 25%, 98% 31.2%, 100% 37.5%, 98% 43.7%,
    100% 50%, 98% 56.2%, 100% 62.5%, 98% 68.7%, 100% 75%, 98% 81.2%, 100% 87.5%,
    98% 93.7%, 100% 100%,
    93.7% 98%, 87.5% 100%, 81.2% 98%, 75% 100%, 68.7% 98%, 62.5% 100%, 56.2% 98%,
    50% 100%, 43.7% 98%, 37.5% 100%, 31.2% 98%, 25% 100%, 18.7% 98%, 12.5% 100%,
    6.2% 98%, 0% 100%,
    2% 93.7%, 0% 87.5%, 2% 81.2%, 0% 75%, 2% 68.7%, 0% 62.5%, 2% 56.2%, 0% 50%,
    2% 43.7%, 0% 37.5%, 2% 31.2%, 0% 25%, 2% 18.7%, 0% 12.5%, 2% 6.2%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* SONAR -- a soft octagon: 7% corner cuts, gentler than SLAB's 12% bevel
     so the two never read as siblings. The closest a square chassis gets
     to a round scope. */
  --fr-ring-oct: polygon(
    7% 0%, 93% 0%, 100% 7%, 100% 93%, 93% 100%, 7% 100%, 0% 93%, 0% 7%, 7% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* MARQUEE -- corner blocks: the sides inset 3.5% and the corners stay
     full, so four chunky mounting blocks stand proud of a recessed board,
     the way a real bulb sign is bolted up. */
  --fr-ring-blocks: polygon(
    0% 0%, 14% 0%, 14% 3.5%, 86% 3.5%, 86% 0%, 100% 0%,
    100% 14%, 96.5% 14%, 96.5% 86%, 100% 86%, 100% 100%,
    86% 100%, 86% 96.5%, 14% 96.5%, 14% 100%, 0% 100%,
    0% 86%, 3.5% 86%, 3.5% 14%, 0% 14%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* LEVELS -- the outline IS an equaliser: the top and bottom outer edges
     step at irregular heights, eight bars a side, no two alike and the two
     edges deliberately not mirrored. */
  --fr-ring-eq: polygon(
    0% 3%, 12.5% 3%, 12.5% 0.5%, 25% 0.5%, 25% 4%, 37.5% 4%, 37.5% 1.5%,
    50% 1.5%, 50% 3.5%, 62.5% 3.5%, 62.5% 0%, 75% 0%, 75% 2.5%,
    87.5% 2.5%, 87.5% 1%, 100% 1%,
    100% 98%, 87.5% 98%, 87.5% 100%, 75% 100%, 75% 96.5%, 62.5% 96.5%,
    62.5% 99%, 50% 99%, 50% 96%, 37.5% 96%, 37.5% 99.5%, 25% 99.5%,
    25% 97%, 12.5% 97%, 12.5% 98.5%, 0% 98.5%, 0% 3%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* TESLA -- spark gaps: one wide, deep V bitten out of the middle of each
     side, the electrode gap an arc jumps across. Deeper and far wider than
     any notch here, because it has to read as a GAP and not a nick. */
  --fr-ring-spark: polygon(
    0% 0%, 40% 0%, 50% 6%, 60% 0%, 100% 0%,
    100% 40%, 94% 50%, 100% 60%, 100% 100%,
    60% 100%, 50% 94%, 40% 100%, 0% 100%,
    0% 60%, 6% 50%, 0% 40%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* DAZZLE -- square bites out of all four corners. The simplest outline
     in the file on purpose: the plate's whole event is the checkerboard
     inverting, and a busy edge would fight it. */
  --fr-ring-bite: polygon(
    5% 0%, 95% 0%, 95% 5%, 100% 5%, 100% 95%, 95% 95%, 95% 100%,
    5% 100%, 5% 95%, 0% 95%, 0% 5%, 5% 5%, 5% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* CRT -- a set bezel. Corners cut small (6%, half of SLAB's, so the two
     never read as the same plate) plus ONE wide shallow notch centred on
     the bottom edge, where a set's controls sit. The asymmetry is what
     makes it a screen rather than a picture frame: televisions have a
     bottom. */
  --fr-ring-bezel: polygon(
    6% 0%, 94% 0%, 100% 6%, 100% 94%, 94% 100%,
    62% 100%, 62% 95%, 38% 95%, 38% 100%,
    6% 100%, 0% 94%, 0% 6%, 6% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* PRISM FOIL -- cut edges. A shallow zigzag around all four sides, four
     teeth to a side, so the plate reads as something with FACETS rather
     than something with a border: the outline of cut foil or a gemmed
     edge, which is what makes light behave interestingly on it. Teeth
     bottom out at 3.5%, well inside the 9% band, so the ring never
     pinches. (Replaced EVENT HORIZON's letterbox, which read as two
     brackets holding the picture rather than as a frame around it -- worth
     remembering as a shape failure: a silhouette heavy on two opposite
     sides stops looking like a frame and starts looking like a clamp.) */
  --fr-ring-facet: polygon(
    0% 0%, 12.5% 3.5%, 25% 0%, 37.5% 3.5%, 50% 0%, 62.5% 3.5%, 75% 0%, 87.5% 3.5%, 100% 0%,
    96.5% 12.5%, 100% 25%, 96.5% 37.5%, 100% 50%, 96.5% 62.5%, 100% 75%, 96.5% 87.5%, 100% 100%,
    87.5% 96.5%, 75% 100%, 62.5% 96.5%, 50% 100%, 37.5% 96.5%, 25% 100%, 12.5% 96.5%, 0% 100%,
    3.5% 87.5%, 0% 75%, 3.5% 62.5%, 0% 50%, 3.5% 37.5%, 0% 25%, 3.5% 12.5%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* MISREGISTER -- the outline itself slips. Each side steps by 3% at a
     different point along its run, so no two edges line up and the plate
     reads as two rectangles printed a hair out of register: the same joke
     its offset CMY plates tell, told by the silhouette. The asymmetry is
     the content -- regularise these numbers and it just looks wrong. */
  --fr-ring-slip: polygon(
    3% 0%, 55% 0%, 55% 3%, 100% 3%,
    100% 48%, 97% 48%, 97% 100%,
    45% 100%, 45% 97%, 0% 97%,
    0% 52%, 3% 52%, 3% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  /* CHROMA ONLY, both of these -- the notched band and the core line that
     runs inside it. THE SHAPE LIVES ON THE OUTER CONTOUR, NEVER THE INNER
     ONE, which is the whole trick that lets a frame be interesting and
     still leave the picture alone: the inner contour stays the house 9%
     square, so the hole the face shows through is untouched, while the
     OUTER edge takes a shallow notch at the middle of each side. The band
     varies in THICKNESS -- full at the corners, thinner along the middles
     -- and reads as four HUD brackets joined by a rail. Same one polygon,
     same nonzero winding, nothing new over the photo.
     --fr-core is that ring again drawn NARROW (outer 3%, inner 6.2%) for
     the tube's hot line, and it TAKES THE NOTCHES TOO, dipping to 4.8%
     where the band dips to 4%. Skip that and the core hangs outside the
     band in the middle of every side, which is the one place a centre line
     has nothing to be the centre of. Move any of 9% / 4% / 37-63 and every
     number in both polygons follows. */
  --fr-ring-notched: polygon(
    0% 0%, 37% 0%, 37% 4%, 63% 4%, 63% 0%, 100% 0%,
    100% 37%, 96% 37%, 96% 63%, 100% 63%, 100% 100%,
    63% 100%, 63% 96%, 37% 96%, 37% 100%, 0% 100%,
    0% 63%, 4% 63%, 4% 37%, 0% 37%, 0% 0%,
    9% 9%, 9% 91%, 91% 91%, 91% 9%, 9% 9%
  );
  --fr-core: polygon(
    3% 3%, 37% 3%, 37% 4.8%, 63% 4.8%, 63% 3%, 97% 3%,
    97% 37%, 95.2% 37%, 95.2% 63%, 97% 63%, 97% 97%,
    63% 97%, 63% 95.2%, 37% 95.2%, 37% 97%, 3% 97%,
    3% 63%, 4.8% 63%, 4.8% 37%, 3% 37%, 3% 3%,
    6.2% 6.2%, 6.2% 93.8%, 93.8% 93.8%, 93.8% 6.2%, 6.2% 6.2%
  );
  /* How far the plate's halo throws. Zeroed by the Glow Bloom switch below
     -- the frames read the perf switches, they don't get an exemption. */
  --fr-bloom: 5px;
  /* Appended to EVERY frame filter chain, and identity everywhere except
     inside an ancestor that spins the menu's colour clock (see the home
     badge below). It exists as a var so a host can cancel that spin
     without restating the drop-shadow stack it would otherwise have to
     copy -- and a copy is a thing that silently stops matching the
     original the next time the glow is tuned. */
  --fr-spin: hue-rotate(0deg);
}

.sw-frame {
  position: absolute;
  inset: -11%;
  z-index: 3;
  pointer-events: none;
  /* The glow rides the WRAPPER, never the picture -- an <img> under a
     filter fights the hue-rotate counter-spin every avatar already carries
     (see .player-icon img). */
  filter: drop-shadow(0 0 var(--fr-bloom) var(--fr-glow, rgba(134, 255, 102, 0.4))) var(--fr-spin);
}
.sw-frame > i {
  position: absolute;
  inset: 0;
  display: block;
  clip-path: var(--fr-ring);
}

/* The host: whatever element actually draws the face. Round faces square
   off when they wear a plate -- a chamfered frame around a circle reads as
   two shapes arguing. .player-icon-photo, .social-av and #pv-pfp clip their
   photo with overflow:hidden, which would eat the overhang, so a framed one
   hands that job to border-radius on the image instead. */
/* SQUARE, not the 3px radius the unframed hosts wear: the band is square
   now, and a rounded picture inside a square ring leaves four crescents of
   background between the two. Nothing clips the picture any more -- that
   was the chamfer's problem and the chamfer is gone. */
.has-frame,
.has-frame > img { border-radius: 0; clip-path: none; }
/* The two big HUD plates cut their own corner (a 10px / 20px chamfer on
   the host itself), which cuts the PHOTO whether the frame does or not.
   Under a frame the plate is the shape, so the host's own cut goes with
   it -- otherwise the band is square and the picture inside it still is
   not. This also un-clips the plate's halo, which that clip-path was
   quietly slicing at the box edge. */
#pv-pfp.has-frame,
#profile-preview-box.has-frame { clip-path: none; }
.player-icon-photo.has-frame,
.social-av.has-frame,
#pv-pfp.has-frame:has(img) { overflow: visible; }

/* A WORN FRAME REPLACES THE HOUSE PLATE, it does not stack on top of it.
   Three of these hosts draw their own chrome -- the profile screen's
   picture button and the profile card's 140px plate both wear a green
   border plus a glow, the friends-list square a dim 1px rule -- and that
   chrome is the DEFAULT frame: what you see when you own nothing. Leave it
   under a bought plate and the corner shows two rings a few pixels apart in
   two different colours, which is what "the frame looks broken" turns out
   to mean every time. The house glow goes with it: two halos in different
   colours around one face is the same bug at a larger radius.
   Colour, not width, on the ones with a real border -- a border that
   disappears resizes the picture inside it. */
#profile-preview-box.has-frame::after { display: none; }
#pv-pfp.has-frame { border-color: transparent; box-shadow: none; }
.social-av.has-frame { border-color: transparent; }
/* And every .player-icon at once, rather than one host at a time -- the
   lobby slot, the party rail, the home badge and the leader's cred ring
   each draw their own border and glow, and hunting them individually is
   how the badge got missed and shipped a gold ring over a rainbow plate.
   :not(.speaking) is the exception that has to survive: the voice ring is
   LIVE STATE, not decoration, and a frame must not hide who is talking.
   The badge needs the id-level repeats because #home-profile-badge beats
   any number of classes. */
.player-icon.has-frame:not(.speaking),
#home-profile-badge .player-icon.has-frame:not(.speaking),
#home-profile-badge:hover .player-icon.has-frame:not(.speaking) {
  border-color: transparent;
  box-shadow: none;
}
/* THE BADGE HUE-ROTATES ITS WHOLE SUBTREE, which is right for chrome and
   wrong for anything the player owns: CHROMA tuned to purple must not walk
   through the spectrum because the menu is.

   IT SPINS ON --logo-hue, NOT --ui-hue. There are TWO colour clocks on
   :root and they run at different speeds (--ui-hue advances at an eighth
   of --logo-hue's rate, see the loop in main.js). Cancelling the wrong one
   is WORSE than not cancelling at all: instead of drifting with the menu
   the plate drifts at the DIFFERENCE of the two rates, so a purple frame
   sits there reading orange and still moving. Always read the clock the
   ancestor's own filter reads.

   ON THE WRAPPER, VIA --fr-spin, NOT ON THE PLATE. First attempt put the
   counter-spin on the plate's children, which fixed the BAND and left the
   HALO cycling -- the halo is the wrapper's own drop-shadows, and at badge
   size the halo is most of what you see, so "the plate is purple and the
   glow is running through the rainbow" still reads as an RGB cycle. The
   var appends the correction to whichever shadow stack that frame already
   declared, so both go through the same one filter chain and neither can
   be fixed without the other. */
#home-profile-badge .sw-frame {
  --fr-spin: hue-rotate(calc(-1 * var(--logo-hue, 0deg)));
}
/* The PHOTO in the badge had the same mismatch, and had it first: the
   global rule on .player-icon img cancels --ui-hue, which is the right
   clock everywhere except in here. Its face has been slowly drifting on
   the menu ever since the badge picked up --logo-hue. */
#home-profile-badge .player-icon img {
  filter: hue-rotate(calc(-1 * var(--logo-hue, 0deg)));
}

/* THE PICTURE SITS INSIDE THE RING, IT DOES NOT SIT UNDER IT.
   Everywhere else the plate hangs OUTSIDE its host (inset:-11%), which puts
   the ring's inner edge exactly on the host's box: the photo fills its own
   box and the band surrounds it, no overlap at all. The two big HUD plates
   are the exception -- they wear the band ON their perimeter (inset:0, see
   below), so the picture ran full-bleed underneath it and was only hidden
   by whatever the band happened to cover. The notched outer edge is what
   exposed that: where the band thins to make the silhouette, the photo it
   was covering shows past it, which reads as the picture poking out of its
   own frame.
   82% is not a taste number: the ring's inner contour is at 9%, so 82%
   square is the exact size of the hole. The photo lands flush against the
   band's inside edge -- no overlap to be uncovered, and no gap of host
   background between the two either. The hosts are already centring
   flexboxes, so the size IS the placement. */
#profile-preview-box.has-frame #profile-avatar,
#pv-pfp.has-frame > img {
  width: 82%;
  height: 82%;
}
/* The emoji fallback obeys the same law as the photo. #profile-preview is
   the plate shown before any picture is uploaded, and it kept its house
   150px inside the 232px box -- under a frame that left a moat of host
   background between plate and band on every side. Same 82% as the img
   (flush against the hole), glyph scaled with it (82px x 190/150). */
#profile-preview-box.has-frame #profile-preview {
  width: 82%;
  height: 82%;
  font-size: 104px;
}

/* Two hosts carry a clip-path of their own (the big angular HUD plates),
   which would cut a child hanging outside them. Those wear the band ON
   their perimeter rather than around it -- at 140px and 232px there is
   plenty of face for a band to sit on. */
#pv-pfp .sw-frame,
#profile-preview-box .sw-frame { inset: 0; }

/* ---- TICK METER -- rank read as tick density. One repeating-conic
   background layer, NOT one element per tick.

   THE TICKS CRAWL, and the crawl obeys the same rule SHEEN BAND does: the
   rotation lives on a pseudo-element INSIDE the clipped band, never on
   .fr-accent itself -- rotating the clipped element turns its chamfer with
   it and the plate visibly wobbles out of true. Transform on a child of a
   static clip is compositor work over a cached texture; the clip never
   moves and nothing repaints.

   ONE PERIOD IS 9deg (1.7deg lit + 7.3deg dark), so the keyframe turns
   exactly 9deg and the loop is seamless -- tick N lands precisely where
   tick N+1 started, and there is no pop at the wrap to time around. Change
   the gradient's period and this number has to move with it or the meter
   stutters once a cycle.

   The pseudo is 200% and centred because a conic gradient rotated inside
   its own box leaves bare triangles at the corners -- which on a square
   ring is all four of them. ---- */
.fr-ticks {
  --fr-glow: rgba(57, 255, 20, 0.42);
  --fr-ring: var(--fr-ring-steps);
}
.fr-ticks .fr-plate {
  background: linear-gradient(155deg, #2a3d25 0%, #0a120a 55%, #35542d 100%);
}
.fr-ticks .fr-accent { opacity: 0.9; }
.fr-ticks .fr-accent::before {
  content: '';
  position: absolute;
  left: -50%;
  top: -50%;
  width: 200%;
  height: 200%;
  background: repeating-conic-gradient(from -8deg at 50% 50%,
    #39ff14 0deg 1.7deg, transparent 1.7deg 9deg);
  animation: fr-ticks-crawl 4s linear infinite;
}
@keyframes fr-ticks-crawl { to { transform: rotate(9deg); } }

/* ---- JAWBONE -- a skull's bite around the picture: maxilla and teeth
   hanging from the top edge, mandible rising from the bottom, both
   FLOATING free of each other. The second no-ring frame, and the first
   whose art is an ASSET rather than a gradient.

   THE ART IS THE OWNER'S OWN PNG, split into its two ink bands by
   scripts/split at the transparent gap between the teeth rows -- a
   deliberate loosening of "frame art is pure CSS, never a URL", which was
   written when the fear was player-controlled values reaching other
   screens. An asset shipped in public/img/frames/ and named by a
   closed-list id has every safety property a gradient has; what stays
   banned forever is a URL any player influences. (A bitmap bends the
   scale-invariance rule a vector wouldn't -- accepted here because the
   source is 558px wide and every surface that draws a frame is smaller,
   so it only ever downscales.)

   IT SHAKES FRANTICALLY -- owner's call, replacing the slow slack-jaw
   drift this shipped with. The construction of a convincing rattle, since
   naive versions all fail the same two ways:

   JAGGED KEYFRAMES, NOT A SMOOTH PATH. A shake is NOISE: the piece is
   never where the last frame predicted. So the keyframes are a dozen
   hand-scattered positions with `steps`-like abruptness supplied by
   `linear` timing over tiny intervals -- an eased curve through the same
   points would be a wobble, and a wobble is a jelly, not a rattle.

   SHORT COPRIME LOOPS (0.34s / 0.27s). Fast enough that the eye can't
   track individual jumps (that is what "frantic" is), and the two loops
   share no factor so upper and lower never lock into a pattern -- locked,
   it reads as the whole image vibrating, which is a rendering fault, not
   a haunted skull.

   AMPLITUDE STAYS SMALL (about 1% travel, under a degree of twist). The
   franticness comes from FREQUENCY, not distance -- big jumps at this
   rate are a strobe and physically read as the picture tearing. The old
   whisper rule survives in the range, just not in the speed. ---- */
.fr-jaws {
  inset: 0;
  --fr-glow: rgba(232, 220, 180, 0.35);
}
.fr-jaws .fr-plate {
  clip-path: none;
  background: none;
  overflow: hidden;   /* the drift must never leave the picture */
}
.fr-jaws .fr-plate::before {
  content: '';
  position: absolute;
  top: -1%;
  left: 0;
  width: 100%;
  height: 52%;
  background: url('/img/frames/skull-top.png') top center / 100% auto no-repeat;
  animation: fr-jaws-top 0.34s linear infinite;
}
.fr-jaws .fr-plate::after {
  content: '';
  position: absolute;
  bottom: -1%;
  left: 0;
  width: 100%;
  height: 46%;
  background: url('/img/frames/skull-jaw.png') bottom center / 100% auto no-repeat;
  animation: fr-jaws-bottom 0.27s linear infinite;
}
@keyframes fr-jaws-top {
  0%, 100% { transform: translate(0, 0) rotate(0deg); }
  9%  { transform: translate(0.7%, -0.5%) rotate(0.5deg); }
  18% { transform: translate(-0.6%, 0.4%) rotate(-0.4deg); }
  28% { transform: translate(0.4%, 0.8%) rotate(0.3deg); }
  37% { transform: translate(-0.8%, -0.3%) rotate(-0.6deg); }
  47% { transform: translate(0.6%, 0.5%) rotate(0.6deg); }
  58% { transform: translate(-0.3%, -0.8%) rotate(-0.3deg); }
  68% { transform: translate(0.8%, 0.2%) rotate(0.4deg); }
  79% { transform: translate(-0.5%, 0.7%) rotate(-0.5deg); }
  90% { transform: translate(0.3%, -0.6%) rotate(0.2deg); }
}
@keyframes fr-jaws-bottom {
  0%, 100% { transform: translate(0, 0) rotate(0deg); }
  11% { transform: translate(-0.6%, 0.6%) rotate(-0.5deg); }
  22% { transform: translate(0.7%, -0.4%) rotate(0.4deg); }
  34% { transform: translate(-0.4%, -0.8%) rotate(0.6deg); }
  45% { transform: translate(0.5%, 0.4%) rotate(-0.3deg); }
  56% { transform: translate(-0.8%, 0.3%) rotate(0.5deg); }
  67% { transform: translate(0.4%, -0.7%) rotate(-0.6deg); }
  78% { transform: translate(0.8%, 0.5%) rotate(0.3deg); }
  89% { transform: translate(-0.3%, -0.5%) rotate(-0.4deg); }
}
/* No band, so the HUD hosts keep the picture full size -- same exemption
   the overlay experiment needed, for the same reason. */
#profile-preview-box.has-frame:has(.fr-jaws) #profile-avatar,
#pv-pfp.has-frame:has(.fr-jaws) > img {
  width: 100%;
  height: 100%;
}
#profile-preview-box.has-frame:has(.fr-jaws) #profile-preview {
  width: 150px;
  height: 150px;
  font-size: 82px;
}

/* ---- HAZARD TAPE -- amber and black on the crawl.

   THE STRIPES MOVE BY EXACTLY ONE TILE and then repeat, which is the whole
   reason this reads as tape rather than as a sliding texture. The stripes
   are a four-stop 45deg gradient TILED at a % of the pseudo's own box (the
   scale-invariance rule: a frame is one design that scales as a unit, so
   nothing is authored in px), and a diagonal-stripe tile is periodic along
   its own width -- so translateX by exactly the tile's width, both numbers
   percentages of the same box, maps the pattern onto itself at any
   rendered size. Same law as TICKS' 9deg, in relative units: change the
   background-size and the keyframe moves with it or the loop jumps.

   The pseudo is oversized because a stripe field in motion has to have
   material waiting outside every edge it moves away from. ---- */
.fr-hazard {
  --fr-glow: rgba(245, 179, 1, 0.45);
  --fr-ring: var(--fr-ring-tear);
}
.fr-hazard .fr-plate { background: #12100a; }
.fr-hazard .fr-plate::before {
  content: '';
  position: absolute;
  inset: -25%;
  background: linear-gradient(45deg,
    #f5b301 0 25%, #12100a 25% 50%, #f5b301 50% 75%, #12100a 75% 100%)
    0 0 / 15% 15% repeat;
  animation: fr-hazard-crawl 1.1s linear infinite;
  will-change: transform;
}
@keyframes fr-hazard-crawl { to { transform: translateX(15%); } }
/* The tape's edges: worn amber lips top and bottom, so the band reads as a
   strip laid ON the plate rather than as a pattern printed through it. */
.fr-hazard .fr-accent {
  background: linear-gradient(155deg,
    rgba(255, 236, 179, 0.9) 0 3%, rgba(0, 0, 0, 0.45) 3% 7%,
    transparent 7% 93%,
    rgba(0, 0, 0, 0.45) 93% 97%, rgba(255, 236, 179, 0.9) 97%);
}

/* ---- BAD COPY -- blown-out black and white, engraved rings, a torn edge.
   The underground-trap plate: a zine cover run through the copier until
   nothing is left but ink and paper.

   TWO TONES, NO GREYS. That is the whole discipline and it is what every
   other frame here does NOT do -- the rest are built out of ramps, and this
   one is built out of THRESHOLDS. Wherever a soft edge would normally go
   (the bevel, the lip, the highlight falloff) this plate jumps straight
   from #fff to #000, because a photocopy has no midtones left by the third
   generation. Add a grey anywhere and it stops being a copy and starts
   being a monochrome frame.

   THE TEXTURE IS ENGRAVED RINGS, not noise. Real film grain needs either an
   SVG turbulence filter (a filter, on up to seven rosters -- the exact
   thing this file bans) or a bitmap (a URL, which a closed-list cosmetic
   must never have). Concentric hairlines from a repeating-radial-gradient
   cost one paint and land on the same reference anyway: the sleeve art
   this is drawn from is line-screened, not grainy. Stops are in % of the
   gradient's radius (scale-invariance rule), so the ring count is part of
   the design and every size shows the same picture.

   ONE ANIMATED LAYER: a soft exposure band drifting down, the copier's
   lamp passing. Slow (7s) and low contrast, because the aesthetic is a
   STILL image that looks damaged -- anything faster reads as a glitch
   effect, and the game already retired one of those. ---- */
.fr-copy {
  --fr-glow: rgba(255, 255, 255, 0.4);
  --fr-ring: var(--fr-ring-torn);
}
.fr-copy .fr-plate {
  background:
    /* Blown highlights -- where the copier gave up and printed paper. Hard
       stops, no falloff: the white is either there or it isn't. */
    radial-gradient(ellipse 44% 34% at 16% 10%,
      #ffffff 0 42%, rgba(255, 255, 255, 0) 44%),
    radial-gradient(ellipse 32% 44% at 88% 78%,
      #ffffff 0 38%, rgba(255, 255, 255, 0) 40%),
    radial-gradient(ellipse 26% 30% at 72% 14%,
      #ffffff 0 34%, rgba(255, 255, 255, 0) 36%),
    /* The line screen. */
    repeating-radial-gradient(circle at 50% 50%,
      #f4f4f4 0 0.4%, #000000 0.4% 1.7%),
    #000000;
}
.fr-copy .fr-plate::before {
  content: '';
  position: absolute;
  left: 0;
  top: -70%;
  width: 100%;
  height: 60%;
  background: linear-gradient(180deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.14) 50%,
    rgba(255, 255, 255, 0) 100%);
  animation: fr-copy-pass 7s linear infinite;
}
@keyframes fr-copy-pass { to { transform: translateY(300%); } }
/* Paper edge outside, ink edge inside -- the threshold rule again, and the
   only thing giving the band depth without a single grey. */
.fr-copy .fr-accent {
  background: linear-gradient(155deg,
    #ffffff 0 4%, #000000 4% 9%,
    transparent 9% 91%,
    #000000 91% 96%, #ffffff 96%);
}

/* ---- ICED OUT -- pavé. A band of set stones, and the answer to "how do
   you draw glitter without an image".

   MUTUALLY-IRRATIONAL TILE SIZES ARE THE WHOLE TRICK. One tiled dot
   gradient is a visible grid, and a grid is the one thing crushed stone
   never looks like. Three tiles whose sizes share no small common multiple
   (6.1% / 8.9% / 4.1% of the plate) drift phase for far longer than the
   band is wide, so the eye never finds the period and reads the scatter as
   random; the scattered background-position offsets stop the three centres
   from ever stacking into one bright node. Change one size to an even
   multiple of another and the whole thing snaps back into a chequerboard.

   THREE SIZES, NOT ONE, for the same reason a real pavé has: big stones
   carry the sparkle, small ones fill the metal between them so there is no
   dead grey. The dots are hard-edged (a stop pair, no falloff) because a
   soft dot is a bokeh blur and a stone is a facet -- this is the same
   contrast rule SLAB's speculars are built on, applied a hundred times at
   4px.

   SPARKLE IS TWO GLINT FIELDS BLINKING OUT OF PHASE, not a bar crossing.
   The first build swept one bright bar over the pavé, and it read as a
   lamp passing a surface -- polish, SLAB's move. Sparkle is a different
   phenomenon: many stones flashing INDEPENDENTLY as the eye moves. Two
   sparse fields of white glints, tiled at 11px and 13px (coprime with each
   other AND with the stone tiles, so a glint lands on a different stone in
   every neighbourhood), each animating only its OPACITY on durations that
   share no factor (1.3s / 2.1s) -- together they make flashes appear and
   die all over the band with no visible rhythm.

   OPACITY, not a repaint: an opacity change on a cached texture is
   compositor work exactly like transform, which is why this is legal under
   the frames' motion rule -- it is the rule's second permitted property,
   not an exception to it. The flash curve is SHARP (on in 8% of the cycle,
   gone by 30%) because a real glint pops; ease it and the field just
   breathes, which is a screensaver. Two layers, both free -- the same
   budget precedent MISREGISTER set, and cheaper per layer than its
   translates. ---- */
.fr-iced {
  --fr-glow: rgba(226, 238, 255, 0.5);
  --fr-ring: var(--fr-ring-bead);
}
.fr-iced .fr-plate {
  background:
    /* Tiles are % of the plate (scale-invariance rule) and stay mutually
       irrational-ish -- none an even multiple of another -- so the three
       grids never phase-lock into a chequerboard at any size. */
    /* big stones */
    radial-gradient(circle at 50% 50%,
      #ffffff 0 26%, rgba(255, 255, 255, 0) 28%) 0 0 / 6.1% 6.1% repeat,
    /* mid stones, offset so they never sit on the big ones */
    radial-gradient(circle at 50% 50%,
      rgba(232, 242, 255, 0.9) 0 22%, rgba(255, 255, 255, 0) 24%) 43% 29% / 8.9% 8.9% repeat,
    /* dust, filling the metal between */
    radial-gradient(circle at 50% 50%,
      rgba(190, 206, 230, 0.75) 0 20%, rgba(255, 255, 255, 0) 22%) 17% 61% / 4.1% 4.1% repeat,
    /* The setting: white gold, and a NARROW ramp on purpose. Same reason
       the accent below is gone -- a wide light-to-dark linear ramp on a
       square ring parks its dark end in a corner, so the metal keeps just
       enough range to not be flat and never enough to make one corner read
       as shaded. */
    linear-gradient(152deg, #97a4b8 0%, #7c8899 38%, #8e9bae 62%, #78849a 84%, #909db1 100%);
}
.fr-iced .fr-plate::before {
  content: '';
  position: absolute;
  inset: 0;
  /* Field A: sparse hard glints with a faint soft halo baked into the same
     tile, so a flash blooms slightly without any filter. */
  background:
    radial-gradient(circle at 50% 50%,
      #ffffff 0 14%, rgba(255, 255, 255, 0.35) 14% 30%,
      rgba(255, 255, 255, 0) 34%) 23% 71% / 10.3% 10.3% repeat;
  animation: fr-iced-glint-a 1.3s linear infinite;
}
@keyframes fr-iced-glint-a {
  0%, 100% { opacity: 0.1; }
  8% { opacity: 1; }
  30% { opacity: 0.1; }
}
/* Field B rides the accent: a second sparse glint tile at 13px (coprime
   with field A's 11px and every stone tile) on its own 2.1s clock, so the
   two never flash the same neighbourhood at the same moment. NO edge rail
   here -- the old one was a linear gradient whose end-stops parked dark
   wedges in the two corners its axis ran into (see the linear-on-a-ring
   rule); the stones and the milgrain bead already say "set into metal". */
.fr-iced .fr-accent {
  background:
    radial-gradient(circle at 50% 50%,
      #ffffff 0 15%, rgba(226, 238, 255, 0.3) 15% 28%,
      rgba(255, 255, 255, 0) 32%) 67% 13% / 14.1% 14.1% repeat;
  animation: fr-iced-glint-b 2.1s linear infinite;
}
@keyframes fr-iced-glint-b {
  0%, 100% { opacity: 0.12; }
  10% { opacity: 1; }
  34% { opacity: 0.12; }
}

/* ---- CRT -- a live set, and the CHEAPEST animated frame on the shelf.

   THE WHOLE BUDGET IS ONE TRANSLATING BAR. Everything that says "screen"
   here -- phosphor tint, scanlines, the darkened corners -- is painted once
   into a static gradient stack and never touched again; the only thing that
   moves is a single soft band sliding down the plate. That is deliberate:
   this is the effect most likely to be worn by somebody on a weak machine
   (it is the cheapest tier that moves), so it gets one composited layer and
   no more. No filter, no blend mode, no second animation.

   SCANLINES ARE 3px AND FIXED IN PX -- a scanline that scales is a moire
   pattern waiting to happen, and at 40px a percentage-based one aliases
   into flat grey. The roll is SOFT-edged and low contrast because a hard
   white bar reads as a glitch effect (that is #crt-rollbar's job on the
   glass, and it was retired for being exactly that); a set that is merely
   out of sync just breathes. ---- */
.fr-crt {
  --fr-glow: rgba(74, 255, 168, 0.4);
  --fr-ring: var(--fr-ring-bezel);
}
.fr-crt .fr-plate {
  background:
    /* Scanlines in % of the plate's height (scale-invariance rule) -- the
       same count of lines at every size, so the texture IS the design
       rather than a density that changes with where it's drawn. */
    repeating-linear-gradient(0deg,
      rgba(0, 0, 0, 0.5) 0 0.9%, rgba(0, 0, 0, 0) 0.9% 2.7%),
    linear-gradient(160deg, #0d2a1e 0%, #071a12 46%, #10382a 100%);
}
.fr-crt .fr-plate::before {
  content: '';
  position: absolute;
  left: 0;
  top: -60%;
  width: 100%;
  height: 46%;
  background: linear-gradient(180deg,
    rgba(120, 255, 200, 0) 0%,
    rgba(120, 255, 200, 0.16) 45%,
    rgba(190, 255, 225, 0.26) 60%,
    rgba(120, 255, 200, 0.1) 78%,
    rgba(120, 255, 200, 0) 100%);
  animation: fr-crt-roll 4.5s linear infinite;
}
@keyframes fr-crt-roll { to { transform: translateY(350%); } }
/* No bezel lip. It was a linear gradient with end-stops, and on a square
   ring those land as wedges in the two corners the axis runs into (the
   linear-on-a-ring rule -- this was the fourth frame caught by it, which
   is why the pattern is banned outright now rather than fixed per plate).
   The phosphor, the scanlines and the roll already carry the set; the
   corner cuts in the silhouette carry the bezel. */
.fr-crt .fr-accent { background: none; }

/* ---- SOUND SYSTEM -- a wall of drivers, and the frame that MOVES BECAUSE
   OF WHAT IT IS rather than to catch the eye.

   THE DRIVERS ARE ONE TILED RADIAL GRADIENT, not one element per cone.
   A single radial-gradient with a background-size tiles into a grid, and
   the ring clip then shows only the ones that land on the band -- so a
   full array costs exactly one paint no matter how many cones are visible,
   at 40px (a couple per side) or at 232px (a dozen). Each tile is a whole
   driver in stops: dust cap, cone, roll surround, basket, and the gap of
   cabinet between them. Ordered outward, and the widths matter more than
   the colours -- a cone is mostly cone, and a surround that reads as thick
   as the cone turns the whole thing into a target symbol.


   THE WHOLE PLATE PUMPS, and this is the ONE frame that breaks the "never
   animate the clipped element" rule on purpose. Everything else in this
   file moves a texture INSIDE a band that stays put, because a plate whose
   own outline shifts reads as broken. Here the outline shifting IS the
   product: a speaker is a box that physically moves air, and a still box
   with a wobbling picture painted inside it is a poster of a speaker. So
   the transform rides the WRAPPER -- band, grille, drivers and halo all
   grow and settle together, as one object, against a photograph that never
   moves. (Scaling is safe where rotating is not: the clip scales with its
   element, so the ring stays square and centred at every size. Rotating it
   would turn the frame against the picture, which is what the rule was
   written about.)

   AND THE SILHOUETTE IS A PLAIN SQUARE, the house default. It is the only
   plate here with no cut in its outline, and that is the point: the shape
   is doing the work by MOVING, so any notch would just be noise on top of
   it -- and a moving outline has to be simple enough that the eye can see
   it change.

   Excursion is quick out and slow back -- a kick, not a sine -- so the
   keyframes spend 10% of the cycle punching and the rest settling. 0.72s
   is about 83bpm: a bassline rather than a vibration, and deliberately NOT
   synced to any real audio, because seven rosters pumping in lockstep to a
   track nobody else can hear is worse than seven pumping on their own.

   Amplitude is 5%, and it wants to stay there. The frame overhangs its
   host by 11%, so a plate growing past about 8% starts colliding with the
   name beside it in a roster row -- the frame is the only thing that
   should move, and it must not push anything else around to do it. ---- */
.fr-stack {
  --fr-glow: rgba(143, 168, 216, 0.4);
  animation: fr-stack-pump 0.72s cubic-bezier(0.2, 0.9, 0.3, 1) infinite;
}
.fr-stack .fr-plate {
  /* Cabinet, then the drivers mounted in it. Both static now: the array
     does not need its own animation once the whole box is moving, and that
     keeps this frame at the one-animated-layer budget every other plate
     holds to. */
  background:
    radial-gradient(circle at 50% 50%,
      #d4dbe8 0 9%,                    /* dust cap, the only bright thing */
      #7d8698 9% 13%,
      #262b35 13% 30%,                 /* cone */
      #3c424f 30% 36%,                 /* roll surround */
      #10131a 36% 41%,                 /* basket rim */
      /* 10% tiles, and 10 is the only right number: it DIVIDES the plate,
         so the grid is commensurate with the 9% band -- each edge gets one
         clean row of cones centred in it, each corner exactly one cone,
         and every interior cone falls wholly in the hole and vanishes. The
         old 18% tile divided nothing: cones straddled the ring's edges
         wherever the grid happened to land, which read as speakers sliding
         off the plate. A tile that doesn't divide 100 puts the seam
         somewhere visible, always. */
      rgba(0, 0, 0, 0) 43%) 0 0 / 10% 10% repeat,
    linear-gradient(150deg, #1c2029 0%, #0e1015 58%, #171b23 100%);
}
@keyframes fr-stack-pump {
  0%, 100% { transform: scale(1); }
  10% { transform: scale(1.05); }
  55% { transform: scale(1); }
}
/* The grille cloth over the array, and ONLY the cloth. The lit lip that
   used to ride with it was a linear gradient with end-stops -- which on a
   square ring lands as bright wedges in the two corners its axis runs
   into, not as an edge (the linear-on-a-ring rule, third frame caught by
   it). The repeating weave is safe because it has no ends: a repeat is
   uniform everywhere, so there is nothing for a corner to catch. Depth
   cues here are the drivers themselves -- recessed cones against the
   cabinet already read as mounted-in. */
.fr-stack .fr-accent {
  background: repeating-linear-gradient(0deg,
    rgba(255, 255, 255, 0.07) 0 0.8%, rgba(0, 0, 0, 0.14) 0.8% 2.4%);
}

/* ---- PRISM FOIL -- holographic card foil, and the only frame whose colour
   is a SHEEN rather than a paint.

   THE BASE IS METAL, NOT COLOUR: a cold silver ramp, the same near-white to
   graphite range a foil card has before you tilt it. Every hue on this plate
   arrives from a layer above it and is gone a second later, which is the
   entire difference between this and CHROMA -- CHROMA is saturated colour
   that IS the plate, PRISM is a mirror that happens to be catching a
   rainbow. Sat side by side they should never be confused; if PRISM ever
   starts reading as "the other rainbow one", the fix is to desaturate the
   sheen, never to slow it down.

   THE SHEEN IS SCREEN-BLENDED AND WIDE. Foil does not have edges in its
   colour -- it is one continuous oil-slick smear that slides as the card
   turns, so the stops are broad and soft (the opposite of every specular in
   this file, which are tight pairs). Screen over silver is what keeps it
   pearlescent instead of painted: the highlights lift toward white rather
   than replacing the metal.

   It travels on a long loop -- 11s, the slowest motion here. A foil card
   only flashes when something moves; a fast holo reads as an RGB gamer
   sticker, which is the exact thing this plate is trying not to be. ---- */
.fr-prism {
  --fr-glow: rgba(216, 226, 245, 0.45);
  --fr-ring: var(--fr-ring-facet);
}
.fr-prism .fr-plate {
  background: linear-gradient(148deg,
    #f4f7fc 0%, #cdd6e6 18%, #98a2b6 40%,
    #7d8698 54%, #b8c2d4 72%, #e8eef8 88%, #9aa4b8 100%);
}
.fr-prism .fr-plate::before {
  content: '';
  position: absolute;
  top: -20%;
  left: -110%;
  width: 220%;
  height: 140%;
  background: linear-gradient(112deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 128, 190, 0.42) 14%,
    rgba(255, 214, 128, 0.38) 27%,
    rgba(150, 255, 190, 0.4) 41%,
    rgba(128, 208, 255, 0.44) 56%,
    rgba(178, 150, 255, 0.42) 70%,
    rgba(255, 128, 190, 0.4) 84%,
    rgba(255, 255, 255, 0) 100%);
  mix-blend-mode: screen;
  animation: fr-prism-shift 11s linear infinite;
}
@keyframes fr-prism-shift {
  to { transform: translateX(100%); }
}
/* The cut edges catch hardest, which is the whole reason the silhouette has
   facets: a bright lip along the outside and inside so each tooth reads as
   a cut face rather than a bite taken out of the outline. */
.fr-prism .fr-accent {
  background: linear-gradient(148deg,
    rgba(255, 255, 255, 0.95) 0 4%, rgba(214, 226, 245, 0.6) 4% 9%,
    transparent 9% 91%,
    rgba(214, 226, 245, 0.6) 91% 96%, rgba(255, 255, 255, 0.95) 96%);
}

/* ---- CHROMA -- the whole spectrum wrapped around the band, and the only
   frame that is a SETTING rather than a look. The wearer picks the hue (or
   sets it running); --fr-hue carries their choice and `is-cycle` says they
   chose motion instead. Both are written by frameEl in main.js from a value
   that is a NUMBER at every step -- see the safety note there and in
   server/profile.js, because this is the one plate whose appearance is
   driven by data another player sends.

   WHY THE SPECTRUM IS CONIC: the same reason SLAB's gold is. A linear
   rainbow crosses the plate, so each side of the ring gets one flat band of
   one colour -- four coloured bars, not a spectrum. Conic wraps it, so the
   colours travel around the band the way the light does.

   THE TWO MODES ARE DIFFERENT PICTURES, NOT ONE PICTURE SHIFTED. Tuned, the
   plate is ONE colour -- the hue you chose, lit; the whole band is that
   colour and nothing else. CYCLE is the spectrum, all of it, travelling.
   That is the actual product: the slider picks a colour, the toggle picks
   "all of them".

   WHICH IS WHY THERE IS NO hue-rotate HERE. Rotating a fixed rainbow would
   make every hue setting the same rainbow starting in a different place --
   a spectrum in all 360 positions, never once a single colour. So the tuned
   plate is authored in hsl() reading --fr-hue directly: every stop shares
   that one hue and varies only LIGHTNESS and SATURATION, which is what
   makes it a lit ring instead of a flat fill. One gradient, 360 colours, no
   filter at all -- and dropping the filter function is cheaper than the
   version it replaces.

   THE MATERIAL IS A NEON TUBE, NOT POLISHED METAL. This plate spent a
   version built the way SLAB's gold is -- near-white specular flashes over
   a near-black floor -- and at any hue but yellow that reads as painted
   plastic catching a studio light. Wrong reference: the room is a
   cyberpunk sign, and a sign is not lit FROM somewhere, it IS the light.
   Three things make that difference, and all three matter:
     1. THE FLOOR NEVER GOES DARK. Metal bottoms out at 11% lightness
        between its flashes because a mirror shows you the unlit half of
        the room. Glowing gas has no unlit half -- the tube is emitting
        along its whole length, so the plate stays 44-62% and the variation
        is the gas being unevenly excited, not light travelling over it.
     2. THE CORE IS WHITE AND THE EDGES ARE NOT. A real tube overexposes
        down its middle and falls to saturated colour at the glass, which
        is exactly the house neon (white-hot core + coloured halo) the menu
        sign is built from. That is what --fr-core is: the accent is
        re-clipped to a narrower ring so it paints the middle of the band
        only. Give it the full ring instead and the plate goes flat white.
     3. THE BLOOM IS TWO SHADOWS, tight-and-hot over wide-and-coloured.
        One shadow can be a halo or a hot edge, never both, and the pair is
        what stops the throw reading as a blur of the plate.
   The conic stays conic for the reason it always was -- the variation has
   to travel AROUND the band, not across it.

   The glow reads the same var, so the halo is the colour of the plate
   without anything having to keep the two in sync. Set them separately and
   the aura sits fixed while the band changes -- an obvious wrongness on the
   one item people paid a million creds to control.

   CYCLING NEVER ANIMATES A FILTER. An animated hue-rotate re-runs a filter
   over the whole subtree every frame, on up to seven rosters at once, which
   is exactly what the frames block bans. Instead the spectrum is handed to
   a pseudo-element inside the clipped band and ROTATED -- TICKS' trick --
   so the colours sweep past a static window as pure compositor work. A full
   turn is a seamless loop because the conic's first and last stops are the
   same colour. ---- */
.fr-rgb {
  /* Alpha-separated hsl so the halo tracks the chosen hue. --fr-hue is
     always written by frameEl as `<integer>deg`, which is a valid <hue>. */
  --fr-glow: hsl(var(--fr-hue, 0deg) 95% 62% / 0.6);
  /* THE SILHOUETTE IS THIS PLATE'S, NOT THE HOUSE'S. Overriding --fr-ring
     on the frame itself is the whole mechanism: every layer below already
     reads that var, so one line re-shapes the band, the core and the flat
     perf-mode outline together, and no other frame is touched. TICKS,
     SLAB and MISREGISTER keep the plain square -- a shape shared by
     everything says nothing about any of them. */
  --fr-ring: var(--fr-ring-notched);
}
.fr-rgb .fr-plate {
  background:
    /* The glass, over the gas: the tube's outer and inner walls are seen
       edge-on and go slightly dark, the middle reads through clean. Neutral
       only -- black and white can shade the band without dragging any stop
       off its hue, which is the whole reason the colour lives in its own
       layer. Nothing like the metal bevel this replaced: a tenth of the
       contrast, because glass is not a mirror. */
    linear-gradient(155deg,
      rgba(0, 0, 0, 0.34) 0%, rgba(0, 0, 0, 0.06) 16%,
      rgba(255, 255, 255, 0.1) 50%,
      rgba(0, 0, 0, 0.08) 84%, rgba(0, 0, 0, 0.3) 100%),
    /* THE GAS. One hue, emitting -- the lightness never leaves the 44-62%
       band, so no part of the ring is ever "the dark side". The stops are
       WIDE and soft on purpose: tight pairs snap, and a snap is a
       reflection. Gas glow drifts. */
    conic-gradient(from 200deg at 50% 50%,
      hsl(var(--fr-hue, 0deg) 100% 47%) 0deg,
      hsl(var(--fr-hue, 0deg) 100% 60%) 48deg,
      hsl(var(--fr-hue, 0deg) 100% 50%) 104deg,
      hsl(var(--fr-hue, 0deg) 100% 44%) 148deg,
      hsl(var(--fr-hue, 0deg) 100% 62%) 196deg,
      hsl(var(--fr-hue, 0deg) 100% 49%) 250deg,
      hsl(var(--fr-hue, 0deg) 100% 58%) 306deg,
      hsl(var(--fr-hue, 0deg) 100% 47%) 360deg);
}
/* THE CORE. Re-clipped to --fr-core so it paints the middle of the band
   and nothing else -- the overexposed centre line of the tube, white with
   only enough of the wearer's hue left in it to say which colour is
   burning. The conic keeps the same rhythm as the gas underneath, so the
   core thins where the glass darkens instead of running at one flat
   brightness. */
.fr-rgb .fr-accent {
  clip-path: var(--fr-core);
  background: conic-gradient(from 200deg at 50% 50%,
    hsl(var(--fr-hue, 0deg) 100% 90%) 0deg,
    hsl(var(--fr-hue, 0deg) 100% 99%) 48deg,
    hsl(var(--fr-hue, 0deg) 100% 91%) 104deg,
    hsl(var(--fr-hue, 0deg) 100% 87%) 148deg,
    hsl(var(--fr-hue, 0deg) 100% 99%) 196deg,
    hsl(var(--fr-hue, 0deg) 100% 90%) 250deg,
    hsl(var(--fr-hue, 0deg) 100% 97%) 306deg,
    hsl(var(--fr-hue, 0deg) 100% 90%) 360deg);
}
/* THE THROW -- and the two rules that separate a photographed glow from a
   drawn one, both learned by getting it wrong first.

   NO WHITE IN THE HALO, EVER. The first pass led with a 2px near-white
   shadow, on the theory that light is white at its hottest. A drop-shadow
   on a RING throws both ways, so what that actually painted was a pale
   line hugging the band on the outside AND the inside -- piping. Piping is
   the single loudest "this is a sticker" cue there is, and no amount of
   bloom behind it recovers. Glass can be white; the air around it takes
   the colour of the light and nothing else.

   FALLOFF IS THE WHOLE ILLUSION, and it needs THREE radii, not two. Real
   bloom is roughly exponential: intense within a hair of the source, still
   clearly there at ten times that distance, gone by fifty. Two shadows can
   only draw a hot edge and a halo with a visible seam where one ends; the
   third (wide, dim, ~4x the middle) is what turns the pair into a curve
   and reads as light in air rather than a blurred copy of the shape. The
   alphas fall faster than the radii grow for the same reason.

   Scoped under both perf switches rather than raising --fr-bloom -- that
   var is set on BODY by Glow Bloom, so a value on the frame itself would
   sit closer to the element and quietly beat the switch. */
body:not(.sw-no-frames):not(.sw-no-bloom) .fr-rgb {
  filter:
    drop-shadow(0 0 3px hsl(var(--fr-hue, 0deg) 100% 62% / 0.8))
    drop-shadow(0 0 9px hsl(var(--fr-hue, 0deg) 100% 54% / 0.55))
    drop-shadow(0 0 24px hsl(var(--fr-hue, 0deg) 100% 50% / 0.34))
    var(--fr-spin);
}
/* CYCLE -- the other picture. The tuned plate's single hue is dropped
   entirely and the SPECTRUM appears here, on the spinner, so the two modes
   never overlap: one colour standing still, or every colour travelling. */
.fr-rgb.is-cycle {
  --fr-glow: rgba(180, 140, 255, 0.5);   /* the flat-outline fallback's colour */
}
/* The core is TRANSLUCENT white here, not solid. Solid white printed a hard
   pale line down the middle of the rainbow -- a stroke, not a hot spot, and
   the same piping problem the halo had. At 0.62 the travelling spectrum
   reads straight through it, so the core is the colour under it washed to
   near-white: which is what an overexposed tube actually looks like on
   camera. It also cannot go tinted (the tuned rule) -- --fr-hue is parked
   at 0 in cycle, so that would be a pale pink line around a rainbow. */
.fr-rgb.is-cycle .fr-accent { background: rgba(255, 255, 255, 0.62); }
/* A RAINBOW CANNOT HAVE A ONE-COLOUR HALO, and it cannot have a white one
   either. drop-shadow takes a single colour, so the honest approximation of
   what a camera does to an RGB tube is CHROMATIC: three spectrum colours at
   the three falloff distances, which mix in the air into a haze that
   belongs to the band instead of a grey blur of its shape. Same
   near/mid/far curve as the tuned plate above. */
body:not(.sw-no-frames):not(.sw-no-bloom) .fr-rgb.is-cycle {
  filter:
    drop-shadow(0 0 3px rgba(255, 90, 190, 0.55))
    drop-shadow(0 0 10px rgba(90, 175, 255, 0.45))
    drop-shadow(0 0 26px rgba(165, 95, 255, 0.32))
    var(--fr-spin);
}
.fr-rgb.is-cycle .fr-plate { background-image: none; background-color: #0b0f14; }
.fr-rgb.is-cycle .fr-plate::before {
  content: '';
  position: absolute;
  left: -50%;
  top: -50%;
  width: 200%;
  height: 200%;
  background: conic-gradient(from 0deg at 50% 50%,
    #ff2f5e 0deg, #ff7b2f 40deg, #ffd42f 80deg, #7bff4d 130deg,
    #2fffd4 175deg, #2fb0ff 215deg, #4d5bff 255deg, #a52fff 295deg,
    #ff2fb0 330deg, #ff2f5e 360deg);
  animation: fr-rgb-cycle 6s linear infinite;
}
@keyframes fr-rgb-cycle { to { transform: rotate(1turn); } }

/* ---- LEGEND SLAB -- SOLID GOLD, and the shine is the ANIMATION.

   THIS PLATE USED TO BE A STANDING CONIC: four fixed specular flashes, a
   near-black floor between them and a dirty olive mid, on the theory that
   polished metal is a mirror compressing a whole room into a few
   millimetres. That is true of a photograph of gold, and it is the wrong
   model for a 40px plate in a roster. At that size the dark two thirds of
   the ring do not read as shadow, they read as DIRT -- the plate looks
   tarnished, or worse, looks like a green-brown frame with yellow corners.
   Owner's call and the right one: the item is called LEGEND SLAB, so it
   should be a slab of gold, one confident colour all the way round.

   SO THE STRUCTURE INVERTS. The base is a near-flat gold -- a narrow
   light-to-deep ramp, nothing in it darker than gold and nothing in it
   grey -- and ALL the specular lives in a highlight that MOVES. A fixed
   highlight has to be dark somewhere to prove it is a highlight; a moving
   one does not, because the eye reads travel as reflection. That is the
   whole trade, and it is why "solid colour" and "shiny" stopped fighting
   each other here.

   THE SWEEP IS LINEAR ON PURPOSE, which is the one place the conic rule
   above does not apply. A standing linear ramp is banned on a ring because
   it crosses the band instead of running along it, so every side gets one
   flat slice of it. A TRAVELLING linear band is a different thing: it is a
   light source passing OVER the plate, so it crosses each side in turn and
   every side gets the whole event, just at different moments. Crossing is
   the point rather than the flaw.

   It glints rather than orbits: the streak takes 40% of the cycle to cross
   and spends the rest parked off the edge. A highlight that runs
   continuously is a lamp on a turntable -- unmissable and instantly cheap.
   One pass every few seconds is what real metal does when something moves
   in the room.

   The slabs stay the ENDS of the accent gradient: a white-hot lip over a
   saturated gold face, so each corner reads as a bevelled block catching
   light rather than a flat cream wedge. ---- */
.fr-slab {
  --fr-glow: rgba(255, 205, 80, 0.6);
  --fr-ring: var(--fr-ring-bevel);
}
.fr-slab .fr-plate {
  /* THE GOLD. One material, edge to edge: the darkest stop here is still
     unmistakably gold (#c98b12), so there is nowhere on the ring that can
     be mistaken for tarnish, shadow or brown. The ramp exists only to give
     the band THICKNESS -- outer face catching the room, inner face falling
     away -- which is the one cue that separates a ring from a painted
     line, and it spans a narrow enough range to still read as a single
     colour from across the screen. Everything brighter than this arrives
     with the sweep. */
  background: linear-gradient(158deg,
    #ffe9a3 0%, #ffd35e 16%, #f5b92f 38%,
    #e0a41e 58%, #c98b12 74%, #edb52c 90%, #ffdd7a 100%);
}
/* The corner slabs get a white-hot LIP over a gold face -- two steps, not
   one flat wedge. A corner is where the bevel turns hardest, so it is where
   a real plate throws its brightest and narrowest glint; giving the slabs
   the same cream as the band made them read as painted-on trim. */
.fr-slab .fr-accent {
  background: linear-gradient(155deg,
    #ffffff 0 4%, #fff4cf 4% 9%, rgba(255, 201, 74, 0.92) 9% 17%,
    transparent 17% 83%,
    rgba(255, 201, 74, 0.92) 83% 91%, #fff4cf 91% 96%, #ffffff 96%);
}
/* ---- THE GLINT. A single hard bar of light crossing the plate, and the
   only bright thing on this frame that is not painted into the gold.

   It is WHITE-HOT AND NARROW. A wide soft sweep is a wash -- it lifts the
   whole plate's brightness for a second and reads as the lights coming up,
   not as a reflection. The bar is one tight ramp to near-white with a
   thin warm shoulder on each side, so the plate goes from gold to hot and
   back over a couple of hundred milliseconds as it passes.

   TIMED AS A GLINT, NOT AS AN ORBIT: 40% of the cycle to cross, 60% parked
   off the edge. Continuous motion is a lamp on a turntable, and the eye
   stops seeing metal and starts seeing an animation. Skewed a few degrees
   off vertical because a perfectly upright bar reads as a wipe.

   MOTION RULES, unchanged and not optional: transform only (no filter, no
   background-position -- that repaints every frame, this composites a
   cached texture); on a pseudo-element INSIDE the clipped band rather than
   on the clipped element, so the bevelled ring never moves with its own
   highlight. Oversized in both axes so the skewed bar still covers the
   corners at the ends of its travel. ---- */
.fr-slab .fr-plate::before {
  content: '';
  position: absolute;
  top: -30%;
  left: -60%;
  width: 42%;
  height: 160%;
  background: linear-gradient(100deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 240, 190, 0.35) 34%,
    rgba(255, 255, 255, 0.95) 50%,
    rgba(255, 240, 190, 0.35) 66%,
    rgba(255, 255, 255, 0) 100%);
  transform: translateX(0) rotate(8deg);
  animation: fr-slab-glint 3.6s ease-in-out infinite;
}
@keyframes fr-slab-glint {
  0% { transform: translateX(0) rotate(8deg); }
  40%, 100% { transform: translateX(500%) rotate(8deg); }
}

/* ---- MISREGISTER -- three plates printed a hair out of line, the way a
   cheap press drops colour. The offset copies sit on the WRAPPER's
   pseudo-elements, not inside .fr-plate: a shifted child of a clipped
   parent gets cut at the parent's ring and the misregistration disappears
   -- which is the whole effect. ---- */
.fr-misreg {
  --fr-glow: rgba(200, 255, 230, 0.45);
  --fr-ring: var(--fr-ring-slip);
}
.fr-misreg .fr-plate {
  background: linear-gradient(155deg,
    #f2fff6 0%, #8fa9a0 40%, #101a16 55%, #cfe9d8 100%);
}
.fr-misreg::before,
.fr-misreg::after {
  content: '';
  position: absolute;
  inset: 0;
  clip-path: var(--fr-ring);
  mix-blend-mode: screen;
}
/* THE PRESS SLIPS -- and this is the CHEAPEST animation in the file, which
   is why the mythic frame is the one that gets to move constantly.

   Nothing here repaints. Both plates are flat single-colour fills that were
   already composited layers (they carry mix-blend-mode), so animating only
   their `transform` is pure compositor work: the browser re-uses the same
   two textures every frame and just moves them. No gradient re-rasterises,
   no filter re-runs, no layout is read. Compare SHEEN, which at least has
   to keep a conic gradient's texture around, and TICKS the same -- this one
   costs less than either while never sitting still.

   Smoothness comes from ease-in-out and COPRIME-ish durations (7s / 9s):
   the two plates drift independently and only rarely line up, so it reads
   as a press wandering out of registration rather than two divs sharing a
   keyframe. Amplitude stays inside ~1.5px of the static offsets on purpose
   -- past that the face stops looking mis-printed and starts looking like
   it is vibrating, and at the 40px roster size that is all anyone sees. */
.fr-misreg::before {
  background: #00b7ff;
  transform: translate(-2px, -2px);
  animation: fr-misreg-c 7s ease-in-out infinite;
}
.fr-misreg::after {
  background: #ff2fd0;
  transform: translate(2px, 2px);
  animation: fr-misreg-m 9s ease-in-out infinite;
}
@keyframes fr-misreg-c {
  0%, 100% { transform: translate(-2px, -2px); }
  33% { transform: translate(-3.5px, -1px); }
  66% { transform: translate(-1px, -3px); }
}
@keyframes fr-misreg-m {
  0%, 100% { transform: translate(2px, 2px); }
  33% { transform: translate(1px, 3.5px); }
  66% { transform: translate(3px, 1px); }
}
/* The scan sweep: one static diagonal, no animation. */
.fr-misreg .fr-accent {
  background: linear-gradient(155deg,
    transparent 0 38%, rgba(255, 255, 255, 0.75) 48%, transparent 58%);
}

/* ---- SONAR -- a scope sweeping. Deep sea-green base with faint range
   rings (repeating-radial, % stops), a few fixed contact blips, and ONE
   moving thing: the beam, a conic wedge with a sharp leading edge and a
   long decaying tail, rotating on TICKS' pseudo mechanism. The tail is
   the whole effect -- a symmetric wedge is a fan, but leading-edge-sharp /
   trailing-edge-soft is unmistakably a phosphor trace decaying. ---- */
.fr-sonar {
  --fr-glow: rgba(90, 255, 190, 0.4);
  --fr-ring: var(--fr-ring-oct);
}
.fr-sonar .fr-plate {
  background:
    radial-gradient(circle at 22% 30%, rgba(120, 255, 200, 0.9) 0 1.4%, transparent 2.4%),
    radial-gradient(circle at 74% 68%, rgba(120, 255, 200, 0.8) 0 1.2%, transparent 2.2%),
    radial-gradient(circle at 60% 18%, rgba(120, 255, 200, 0.7) 0 1%, transparent 2%),
    repeating-radial-gradient(circle at 50% 50%,
      rgba(90, 255, 190, 0.14) 0 0.5%, transparent 0.5% 9%),
    radial-gradient(circle at 50% 50%, #0b2e22 0 60%, #061a13 100%);
}
.fr-sonar .fr-accent { opacity: 0.9; }
.fr-sonar .fr-accent::before {
  content: '';
  position: absolute;
  left: -50%;
  top: -50%;
  width: 200%;
  height: 200%;
  background: conic-gradient(from 0deg at 50% 50%,
    rgba(120, 255, 200, 0.85) 0deg, rgba(120, 255, 200, 0.4) 24deg,
    rgba(120, 255, 200, 0.12) 70deg, transparent 130deg 360deg);
  animation: fr-sonar-sweep 3.8s linear infinite;
}
@keyframes fr-sonar-sweep { to { transform: rotate(1turn); } }

/* ---- THE PAGE-TWO LAW, learned by shipping its violation: A FRAME MUST
   READ AT 40 PIXELS AS ONE BOLD THING IN MOTION. The first page-two batch
   was texture and subtlety -- fine dot fields, low alphas, occasional
   flickers -- and every one of them dissolved into grey at roster size.
   SONAR survived alone, because a single bright object sweeping is legible
   anywhere. So each of these leads with one big, high-contrast moving
   element, and the silhouettes go experimental with the same confidence:
   a shape you can name from across the room. ---- */

/* ---- MARQUEE -- theatre bulbs chasing around the board. The chase is a
   repeating-conic of fat lit segments rotated in STEPS, not smoothly:
   steps(2) jumps the pattern half a period at a time, which is exactly
   what chasing bulbs do -- light does not slide from bulb to bulb, the
   next one comes on. ---- */
.fr-marquee {
  --fr-glow: rgba(255, 210, 87, 0.55);
  --fr-ring: var(--fr-ring-blocks);
}
.fr-marquee .fr-plate {
  background: linear-gradient(155deg, #33200a 0%, #1c1005 55%, #2b1a08 100%);
}
.fr-marquee .fr-accent::before {
  content: '';
  position: absolute;
  left: -50%;
  top: -50%;
  width: 200%;
  height: 200%;
  background: repeating-conic-gradient(from 0deg at 50% 50%,
    #ffd257 0deg 8deg, rgba(70, 40, 10, 0.9) 8deg 16deg);
  animation: fr-marquee-chase 0.6s steps(2) infinite;
}
@keyframes fr-marquee-chase { to { transform: rotate(16deg); } }

/* ---- LEVELS -- the meter bridge, pumping. A green-to-red gradient masked
   into fat bars, scaled from the bottom on jagged keyframes; a second bar
   set on its own clock so columns alternate rather than breathe together.
   The mask scales WITH the pseudo (a mask paints in its element's box), so
   the bars stay bars at every excursion -- and the colour gradient rides
   the scale too, which is what a real meter does: red only shows when the
   bar is tall enough to reach it. ---- */
.fr-levels {
  --fr-glow: rgba(57, 255, 20, 0.45);
  --fr-ring: var(--fr-ring-eq);
}
.fr-levels .fr-plate { background: #06100a; }
.fr-levels .fr-plate::before,
.fr-levels .fr-plate::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(0deg,
    #39ff14 0 34%, #b8ff2f 34% 52%, #ffd42f 52% 72%, #ff5a2f 72% 100%);
  transform-origin: 50% 100%;
}
.fr-levels .fr-plate::before {
  mask-image: repeating-linear-gradient(90deg, #fff 0 4.5%, transparent 4.5% 9%);
  animation: fr-levels-a 0.44s linear infinite;
}
.fr-levels .fr-plate::after {
  mask-image: repeating-linear-gradient(90deg, transparent 0 4.5%, #fff 4.5% 9%);
  opacity: 0.85;
  animation: fr-levels-b 0.37s linear infinite;
}
@keyframes fr-levels-a {
  0%, 100% { transform: scaleY(0.55); }
  22% { transform: scaleY(0.95); }
  46% { transform: scaleY(0.62); }
  68% { transform: scaleY(1); }
  85% { transform: scaleY(0.7); }
}
@keyframes fr-levels-b {
  0%, 100% { transform: scaleY(0.8); }
  19% { transform: scaleY(0.5); }
  42% { transform: scaleY(1); }
  66% { transform: scaleY(0.58); }
  87% { transform: scaleY(0.9); }
}

/* ---- DAZZLE -- a checkerboard that INVERTS. Two identical boards half a
   tile apart, alternating on a steps(1) clock so one is always on: the
   squares do not fade or slide, they swap, which is the one motion here
   with no direction at all. Cheapest possible checkerboard: a four-stop
   conic tiled by background-size, no masks and no elements. ---- */
.fr-dazzle {
  --fr-glow: rgba(255, 255, 255, 0.35);
  --fr-ring: var(--fr-ring-bite);
}
.fr-dazzle .fr-plate { background: #0a0a0c; }
.fr-dazzle .fr-plate::before,
.fr-dazzle .fr-plate::after {
  content: '';
  position: absolute;
  inset: 0;
  background: conic-gradient(
    #f2f2f5 0 25%, #101014 0 50%, #f2f2f5 0 75%, #101014 0) 0 0 / 13% 13%;
}
.fr-dazzle .fr-plate::before { animation: fr-dazzle-a 0.72s steps(1) infinite; }
.fr-dazzle .fr-plate::after {
  background-position: 6.5% 6.5%;
  animation: fr-dazzle-b 0.72s steps(1) infinite;
}
@keyframes fr-dazzle-a { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes fr-dazzle-b { 0%, 49.9% { opacity: 0; } 50%, 100% { opacity: 1; } }

/* ---- TESLA -- arcs STRIKING, not sweeping. Three narrow white-blue
   spikes on a conic, rotated by steps(7): seven positions, three spikes,
   and 7 shares no factor with 3, so no strike lands where the last one
   did until the whole cycle comes round. That is what buys the randomness
   -- a smooth rotation would be SONAR, and steps(3) would be a pattern
   you can predict after one turn. ---- */
.fr-tesla {
  --fr-glow: rgba(160, 220, 255, 0.55);
  --fr-ring: var(--fr-ring-spark);
}
.fr-tesla .fr-plate {
  background: linear-gradient(155deg, #0b1622 0%, #050810 55%, #0a1420 100%);
}
.fr-tesla .fr-accent::before {
  content: '';
  position: absolute;
  left: -50%;
  top: -50%;
  width: 200%;
  height: 200%;
  background: conic-gradient(from 0deg at 50% 50%,
    #eaf6ff 0deg 2.5deg, rgba(120, 190, 255, 0.25) 2.5deg 6deg,
    transparent 6deg 118deg,
    #d6ecff 118deg 121deg, rgba(120, 190, 255, 0.2) 121deg 124deg,
    transparent 124deg 235deg,
    #eaf6ff 235deg 237.5deg, rgba(120, 190, 255, 0.25) 237.5deg 241deg,
    transparent 241deg 360deg);
  animation: fr-tesla-arc 0.95s steps(7) infinite;
}
@keyframes fr-tesla-arc { to { transform: rotate(1turn); } }

/* ---- the perf switches ----
   Glow Bloom kills the halo (the frames read the same tokens everything
   else on the menu does). Avatar Frames collapses every plate to a flat
   one-pixel outline: no gradients, no ticks, no sheen, no offset plates --
   seven of these in a full roster is the case that switch exists for. */
body.sw-no-bloom { --fr-bloom: 0px; }
body.sw-no-frames .sw-frame {
  filter: none;
  /* SOUND SYSTEM's pump lives on the wrapper, which this switch keeps
     (it becomes the flat outline) rather than hiding -- so the animation
     has to be stopped by name or the perf mode still has a plate scaling
     up and down on every roster row. */
  animation: none;
  border: 1px solid var(--fr-glow, rgba(134, 255, 102, 0.5));
  border-radius: 3px;
}
body.sw-no-frames .sw-frame > i,
body.sw-no-frames .sw-frame::before,
body.sw-no-frames .sw-frame::after { display: none; }

/* On-air is information; the throb is ambience -- same call the roster's
   speaking ring already makes. */
@media (prefers-reduced-motion: reduce) {
  .fr-ticks .fr-accent::before,
  .fr-hazard .fr-plate::before,
  .fr-prism .fr-plate::before,
  .fr-crt .fr-plate::before,
  .fr-copy .fr-plate::before,
  /* Both jaw pieces park at their rest pose -- closed enough to read. */
  .fr-jaws .fr-plate::before,
  .fr-jaws .fr-plate::after,
  .fr-sonar .fr-accent::before,
  /* Page-two batch: sweeps and chases hold still; LEVELS parks at natural
     scale (full bars), HEATER parks lit. */
  .fr-marquee .fr-accent::before,
  .fr-dazzle .fr-plate::before,
  .fr-dazzle .fr-plate::after,
  .fr-tesla .fr-accent::before,
  .fr-levels .fr-plate::before,
  .fr-levels .fr-plate::after,
  /* Both glint fields; parked they sit at their authored (lit) state, so a
     reduced-motion pavé is fully iced rather than dark. */
  .fr-iced .fr-plate::before,
  .fr-iced .fr-accent,
  /* SOUND SYSTEM moves the whole plate, so the animation to stop is the
     wrapper's own rather than a pseudo's. */
  .fr-stack,
  /* Safe to simply stop: the glint's rest position is off the plate's left
     edge, so a frozen one is an invisible one rather than a white bar
     parked across the gold. The plate keeps its own colour, which is the
     whole material anyway. */
  .fr-slab .fr-plate::before,
  .fr-misreg::before,
  .fr-misreg::after,
  .fr-rgb.is-cycle .fr-plate::before { animation: none; }
  /* A stopped polish keeps its glints -- they park wherever the gradient
     is authored, which is a lit plate, not a dark one. */
  /* A stopped CYCLE must not become a plain dark ring -- the wearer bought
     a spectrum. The spinner keeps its gradient and simply holds still, so
     reduced motion costs the movement and nothing else. */
}

/* ---- previews: the store card, the store detail hero, the store mock row
   and the inventory cell. A frame is a thing you wear, so every preview
   wears it on your own face rather than showing a symbol for it. ---- */
.shop-frame-face,
.inv-frame-face {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 58px;
  height: 58px;
  font-size: 28px;
  line-height: 1;
  background: #12240f;
}
.shop-frame-face > img,
.inv-frame-face > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.shop-frame-face.is-hero { width: 104px; height: 104px; font-size: 50px; }

/* ---- the frame card has NO PANEL ----
   A frame IS a picture, so the picture is the card. Every other shop card
   is a plate with an art window punched in it and a body strip underneath,
   which is right for an item you can only draw a symbol for -- put a frame
   in that window and you get a 58px chip marooned in a grid-lined box,
   with "AVATAR FRAME" printed above a name in a panel below it, on a shelf
   the player already filtered to avatar frames. Three pieces of chrome and
   a label, all louder than the thing being sold.

   So: no plate, no window, no body strip. The frame fills the cell, and
   the name and the price sit directly under it as plain text. Rarity keeps
   its colour language but moves from a band across the top of a panel that
   no longer exists to a short rule between the picture and the name. */
.shop-card.is-frame-card {
  background: transparent;
  border: none;
  clip-path: none;
  overflow: visible;
  text-align: center;
}
.shop-card.is-frame-card:hover,
.shop-card.is-frame-card:focus-visible {
  /* The lift stays -- it's how every card says it is clickable -- but the
     extrusion shadows drew the WALL of a plate, and there is no plate. */
  transform: translateY(-6px);
  border-color: transparent;
  box-shadow: none;
}
.is-frame-card .shop-art {
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border-bottom: none;
  /* The plate overhangs its face by 11% and the halo further still; the
     window used to clip both. Horizontal padding only -- see the height
     rule on the face below for why the vertical fit isn't padding. */
  overflow: visible;
  padding: 0 6%;
}
.is-frame-card .shop-frame-face {
  width: auto;
  /* NOT 100% with vertical padding on the parent. A percentage padding
     resolves against the container's WIDTH, so on a card that is wider
     than it is tall (every card here) it reserves the wrong amount and
     the plate spills past the picture onto the name -- measured at 9px,
     with hover's scale putting the halo straight through it. The plate is
     122% of the face it wraps (inset:-11% each side), so sizing the face
     at a fraction of the box makes the fit a RATIO: correct at every card
     size, with the remainder left for the halo and the hover scale. */
  height: 74%;
  max-width: 100%;
  aspect-ratio: 1 / 1;
  font-size: clamp(30px, 4 * var(--dvw, 1vw), 84px);
  transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.is-frame-card:hover .shop-frame-face { transform: scale(1.06); }
/* The shelf is already filtered to avatar frames and the dots repeat
   colours the plate itself is wearing -- both were saying what the picture
   says better. */
.is-frame-card .shop-kind,
.is-frame-card .shop-dots { display: none; }
.is-frame-card .shop-body { padding: 14px 10px 4px; align-items: center; gap: 5px; }
.is-frame-card .shop-price-row { margin-top: 0; justify-content: center; }
/* NO RARITY BAR ON A FRAME CARD -- WITH ONE EXCEPTION.
   On every other frame card the band had to go: the card has no plate for
   it to be a band ACROSS, so it floated under the picture as a stray
   coloured line, and it was saying a second time what the plate already
   says in colour and material.
   CHROMA is the exception because its bar is not a tier marker, it is the
   ITEM: the spectrum, running. The card can't show the cycle any other way
   -- the preview wears whatever hue the shopper has tuned (their own face,
   their own setting, which is the honest preview), so without this line
   the one thing that separates a million-cred frame from a tuned colour
   never appears on the shelf at all.
   It TRAVELS rather than sitting still, for the same reason the plate's
   spectrum does: a static rainbow is a rainbow, a moving one is a cycle.
   Transform on an oversized child, never background-position -- same rule
   as the frames, one cached texture sliding. */
.is-frame-card .shop-rare-bar { display: none; }
.is-frame-card.r-chroma .shop-rare-bar {
  display: block;
  top: auto;
  bottom: 0;
  left: 50%;
  right: auto;
  width: 64px;
  height: 3px;
  transform: translateX(-50%);
  overflow: hidden;
  background: none;
  box-shadow: 0 0 12px rgba(255, 255, 255, 0.35);
}
.is-frame-card.r-chroma .shop-rare-bar::after {
  content: '';
  position: absolute;
  inset: 0;
  width: 200%;
  /* Two full spectra end to end, so the wrap lands on the colour it left
     and the loop has no seam to notice. */
  background: linear-gradient(90deg,
    #ff2f5e, #ffd42f, #7bff4d, #2fb0ff, #a52fff, #ff2f5e,
    #ffd42f, #7bff4d, #2fb0ff, #a52fff, #ff2f5e);
  animation: fr-shop-chroma 6s linear infinite;
}
@keyframes fr-shop-chroma { to { transform: translateX(-50%); } }
#sd2-glyph.is-frame-glyph { display: grid; place-items: center; }
/* Frame cells want a little more room than a plain glyph cell: the plate
   plus a name plus a price does not fit the default square. */
.inv-grid-frame .inv-cell { min-height: 132px; }
.inv-cell-frame .inv-name { font-size: 11px; letter-spacing: 1px; }

/* ---- THE HUE TUNER ----
   Lives inside the CHROMA cell (see invRender), so it only ever appears on
   the frame it drives. The rail's track IS the spectrum it selects from --
   a neutral track with a number beside it would make the player guess what
   210 looks like, and this control exists precisely so they don't have to.
   The track is authored once, in the same order as the plate's conic, so
   dragging right walks the plate the same direction the rail does. */
.inv-hue {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  margin-top: 6px;
  cursor: default;
}
.inv-hue-range {
  flex: 1 1 auto;
  min-width: 0;
  height: 12px;
  margin: 0;
  -webkit-appearance: none;
  appearance: none;
  background: linear-gradient(90deg,
    #ff2f5e 0%, #ff7b2f 11%, #ffd42f 22%, #7bff4d 36%,
    #2fffd4 49%, #2fb0ff 60%, #4d5bff 71%, #a52fff 82%,
    #ff2fb0 92%, #ff2f5e 100%);
  border: 1px solid var(--sh-line, rgba(255, 255, 255, 0.25));
  cursor: pointer;
}
.inv-hue-range:disabled { filter: grayscale(1) brightness(0.5); cursor: default; }
/* The thumb is a bare white bar, not a coloured knob: a knob carrying its
   own colour competes with the track for "which of these is the value". */
.inv-hue-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 6px;
  height: 18px;
  background: #fff;
  border: 1px solid #000;
  cursor: pointer;
}
.inv-hue-range::-moz-range-thumb {
  width: 6px;
  height: 18px;
  border-radius: 0;
  background: #fff;
  border: 1px solid #000;
  cursor: pointer;
}
.inv-hue-cycle {
  flex: none;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 1px;
  padding: 4px 6px;
  background: transparent;
  color: var(--sh-dim, #8f76a8);
  border: 1px solid currentColor;
  cursor: pointer;
}
.inv-hue-cycle.is-on {
  color: #fff;
  background: linear-gradient(90deg,
    rgba(255, 47, 94, 0.55), rgba(47, 176, 255, 0.55), rgba(165, 47, 255, 0.55));
  border-color: #fff;
}
.inv-cell-frame.is-locked .inv-frame-face { opacity: 0.75; }

/* ==========================================================================
   FONT FX  //  文字効果
   ==========================================================================
   Effects layered over the NAME, the third wearable kind. Every effect is
   built from the text itself -- glyph fills via background-clip:text,
   throws via text-shadow, motion via transform/opacity on the element --
   which is what makes the whole page FONT-AGNOSTIC: a new font needs zero
   per-fx work, because nothing here knows what the letters look like.

   THE ID IS A CSS CLASS FROM A CLOSED LIST (applyPlayerFont checks
   FX_CATALOG before hanging it on anything -- the value arrives from other
   players; same rule as frames). All sizes are in EM, the text world's
   scale-invariance: a name at 14px and the same name at 48px wear the
   same design.

   PERF, stated honestly: some of these animate text-shadow, which is a
   PAINT, not a composite -- the property class the frames ban outright.
   It is allowed here because the painted area is a name (a few hundred
   pixels), not a plate, and every animated effect dies under the Font FX
   switch (sw-no-fontfx): animation stops AND the throw drops, while the
   glyph FILL stays -- a static fill costs nothing and still says who owns
   what, the same call as a frame collapsing to its 1px outline.
   DEAD CHANNEL's boil steps background-position, banned on plates for the
   same repaint reason, same size argument, same switch.
   ========================================================================== */

/* ---- HAZARD TAPE -- the frame's crawl inside the letters: a four-stop
   45deg tile, translating one own-tile per loop (the loop law -- change
   the size and the keyframe moves with it). ---- */
/* Amber dominates 34/16 here where the FRAME keeps even 25/25 stripes:
   on the plate black is background, but in the LETTERS black is missing
   ink -- at even widths it ate the glyph strokes and the name got hard to
   read. The crawl also runs faster than the frame's (1.1s): at name size
   a slower background-position crawl steps visibly. */
.fx-tape {
  background:
    linear-gradient(45deg,
      #f5b301 0 34%, #12100a 34% 50%, #f5b301 50% 84%, #12100a 84% 100%)
      0 0 / 0.5em 0.5em;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent !important;
  -webkit-text-fill-color: transparent;
  animation: fx-tape-crawl 1.1s linear infinite;
}
@keyframes fx-tape-crawl {
  to { background-position: 0.5em 0; }
}

/* ---- AURORA -- the tuneable one: the wearer's own two or three hues
   flowing through the letters. CHROMA's contract on text: the palette is
   NUMBERS end to end (--fx-h1/2/3 are written by applyPlayerFont as
   integer deg, sanitized both sides), the gradient is authored in hsl()
   reading those vars, and it closes on the first hue. ease-in-out
   ALTERNATE rather than a wrapping slide: a back-and-forth sheet of light
   is what an aurora does, and it needs no wrap point to hide. ---- */
.fx-aurora {
  /* Full-colour vars (--fx-cN), not bare hues: a palette slot can be the
     BLACK sentinel, and applyPlayerFont builds each string from a
     validated integer either way. */
  background: linear-gradient(100deg,
    var(--fx-c1, hsl(300deg 90% 60%)) 0%,
    var(--fx-c2, hsl(180deg 90% 60%)) 34%,
    var(--fx-c3, hsl(300deg 90% 60%)) 66%,
    var(--fx-c1, hsl(300deg 90% 60%)) 100%)
    0% 50% / 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent !important;
  -webkit-text-fill-color: transparent;
  animation: fx-aurora-flow 4.2s ease-in-out infinite alternate;
}
@keyframes fx-aurora-flow {
  from { background-position: 0% 50%; }
  to { background-position: 100% 50%; }
}

/* ---- HOUSE GLOWS RETIRE UNDER ANY FX. Surfaces that paint their own
   green text-shadow on a name (the profile card's plate, the home badge's
   inherited glow) radiate it around an fx fill as a halo the player never
   chose -- amber tape letters in a green cloud. The marker class from
   applyPlayerFont drops them, scoped WITHOUT !important so an fx that
   brings its own throw still wins via its keyframes; the bare class rule
   is the catch-all for glows that arrive by inheritance. ---- */
.has-fontfx { text-shadow: none; }
#pv-name.has-fontfx { text-shadow: none; }
#hpb-text .has-fontfx { text-shadow: none; }

/* ---- the per-letter chassis: every split glyph is its own box, indexed
   by --ci for staggered delays. inline-block is what lets a letter move. */
.fxch { display: inline-block; }

/* ---- WIREFRAME -- an actual wireframe, not just a hollow stroke: the
   contour is the text-stroke, and the GLYPH INTERIOR carries the mesh --
   a fine engineering grid clipped into the letterforms, with brighter
   vertex dots where the lines cross. Reading order matters: dots first
   (over), then verticals, then horizontals, then the near-transparent
   panel that keeps the mesh from vanishing on dark rows. Static -- a
   drawing does not animate, and em keeps the mesh density identical at
   every size. */
.fx-wire {
  color: transparent !important;
  -webkit-text-fill-color: transparent;
  -webkit-text-stroke: 0.032em #d8e4f2;
  background:
    radial-gradient(circle at 0 0,
      rgba(216, 228, 242, 0.9) 0 0.022em, rgba(0, 0, 0, 0) 0.03em)
      0 0 / 0.18em 0.18em,
    repeating-linear-gradient(90deg,
      rgba(160, 190, 230, 0.45) 0 0.014em, rgba(0, 0, 0, 0) 0.014em 0.18em),
    repeating-linear-gradient(0deg,
      rgba(160, 190, 230, 0.45) 0 0.014em, rgba(0, 0, 0, 0) 0.014em 0.18em),
    rgba(90, 120, 160, 0.12);
  -webkit-background-clip: text;
  background-clip: text;
}

/* ---- THE WAVE -- the OSRS classic: letters bobbing in sequence. The
   NEGATIVE delay times --ci is the whole trick: every letter runs the same
   loop, each started a beat earlier down the line, and the phase offsets
   read as a travelling wave. ---- */
.fx-wave .fxch {
  animation: fx-wave-bob 1.15s ease-in-out infinite;
  animation-delay: calc(var(--ci) * -0.115s);
}
@keyframes fx-wave-bob {
  0%, 100% { transform: translateY(0.05em); }
  50% { transform: translateY(-0.16em); }
}

/* ---- ARCADE -- per-letter colours stepping through the palette on
   steps(1): no fades, ever, because an arcade cabinet has no tweens. The
   stagger makes neighbouring letters always disagree. ---- */
.fx-arcade .fxch {
  animation: fx-arcade-step 1.8s steps(1) infinite;
  animation-delay: calc(var(--ci) * -0.3s);
}
@keyframes fx-arcade-step {
  0%, 100% { color: #ff2f5e; }
  16.6% { color: #ff8a1e; }
  33.3% { color: #ffd42f; }
  50% { color: #4dff7a; }
  66.6% { color: #2fb0ff; }
  83.3% { color: #c44dff; }
}

/* ---- FAULTY -- a neon sign of the name where letters keep dying. The
   sign itself is steady (colour + throw on the parent, inherited by every
   glyph); TWO flicker clocks share no factor and are assigned by
   nth-child stripes, so which letters buzz out drifts around the name
   instead of always being the same pair. The dips are BRIEF -- a sign
   that is mostly off is broken, one that mostly works is haunted. ---- */
.fx-faulty {
  color: #ff9ad8 !important;
  text-shadow: 0 0 0.12em rgba(255, 47, 176, 0.9), 0 0 0.4em rgba(255, 47, 176, 0.5);
}
.fx-faulty .fxch { animation: fx-faulty-a 3.7s linear infinite; }
.fx-faulty .fxch:nth-child(3n + 2) { animation: fx-faulty-b 2.9s linear infinite; }
@keyframes fx-faulty-a {
  0%, 100% { opacity: 1; }
  81% { opacity: 1; }
  82%, 84% { opacity: 0.25; }
  85% { opacity: 1; }
  91% { opacity: 1; }
  92% { opacity: 0.4; }
  93% { opacity: 1; }
}
@keyframes fx-faulty-b {
  0%, 100% { opacity: 1; }
  37% { opacity: 1; }
  38%, 41% { opacity: 0.2; }
  42% { opacity: 1; }
  67% { opacity: 1; }
  68% { opacity: 0.5; }
  69% { opacity: 1; }
}

/* ---- TRACKING -- VHS tear. Two attr() copies of the name, statically
   clipped to top and bottom halves (the clip never animates -- the motion
   rules' oldest law -- only their transforms do), aligned dead-on most of
   the cycle and shearing apart in brief opposite jolts. The base text
   stays visible underneath, so a shear leaves a ghost of the whole name
   behind the halves -- which is exactly what worn tape does. ---- */
.fx-track {
  position: relative;
  display: inline-block;
}
.fx-track::before,
.fx-track::after {
  content: attr(data-fx-text);
  position: absolute;
  inset: 0;
  padding: inherit;
  display: flex;
  align-items: inherit;
  justify-content: inherit;
  pointer-events: none;
}
.fx-track::before {
  clip-path: inset(0 0 50% 0);
  animation: fx-track-top 4.2s linear infinite;
}
.fx-track::after {
  clip-path: inset(50% 0 0 0);
  animation: fx-track-bot 4.2s linear infinite;
}
@keyframes fx-track-top {
  0%, 55% { transform: translateX(0); }
  57%, 60% { transform: translateX(0.09em); }
  62%, 78% { transform: translateX(0); }
  80%, 82% { transform: translateX(-0.13em); }
  84%, 100% { transform: translateX(0); }
}
@keyframes fx-track-bot {
  0%, 55% { transform: translateX(0); }
  57%, 60% { transform: translateX(-0.07em); }
  62%, 78% { transform: translateX(0); }
  80%, 82% { transform: translateX(0.1em); }
  84%, 100% { transform: translateX(0); }
}

/* ---- HINGED -- the whole name swinging from one nail. transform-origin
   sits ABOVE the text (the nail is in the wall, not in the letters), and
   ease-in-out alternate puts the slow-down at the ends of the arc where a
   real pendulum spends its time. The smallest amplitude that still reads:
   past ~3deg a hanging sign becomes a metronome. ---- */
.fx-hinge {
  display: inline-block;
  transform-origin: 50% -0.2em;
  animation: fx-hinge-swing 2.4s ease-in-out infinite alternate;
}
@keyframes fx-hinge-swing {
  from { transform: rotate(-2.6deg); }
  to { transform: rotate(2.6deg); }
}

/* ---- SPLIT-FLAP -- the departure board. Each letter tumbles over on its
   own long clock (staggered by a coprime-ish -0.37s per index, so the
   flips land scattered, never as a sweep), spending most of the cycle at
   rest -- a board that never stops flipping never says anything. The
   perspective lives IN the transform value so no wrapper is needed, and
   the mid-flip mirror image is authentic: real flaps show you the back of
   the card. ---- */
.fx-flap .fxch {
  animation: fx-flap-tick 4.8s linear infinite;
  animation-delay: calc(var(--ci) * -0.37s);
}
@keyframes fx-flap-tick {
  0%, 86% { transform: perspective(6em) rotateX(0); }
  93% { transform: perspective(6em) rotateX(180deg); }
  100% { transform: perspective(6em) rotateX(360deg); }
}

/* ---- the switch -- animations stop, fills and strokes stay, and the
   pieces that would LIE when frozen disappear: a parked censor bar is a
   name permanently blacked out, parked fireflies are stains, aligned
   tracking copies are free bold. The per-letter split stays split (it is
   inert without its animations). ---- */
body.sw-no-fontfx .fx-tape,
body.sw-no-fontfx .fx-aurora,
body.sw-no-fontfx .fx-hinge,
body.sw-no-fontfx .fx-wave .fxch,
body.sw-no-fontfx .fx-flap .fxch,
body.sw-no-fontfx .fx-arcade .fxch,
body.sw-no-fontfx .fx-faulty .fxch,
body.sw-no-fontfx .fx-faulty { animation: none; text-shadow: none; }
body.sw-no-fontfx .fx-track::before,
body.sw-no-fontfx .fx-track::after { display: none; }

@media (prefers-reduced-motion: reduce) {
  .fx-tape, .fx-aurora, .fx-hinge, .fx-wave .fxch, .fx-arcade .fxch,
  .fx-faulty .fxch, .fx-flap .fxch { animation: none; }
}

/* ---- the inventory cells ---- */
#profile-inventory #inv-grid.inv-grid-fx { grid-template-columns: repeat(2, 1fr); }
.inv-cell-fx { position: relative; padding: 14px 10px; min-height: 120px; }
.inv-fx-sample {
  display: block;
  font-size: 24px;
  line-height: 1.4;
  color: #eafff0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
  padding: 0.2em 0.5em;
}
.inv-cell-fx .inv-name { font-size: 11px; letter-spacing: 1px; }
.inv-cell-fx.is-locked .inv-fx-sample { opacity: 0.8; }

/* SHOWCASE THEME cells: wide like fx cells (long names), previewed by a
   palette swatch -- a theme is a whole screen, and a postage-stamp
   screenshot of one would lie about it. */
#profile-inventory #inv-grid.inv-grid-theme { grid-template-columns: repeat(2, 1fr); }
.inv-cell-theme { position: relative; padding: 14px 10px; min-height: 120px; }
.inv-theme-swatch {
  display: block;
  width: 72px;
  height: 40px;
  margin: 4px auto 8px;
  border-radius: 4px;
  /* THE CLIP EATS THE BORDER rule doesn't apply -- no clip-path here --
     but the inset ring is still the cheap crisp outline at the root zoom. */
  box-shadow: inset 0 0 0 1px rgba(134, 255, 194, 0.35);
}
.inv-cell-theme .inv-name { font-size: 11px; letter-spacing: 1px; }
.inv-cell-theme.is-locked .inv-theme-swatch { opacity: 0.8; }

/* The store's fx preview: a name, not a glyph, so it gets text manners the
   glyph never needed -- a readable base colour for the OUTSIDE effects
   (fill effects override it), one line, and an ellipsis instead of a
   spill when the name is long. */
.shop-fx-sample {
  color: #eafff0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 92%;
  padding: 0 0.4em;
}

/* ---- THE NO-PANEL TREATMENT GOES SHELF-WIDE for the kinds whose preview
   IS the item: font cards and fx cards join the frame cards (owner call:
   'just the product and the price'). Same reasoning as the frame block
   above -- a name rendered live does not need an art window, a kind label
   the filter already states, or dot swatches repeating colours the preview
   itself is wearing. The card is the name; the price sits under it. */
.shop-card.is-font-card,
.shop-card.is-fx-card {
  background: transparent;
  border: none;
  clip-path: none;
  overflow: visible;
  text-align: center;
}
.shop-card.is-font-card:hover,
.shop-card.is-font-card:focus-visible,
.shop-card.is-fx-card:hover,
.shop-card.is-fx-card:focus-visible {
  transform: translateY(-6px);
  border-color: transparent;
  box-shadow: none;
}
.is-font-card .shop-art,
.is-fx-card .shop-art {
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border-bottom: none;
  overflow: visible;
  padding: 0 4%;
}
/* The throws (AFTERGLOW's bloom, TORCHED's gutter) need air; an overflow
   window would slice them at the art box, which on a glow reads as the
   Firefox layer-clip bug all over again. */
.is-fx-card .shop-glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: 4% 6%;
  text-align: center;
  line-height: 1.1;
  overflow: visible;
  filter: none;   /* the fx brings its own throw; the house glyph glow would double it */
}
.is-font-card .shop-kind,
.is-font-card .shop-dots,
.is-fx-card .shop-kind,
.is-fx-card .shop-dots { display: none; }
.is-font-card .shop-body,
.is-fx-card .shop-body { padding: 14px 10px 4px; align-items: center; gap: 5px; background: none; border: none; }
.is-font-card .shop-price-row,
.is-fx-card .shop-price-row { margin-top: 0; justify-content: center; }
.is-font-card .shop-rare-bar,
.is-fx-card .shop-rare-bar {
  top: auto;
  bottom: 0;
  left: 50%;
  right: auto;
  width: 56px;
  height: 2px;
  transform: translateX(-50%);
}
/* Bigger stage for the fx preview -- the fit search's ceiling rose with
   it (see shopRender), the CSS just stops crowding the result. */
.shop-fx-sample { max-width: 96%; }

/* AURORA's palette rail: two or three sliders stacked, the count toggle
   beneath -- frameHueRail's chassis, vertical. */
.inv-hue-stack { flex-direction: column; align-items: stretch; gap: 5px; }
.inv-hue-stack .inv-hue-cycle { align-self: center; }

/* THE NAME FIELD'S FX MIRROR -- the identity name is an <input>, which
   cannot wear fx (no pseudo copies, unreliable glyph clipping), so a twin
   span shares its exact box and font vars and wears the effect while the
   field is not being edited; the input's own text goes transparent under
   it. Focus hides the mirror and restores the plain input -- see
   profileNameFxSync. Same grid-area trick as the input: absolute against
   .name-autosize, same padding, same font vars, so glyphs land on glyphs. */
#profile-name-fx {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 4px 8px 4px 0;
  font-family: var(--player-name-font);
  font-size: var(--player-name-size, 48px);
  font-weight: bold;
  letter-spacing: 1px;
  white-space: pre;
  overflow: hidden;
  pointer-events: none;
  color: #eafff0;
}
#profile-name-fx.hidden { display: none; }
#profile-name.fx-text-hidden {
  color: transparent !important;
  text-shadow: none !important;
}

/* AURORA's sliders carry a BLACK zone at the far left (the -1 sentinel's
   home) before the spectrum starts -- scoped to the stacked rail so
   CHROMA's single hue slider keeps its pure wheel. Proportions match the
   slider's -28..359 range: ~7% of travel is the black head. */
.inv-hue-stack .inv-hue-range {
  background: linear-gradient(90deg,
    #000 0%, #000 6%, #ff2f5e 8%, #ff7b2f 18%, #ffd42f 28%, #7bff4d 41%,
    #2fffd4 53%, #2fb0ff 63%, #4d5bff 73%, #a52fff 84%, #ff2fb0 93%, #ff2f5e 100%);
}

/* A worn fx is the player's COLOUR, not the badge's chrome -- the home
   badge hue-rotates its whole subtree on --logo-hue (its own clock; the
   --ui-hue mixup already burned us once, see the frame counter-spin), so
   without this the tape's amber cycles pink-green like an RGB sticker.
   Same counter-rotation the avatar photo and the frame already wear. */
#home-profile-badge .has-fontfx {
  filter: hue-rotate(calc(-1 * var(--logo-hue, 0deg)));
}

/* ---- THE MINIMAL DETAIL -- for the kinds whose preview IS the item
   (fonts, frames, fx), the detail panel drops to the same three facts the
   cards carry: the thing at full size, its name, the price with BUY. The
   kind/rarity line restates the filter, the desc restates the preview,
   the spend-note restates the price -- all gone. ---- */
#shop-detail.sd2-minimal #sd2-kind,
#shop-detail.sd2-minimal #sd2-desc,
#shop-detail.sd2-minimal #sd2-note { display: none; }
#shop-detail.sd2-minimal #sd2-art {
  background: none;
  border: none;
  min-height: 180px;
}
#shop-detail.sd2-minimal #sd2-name { text-align: center; }
#shop-detail.sd2-minimal #sd2-buyrow { justify-content: center; gap: 18px; }

/* OWNED dims the WHOLE card, not just the art -- a bright name and price
   under an OWNED stamp still reads as for sale. */
.shop-card.is-owned .shop-name,
.shop-card.is-owned .shop-price,
.shop-card.is-owned .shop-price-k { opacity: 0.4; }

/* ---- THE BRIEF, REMASTERED (owner call: simpler, bigger, centred in its
   column). Right-aligned 12px whispering read as clutter; the brief is
   what every track on this screen was MADE TO, so it earns display size.
   Appended, so it wins the earlier block on order. ---- */
#bs-brief {
  text-align: center;
  align-self: center;
  padding-top: 0;
}
.bs-brief-kata {
  font-size: 18px;
  letter-spacing: 5px;
  margin-bottom: 12px;
  color: rgba(159, 255, 207, 0.85);
}
.bs-brief-row {
  justify-content: center;
  gap: 12px;
  margin-top: 8px;
}
.bs-brief-k { font-size: 14px; letter-spacing: 4px; }
#bs-brief-beat, #bs-brief-sample, #bs-brief-cred {
  font-size: 21px;
  letter-spacing: 1px;
}
#bs-brief-words { justify-content: center; gap: 8px; }
.bs-word {
  font-size: 17px;
  letter-spacing: 2px;
  padding: 4px 13px;
}

/* The fx hero needs the font hero's measurable box -- an inline span
   reports clientWidth 0 and the fitter bails (the same bug the fx CARDS
   had). Single line + visible overflow: an fx name never wraps, and its
   throws must not clip at the art box. */
#sd2-glyph.is-fx-glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  padding: 4% 6%;
  text-align: center;
  line-height: 1.1;
  white-space: nowrap;
  overflow: visible;
  filter: none;   /* the fx brings its own throw */
}

/* ============================= THE DOOR LINE =============================
   The server-full queue plate (#queue-plate, built lazily in main.js).
   Shown only while the server is at its concurrent-player ceiling and this
   client is waiting for a seat. Sits over everything including the login
   screen -- while queued there is nothing behind it worth interacting
   with. Glow reads the house tokens, per the standing rule. */
#queue-plate {
  display: none;                /* main.js flips to flex on queue:state */
  position: fixed;
  inset: 0;
  z-index: 4000;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.82);
}
#queue-plate .qp-box {
  border: 1px solid rgba(57, 255, 20, 0.45);
  background: #0a0f0a;
  padding: 34px 46px;
  text-align: center;
  filter: drop-shadow(var(--sw-glow-core)) drop-shadow(var(--sw-glow-bloom));
}
#queue-plate .qp-head {
  font-family: 'Courier New', monospace;
  font-weight: 700;
  font-size: 1.5rem;
  letter-spacing: 0.35em;
  color: #eaffea;
}
#queue-plate .qp-pos {
  font-family: 'Courier New', monospace;
  font-size: 1.05rem;
  letter-spacing: 0.18em;
  color: #8fd99a;
  margin-top: 14px;
}
#queue-plate .qp-sub {
  font-family: 'Courier New', monospace;
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  color: #5d7a62;
  margin-top: 18px;
}

/* ---- SKIP WAGER (admin only) ----
   The admin HUD's SKIP PHASE already does this and already works during the
   wager, but #screen-wager is a modal overlay that paints over the HUD, so
   the control was there and unreachable. Same button, same event, given a
   seat above the scrim. Wears the HUD's red so it reads as ADMIN CHROME and
   never as part of the betting UI -- nobody should be able to mistake it for
   something the room is meant to press. */
#wg-devskip {
  position: absolute;
  right: 16px;
  bottom: 16px;
  z-index: 40;
  padding: 8px 14px 6px;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 1px;
  color: #ffd9d9;
  background: rgba(60, 12, 14, 0.82);
  border: 1px solid rgba(255, 96, 96, 0.45);
  cursor: pointer;
  clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
  transition: background 0.16s ease-out, border-color 0.16s ease-out;
}
#wg-devskip:hover { background: rgba(90, 18, 20, 0.9); border-color: rgba(255, 96, 96, 0.8); }

/* ============== SHOWCASE THEMES: THE SCENE LAYER ==============
   The stage floor every theme decorates. #bs-scene sits behind the whole
   listen screen's furniture (z-index below everything, pointer-events
   none) and is EMPTY until bsApplyTheme injects a theme's house-authored
   template; the default rig never puts anything in it. Theme stylesheets
   live in /themes/<id>/theme.css, lazy-loaded, and every rule in them is
   scoped under body.thm-<id> so a lingering class can never restyle a
   screen the theme doesn't own. body.sw-no-themes never co-exists with a
   thm-* class (bsApplyTheme normalizes to default while the switch is
   down), so themes need no per-rule escape hatch here. */
#bs-scene {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

/* ============================================================
   CHAT MESSAGE REACTIONS
   ============================================================
   Chips under a message, plus ONE body-level picker shared by the battle
   chat and the gang terminal. Two rules this block deliberately follows:

   - NO backdrop-filter anywhere in here. Glass Filter (RIG_SWITCHES) has to
     gate every instance of that property, and a new ungated one is exactly
     the drift the perf-switch note warns about. Flat backgrounds instead.
   - The two chats keep their own palettes. The gang terminal owns its
     chrome -- dim phosphor green, Courier New -- and must not import the
     battle chat's mint, so the shared pieces are shaped here and coloured
     per host below.
   ------------------------------------------------------------ */

.chat-react-row {
  display: flex;
  flex-wrap: wrap;
  gap: 3px;
  margin-top: 3px;
}

/* SIZED IN EM, LIKE THE BUTTON BELOW -- and for the same reason, learned
   the hard way: this was authored in fixed px (10px/17px) and its two
   hosts set type six times apart. The gang terminal's .chat-line is ~10px
   and the listen screen's .bs-chat-msg is 23px, so ONE px number can only
   ever be right in one of them -- the chip looked deliberate on the phone
   and half-size in the battle rail, which is not a taste call, it is the
   same chip drawn at two different fractions of its own message. Every
   number here is relative to the chip's own font-size, which is relative
   to the host's: change a host's type and the chip follows. */
.chat-react-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  padding: 0 0.6em;
  height: 1.75em;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 999px;
  font-family: 'Courier New', monospace;
  font-size: 0.8em;
  line-height: 1;
  cursor: pointer;
}

.chat-react-emoji { font-size: 1.15em; line-height: 1; }
.chat-react-n { font-variant-numeric: tabular-nums; }

/* The hover affordance. Sized in em so it tracks whatever type its host
   sets, the way the frames' own textures are authored relative to their
   box rather than in px. */
.chat-react-btn {
  padding: 0 2px;
  background: transparent;
  border: none;
  font-size: 0.9em;
  line-height: 1;
  cursor: pointer;
}

/* ---- gang terminal: phosphor, Courier, bracketed neighbours ---- */
/* One hover toolbar holds [ REPLY ] and the react button. The reply button
   was absolute on its own; inside the toolbar it goes static, or the two
   would stack on top of each other. */
.chat-line-tools {
  position: absolute;
  right: 2px;
  top: 0;
  display: none;
  align-items: center;
  gap: 4px;
}
.chat-line:hover .chat-line-tools { display: flex; }
.chat-line-tools .chat-reply-btn { position: static; display: block; }
.chat-line-tools .chat-react-btn {
  color: #86ff66;
  font-family: 'Courier New', monospace;
  font-size: 10px;
  border: 1px solid rgba(134, 255, 102, 0.35);
  border-radius: 3px;
  background: rgba(4, 10, 4, 0.9);
  padding: 1px 4px;
}
.chat-line-tools .chat-react-btn:hover {
  border-color: rgba(134, 255, 102, 0.8);
  color: #d2ffc0;
}
#phone-screen .chat-line-tools .chat-react-btn { font-size: 8px; padding: 0 3px; }
/* The one host that PINS its own chip type rather than inheriting: the
   phone's glass is already the smallest type in the app, and 0.8em of it
   is unreadable. Everything else on the chip stays em-relative to this,
   so the terminal keeps exactly the plate it had. */
#phone-screen .chat-react-chip {
  font-size: 10px;
  background: rgba(134, 255, 102, 0.08);
  border-color: rgba(134, 255, 102, 0.3);
  color: #86ff66;
}
#phone-screen .chat-react-chip:hover { border-color: rgba(134, 255, 102, 0.7); }
/* Yours reads as pressed-in rather than merely brighter -- at 10px a
   brightness step alone is not a state. */
#phone-screen .chat-react-chip.is-mine {
  background: rgba(134, 255, 102, 0.22);
  border-color: #86ff66;
  color: #d2ffc0;
}

/* ---- battle chat: the listen screen's mint ---- */
.bs-chat-msg .chat-react-btn {
  display: inline-block;
  margin-left: 4px;
  color: rgba(159, 255, 207, 0.35);
  vertical-align: middle;
}
.bs-chat-msg .chat-react-btn:hover { color: #86ffc2; }
.bs-chat-msg .chat-react-chip {
  background: rgba(159, 255, 207, 0.08);
  border-color: rgba(159, 255, 207, 0.28);
  color: rgba(159, 255, 207, 0.85);
}
.bs-chat-msg .chat-react-chip:hover { border-color: rgba(159, 255, 207, 0.7); }
.bs-chat-msg .chat-react-chip.is-mine {
  background: rgba(159, 255, 207, 0.22);
  border-color: #86ffc2;
  color: #d6fff0;
}

/* ---- the picker ---- */
/* Body-level and FIXED, so it is never clipped by a chat panel's own
   overflow and never rides a screen's transform. Positioned from JS in
   visual px, which is what getBoundingClientRect already answers in. */
#chat-react-picker {
  position: fixed;
  z-index: 9000;
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 3px;
  padding: 8px;
  background: #060d06;
  border: 1px solid rgba(134, 255, 102, 0.45);
  border-radius: 6px;
  box-shadow: 0 6px 22px rgba(0, 0, 0, 0.75);
}
#chat-react-picker.hidden { display: none; }

/* THESE ARE TAP TARGETS UNDER THE ROOT ZOOM, so read the number twice:
   the whole GUI rides swApplyZoom (0.5 on a 1280 window), so a cell
   written as 30px is fifteen DEVICE pixels of thing to hit -- smaller
   than the emoji it is meant to be showing. Sized for the zoomed-down
   case, not for the number that looks sane in the stylesheet. */
.chat-react-cell {
  width: 42px;
  height: 42px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  font-size: 24px;
  line-height: 1;
  cursor: pointer;
}
.chat-react-cell:hover:not(:disabled) {
  background: rgba(134, 255, 102, 0.16);
  border-color: rgba(134, 255, 102, 0.5);
}
.chat-react-cell.is-mine {
  background: rgba(134, 255, 102, 0.24);
  border-color: #86ff66;
}
/* At the per-message cap the cells you do NOT already hold go dead, because
   the server would refuse them -- a picker offering a button that does
   nothing reads as broken. The ones you hold stay live so you can take one
   off and free the slot. */
.chat-react-cell:disabled { opacity: 0.25; cursor: default; }

/* ======================= TOURNAMENT ARENA ======================= */
/* Three screens (create form, join card, arena stage) plus the listen
   screen's arena dress.

   THE ARENA IS A VENUE IN ONE-POINT PERSPECTIVE, and that is the whole
   organising idea rather than a decoration on top of one. The atmosphere
   layers are the staging screen's own (.mp-atmos-*, sharing its single
   vanishing point) so the floor genuinely goes away from you, and the
   three bands then sit at three DEPTHS instead of merely stacking:

     THE BILLING  a marquee hung over the stage -- who is presenting
     THE FIELD    the lit floor -- the competitors, hero of any capture
     THE STAND    nearest the camera -- the crowd, in SILHOUETTE

   The silhouettes are the one risk this screen takes, and it is a
   physical argument rather than a stylistic one: a crowd standing between
   you and a lit stage is BACKLIT, so it is dark with a rim along the top
   and nothing else. Drawing the audience as bright objects would have put
   them in competition with the field for the eye, which is exactly
   backwards for something people capture into a stream.

   Palette is the house green throughout, with exactly one import: CYAN
   for the CROWD. That was already law here for the crowd SCORE POOL --
   the two pools have to be told apart at a glance and green-vs-green
   cannot do it -- and extending it to the STAND makes the colour mean the
   same thing in both places: green is the field, cyan is the audience,
   gold is the champion. No new signal is introduced; an existing one is
   spent twice on one meaning.

   SIZING RIDES --dvw, NOT vw. The GUI sits under swApplyZoom (0.5 on a
   1280 window), and `vw` resolves against the VISUAL viewport while the
   layout these numbers live in is twice that -- so a plain `3vw` clamp
   lands at half the size it reads as in the stylesheet. Calibrated
   against the staging screen, which is the app's other full-bleed
   screen: its mode name is 82px, its menu items 65px, its tagline 22px,
   its micro-labels 11px. */

/* THE 700px BODY CAP HAS TO BE RELEASED PER SCREEN, and these three never
   released it -- so the arena's max-width: 1200px was dead from the day it
   shipped and the stage has always been drawn inside a 700px column. Every
   full-bleed screen in this file carries its own line; there is no shared
   class to inherit, which is exactly how a screen gets missed. */
body:has(#screen-tcreate.active),
body:has(#screen-tjoin.active),
body:has(#screen-arena.active) { max-width: none; padding: 0; }

#screen-tcreate, #screen-tjoin, #screen-arena {
  display: none;
  position: relative;
  /* Body padding is 0 for these and box-sizing is border-box, so the
     screen's own padding sits INSIDE this -- the full viewport height, or
     a short screen leaves a dead tail under the CRT glass (THE GREY BAR). */
  min-height: var(--vph, 100vh);
  padding: 34px 44px 28px;
  color: #cfe8cf;
  overflow: hidden;
}
/* Hide scrollbars but keep scroll functionality */
#screen-arena {
  scrollbar-width: none;  /* Firefox */
}
#screen-arena::-webkit-scrollbar {
  display: none;  /* Chrome, Safari */
}
#screen-tcreate.active, #screen-tjoin.active { display: block; }
#screen-arena.active { display: flex; flex-direction: column; }

/* THE BILLING CLEARS THE DOOR. The global LEAVE plate is position:fixed at
   top:16px and stands about 59px tall, so it owns y 16-75 -- and the arena
   is the only one of these three screens where it is ever shown (you are in
   a lobby here; on the create and join screens it stays hidden). At the
   shared 34px top padding the marquee's own top-left corner ran underneath
   it. Scoped to the arena so the other two are not pushed down for a button
   they never draw, and it clears the top-RIGHT chrome (the menu and DEV
   discs) by the same move. */
#screen-arena { padding-top: 100px; }
/* The wrap FILLS the screen's content box rather than restating the screen
   height minus a hardcoded padding total. That calc was tuned to the old
   34+28 padding, so raising the top clearance above pushed the page 66px
   past the viewport and gave the whole stage a scrollbar. Deriving it means
   the padding can move again without a second number having to be found and
   moved with it. */

/* The room. Children are the staging screen's layers verbatim -- one
   vanishing point, one set of rules, and no second geometry fighting it. */
.ar-atmos {
  position: absolute; inset: 0; z-index: 0;
  overflow: hidden; pointer-events: none;
}
#tc-wrap, #tj-wrap, #ar-wrap { position: relative; z-index: 1; }

/* ---------------------------------------------------------------- */
/* THE MARQUEE -- the signature, and the ONE component shared by the
   create form and the arena. On the create screen it is a live preview
   filling in as the host types; in the arena it is the real billing. Same
   class, same construction, so what you set up is literally what the room
   is about to look at.

   Reading order left to right is deliberately WHO -> WHAT -> WHEN: the
   host's face, the show's name, the clock. A tournament is somebody's
   event before it is a format, and this screen is the only place in the
   app where a person owns the room. */
.ar-marquee {
  display: grid;
  grid-template-columns: 6px auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 0 26px;
  padding: 24px 30px 24px 0;
  /* OPAQUE, and that is not a taste call: at 0.94 the atmosphere's green
     haze pooled straight through the plate as a bright blotch across its
     bottom edge -- the dirtiest thing on the screen, and it read as a
     rendering fault rather than as atmosphere. A title plate is a solid
     object hung in the room; the room goes behind it, not through it. */
  background:
    linear-gradient(115deg, #0d1e0d 0%, #081408 60%, #050f06 100%);
  border: 1px solid transparent;
  /* Ring inside the clip -- THE CLIP EATS THE BORDER (#prod-sample-box). */
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.42);
  clip-path: polygon(0 0, calc(100% - 26px) 0, 100% 26px, 100% 100%, 26px 100%, 0 calc(100% - 26px));
  position: relative;
  overflow: hidden;
}
/* The hazard rail: the marquee's left edge, the staging header's own
   device. It is the strip that says "this plate is a title", and it
   carries the only saturated green on the component. Full-bleed
   vertically -- the plate's padding would otherwise inset it, and a title
   bar's edge stripe stopping short of the corners reads as a misaligned
   border rather than an edge. */
.ar-mq-rail {
  grid-column: 1; align-self: stretch;
  margin: -24px 0;
  background: repeating-linear-gradient(
    -45deg,
    #39ff14 0 8px,
    rgba(6, 15, 7, 0.9) 8px 16px);
  opacity: 0.75;
}
/* A single STANDING sheen across the plate. Not travelling: the glint law
   belongs to things meant to catch the eye repeatedly (a gold frame in a
   roster), and a title plate that keeps flashing through a forty-minute
   show is a distraction sitting on somebody's stream. */
.ar-mq-sweep {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(104deg,
    transparent 0 38%,
    rgba(134, 255, 102, 0.05) 46%,
    rgba(134, 255, 102, 0.012) 54%,
    transparent 62% 100%);
}

/* ---- who ---- */
.ar-mq-host {
  grid-column: 2;
  display: flex; align-items: center; gap: 20px;
  padding-left: 26px;
}
.ar-mq-face { line-height: 0; }
/* THE HOST IS THE BIGGEST FACE ON THE SCREEN by a clear margin over a
   competitor plate (60px wide-field / 112px close). The hierarchy the room
   reads is host > competitor > crowd, and it is carried by SIZE before it
   is carried by position. */
.ar-mq-face .player-icon {
  width: clamp(104px, calc(6.4 * var(--dvw, 1vw)), 196px);
  height: clamp(104px, calc(6.4 * var(--dvw, 1vw)), 196px);
  font-size: clamp(48px, calc(3 * var(--dvw, 1vw)), 92px);
}
.ar-mq-hostid { min-width: 0; }
.ar-mq-eyebrow {
  display: block;
  font-family: 'Courier New', monospace;
  font-size: 14px; letter-spacing: 4px;
  color: #86ff66; opacity: 0.62;
}
.ar-mq-eyebrow i { font-style: normal; opacity: 0.55; letter-spacing: 1px; }
.ar-mq-hostname {
  font-size: clamp(26px, calc(1.9 * var(--dvw, 1vw)), 56px);
  letter-spacing: 1px; color: #eaffea;
  margin-top: 8px; line-height: 1.05;
  max-width: 16ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ---- what ---- */
/* The divider is a RULE, not a gap: the host and the show are two facts
   and the plate should say so. */
.ar-mq-show {
  grid-column: 3; min-width: 0;
  padding-left: 30px;
  border-left: 1px solid rgba(57, 255, 20, 0.16);
}
.ar-mq-title {
  margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: clamp(34px, calc(2.7 * var(--dvw, 1vw)), 88px);
  line-height: 1.1;
  letter-spacing: 1px;
  color: #eaffea;
  /* Reads the house tokens rather than hardcoding a pair -- a surface that
     invents its own glow is invisible to the Glow Bloom perf switch. */
  text-shadow: var(--sw-glow-core, 0 0 6px rgba(134, 255, 102, 0.5));
  word-wrap: break-word; overflow-wrap: break-word;
}
.ar-mq-meta {
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
  margin-top: 12px;
  font-family: 'Courier New', monospace;
  font-size: 16px; letter-spacing: 2px;
}
.ar-mq-mode { color: #86ff66; }
.ar-mq-sep { opacity: 0.3; }
#ar-sub, #tc-mq-field { opacity: 0.6; }
/* IN FLOW, not floated. On margin-left:auto this landed in the dead middle
   of the meta row, aligned to nothing on either side and reading as a
   stray label. It is the third fact about the show, so it sits third. */
#ar-code {
  opacity: 0.45; user-select: all;
  letter-spacing: 1px; font-size: 14px;
}
#ar-code:empty { display: none; }

/* ---- when ---- */
/* A FIXED column, or the whole marquee re-flows every time the clock goes
   from --:-- to 09:59 and the title jumps under the cursor mid-show. */
.ar-mq-readout {
  grid-column: 4; text-align: center;
  padding-left: 30px;
  min-width: 210px;
  border-left: 1px solid rgba(57, 255, 20, 0.16);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.ar-mq-rlabel {
  font-family: 'Courier New', monospace; font-size: 12px;
  letter-spacing: 4px; opacity: 0.4;
}
.ar-mq-clock {
  font-family: 'Courier New', monospace;
  font-size: clamp(30px, calc(2.3 * var(--dvw, 1vw)), 68px);
  line-height: 1.05; letter-spacing: 2px;
  font-variant-numeric: tabular-nums;
}
/* The phase is a CHIP, and the label it carries is the SHORT one. It used
   to take the full sentence ("SIGNUP -- WAITING FOR THE FIELD"), which
   wrapped to two ragged right-aligned lines and read as text that had
   overflowed something. The sentence still exists -- it is #ar-status, at
   the foot, where a sentence belongs. */
.ar-mq-phase {
  font-family: 'Courier New', monospace;
  font-size: 13px; letter-spacing: 3px;
  color: #86ff66;
  padding: 5px 12px;
  border: 1px solid rgba(57, 255, 20, 0.32);
  background: rgba(57, 255, 20, 0.06);
  white-space: nowrap;
}

/* ---------------------------------------------------------------- */
/* THE BAND RAIL. Every band names itself and states its own count -- and
   the count is the content, not an ornament: how many are still alive and
   how many are watching IS what somebody tuning in wants from the screen. */
.ar-band {
  display: flex; align-items: center; gap: 16px;
  margin: 0 0 16px;
  font-family: 'Courier New', monospace;
  font-size: 16px; letter-spacing: 4px;
}
.ar-band-name { color: #86ff66; opacity: 0.85; }
.ar-band-rule {
  flex: 1; height: 1px;
  background: linear-gradient(to right, rgba(57, 255, 20, 0.5), rgba(57, 255, 20, 0.06));
}
.ar-band-count { opacity: 0.6; }
.ar-band-count b { font-weight: 400; color: #eaffea; }
#ar-stand .ar-band-name { color: #7fdcf5; }
#ar-stand .ar-band-rule {
  background: linear-gradient(to right, rgba(63, 224, 255, 0.24), rgba(63, 224, 255, 0.03));
}
#ar-stand .ar-band-count b { color: #cdf3ff; }

/* ---------------------------------------------------------------- */
#ar-wrap {
  width: 100%;
  max-width: 2200px;
  margin: 0 auto;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 26px;
}

/* ---- the host's console, docked under his own billing ---- */
#ar-hostbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
#ar-hostbar.hidden { display: none; }
#ar-end { margin: 0; }
/* The test rig reads as tooling, not as a control anybody is meant to
   press during a real show: smaller, dimmer, pushed to the far end, and
   gone entirely the moment round 1 starts. */
#ar-botbar {
  margin-left: auto;
  display: flex; gap: 10px;
  opacity: 0.4; transition: opacity 0.15s ease;
}
#ar-botbar:hover, #ar-botbar:focus-within { opacity: 0.9; }
#ar-botbar.hidden { display: none; }
#ar-botbar button { font-size: 15px; padding: 8px 14px; letter-spacing: 1px; margin: 0; }

/* The judges panel: a real control a host uses through the whole show
   (unlike the test rig), so it reads at full opacity and docks beside the
   start/end buttons rather than pushed to the far end. */
#ar-judgesbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#ar-judgesbar.hidden { display: none; }
#ar-judgesbar button { font-size: 15px; padding: 8px 14px; letter-spacing: 1px; margin: 0; }
/* Themed to match .tc-ghost/#tc-name rather than left as browser-default
   white pills -- those read as a totally different, un-styled control
   sitting in an otherwise all-dark-green console. */
#ar-judgesbar select {
  font-size: 14px; padding: 7px 10px; letter-spacing: 0.5px;
  background: rgba(8, 18, 8, 0.9); color: #9fc79f;
  border: 1px solid #2c4a2c; border-radius: 4px; cursor: pointer;
  font-family: 'Courier New', monospace;
  transition: border-color 0.12s ease, color 0.12s ease;
}
#ar-judgesbar select:hover, #ar-judgesbar select:focus {
  color: #d8ffd8; border-color: #477a47; outline: none;
}
#ar-judgesbar select option { background: #0a180a; color: #d8ffd8; }

/* ---- BAND 2: the field ---- */
/* THE EMPTY SPACE BELONGS IN ONE PLACE, AND THAT PLACE IS THE ROOM. The
   field used to be flex:1 with the board centred inside it, which split
   the slack into two voids -- one above the plates and one below -- and a
   gap over a heading reads as a layout that failed rather than as air. It
   sizes to its content now and the STAND keeps margin-top:auto, so
   everything flows down from the billing and the one remaining gap is the
   distance between the stage and the audience. Which is what a venue is.

   THE CENTRING BELONGS TO THE BAND, NOT THE BOARD. Centring the plates
   INSIDE a tall board strands the rail at the top of it with a void under
   the heading -- the original bug. The rail and the plates travel as one
   block and that block is what centres, so the slack always lands outside
   the pair and never between them. */
#ar-field { flex: 1; display: flex; flex-direction: column; justify-content: center; }
#ar-board {
  display: flex;
  align-items: flex-start;
  justify-content: center;
}
#ar-map {
  display: grid;
  gap: 16px;
  width: 100%;
  /* EIGHT COLUMNS AT FULL WIDTH, and the number is load-bearing: a field
     of 16 is the common case and it wants to read as two rows of eight,
     not as one 12-wide strip of narrow cards with everybody's name
     truncated. 2080/250 lands on 8 and 32 still divides into four rows.
     Seat count drives the DRESS separately -- eight left is the closing
     rounds and gets the big plates (and the frames). */
  /* 240 and not 250: eight columns need 8*240 + 7*16 = 2032 to fit
     inside the 2080 cap, and at 250 the eighth does not fit -- a field of
     16 then lays out 7+7+2 and that stranded pair of cards is the ragged
     edge this rule exists to prevent. Nine would need 2288, so it holds
     at eight. Changing the gap moves this number with it. */
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  max-width: 2080px;
  margin: 0 auto;
}
#ar-board[data-size='close'] #ar-map {
  grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
  gap: 22px;
  max-width: 1300px;
  margin: 0 auto;
}
#ar-empty {
  font-family: 'Courier New', monospace; font-size: 18px;
  opacity: 0.5; text-align: center;
}

/* A PLATE IS A SEAT, and it is the same element all night -- lit through
   the round, scored at the cut, then dark. Never rebuilt between states
   (see arenaPaintBoard), because the ceremony has to happen TO something
   the room has already been looking at. */
.ar-plate {
  position: relative;
  display: flex; flex-direction: column; align-items: center;
  gap: 9px;
  padding: 18px 14px 14px;
  background: linear-gradient(180deg, rgba(12, 26, 12, 0.96), rgba(5, 13, 6, 0.99));
  /* ring INSIDE the clip -- see THE CLIP EATS THE BORDER (#prod-sample-box) */
  border: 1px solid transparent;
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.42);
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
  /* Transform + opacity only. The glow SNAPS between states rather than
     tweening -- interpolating mismatched shadow lists is the black-band
     bug this codebase already paid for once. */
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s ease;
}
.ar-plate-face { line-height: 0; }
.ar-plate-face .player-icon { width: 76px; height: 76px; font-size: 36px; }
#ar-board[data-size='close'] .ar-plate { padding: 26px 20px 20px; gap: 13px; }
#ar-board[data-size='close'] .ar-plate-face .player-icon { width: 118px; height: 118px; font-size: 56px; }
.ar-plate-name {
  font-size: 21px; letter-spacing: 1px; text-align: center;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#ar-board[data-size='close'] .ar-plate-name { font-size: 27px; }
.ar-plate-meta {
  font-family: 'Courier New', monospace; font-size: 14px;
  letter-spacing: 1px; opacity: 0.5;
}

/* ---- the two pools ---- */
.ar-plate-bars { width: 100%; display: flex; flex-direction: column; gap: 5px; margin-top: 3px; }
.ar-plate-bars.hidden { display: none; }
.ar-bar {
  display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px;
  font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 1px;
}
.ar-bar-label { opacity: 0.5; }
.ar-bar-track { height: 6px; background: rgba(255, 255, 255, 0.09); display: block; }
.ar-bar-track i { display: block; height: 100%; width: var(--v, 0%); transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
.ar-bar[data-pool='field'] .ar-bar-track i { background: #39ff14; }
.ar-bar[data-pool='crowd'] .ar-bar-track i { background: #3fe0ff; }
.ar-bar b { text-align: right; font-weight: 400; opacity: 0.9; }
.ar-bar[data-pool='crowd'] b { color: #9fe9ff; }
.ar-plate-verdict {
  font-family: 'Courier New', monospace; font-size: 14px; letter-spacing: 2px;
  color: #86ff66; min-height: 17px;
}

/* ---- the cut ---- */
/* Eliminated: the plate is still there for the length of the ceremony
   (they are still in the room, and about to be in the stand) but the
   light is out. */
.ar-plate.is-cut, .ar-plate.is-falling {
  box-shadow: inset 0 0 0 1px rgba(140, 160, 140, 0.22);
  background: linear-gradient(180deg, rgba(14, 16, 14, 0.95), rgba(6, 7, 6, 0.98));
}
.ar-plate.is-cut .ar-plate-verdict, .ar-plate.is-falling .ar-plate-verdict { color: #8d9a8d; }
.ar-plate.is-falling { transform: translateY(10px) scale(0.965); opacity: 0.34; }
.ar-plate.is-cut:not(.is-falling) { opacity: 0.34; }
/* While the ceremony runs, the survivors are the only thing still lit. */
#ar-board.is-cutting .ar-plate:not(.is-cut):not(.is-falling) {
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.95);
}

#ar-champion {
  text-align: center; margin: 0;
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-size: clamp(40px, calc(3.6 * var(--dvw, 1vw)), 116px);
  letter-spacing: 4px; color: #ffd76a;
  filter: drop-shadow(0 0 10px rgba(255, 215, 106, 0.55));
}
#ar-champion.hidden { display: none; }

/* ---------------------------------------------------------------- */
/* ---- BAND 3: THE STAND ---- */
/* Two populations, one band, and they are not the same fact. Cut
   competitors are NAMED (their names are already public in the roster)
   and lit dimly -- the fallen sitting in the audience, still voting.
   Everyone else is an anonymous silhouette: the server publishes a COUNT
   for the crowd and never its members, and this band is built so that
   stays true no matter how it is restyled later. */
#ar-stand { margin-top: auto; }
#ar-stand-body {
  position: relative;
  display: flex; align-items: flex-end; gap: 26px;
  min-height: 124px;
  padding: 0 6px;
  /* The stage light spilling past the audience -- the reason they read as
     backlit rather than merely dark. */
  background: radial-gradient(ellipse 70% 130% at 50% 118%,
    rgba(63, 224, 255, 0.075), transparent 70%);
}

/* ---- the fallen ---- */
#ar-stand-cut { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
#ar-stand-cut:empty { display: none; }
/* Deliberately NOT a plate: a seat in the stand is not a seat on the
   floor, and giving it the same chrome would say the round is still
   theirs. A face and a name, nothing else. */
.ar-seat {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  width: 78px;
  opacity: 0.72;
}
.ar-seat .player-icon { width: 46px; height: 46px; font-size: 22px; }
.ar-seat-name {
  font-size: 13px; letter-spacing: 0.5px; text-align: center;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: #cdf3ff; opacity: 0.8;
}

/* ---- the anonymous house ---- */
/* Each mark is a BUST: shoulders on the element, a head on ::before.
   Overlapping negative margins so it clusters like a crowd instead of
   lining up like a bar chart -- an evenly spaced row of identical shapes
   reads as a meter, which is the one thing this band must not be. */
#ar-crowd-marks {
  display: flex; align-items: flex-end; justify-content: center;
  flex: 1; flex-wrap: wrap-reverse;
  min-height: 68px;
}
#ar-crowd-marks i {
  position: relative;
  display: block;
  width: 34px; height: 40px;
  margin-right: -7px;
  background: #050d10;
  border-radius: 46% 46% 0 0 / 62% 62% 0 0;
  /* The rim light. A crowd facing a lit stage catches it along the top
     edge and nowhere else -- one inset shadow, no halo, no fill. */
  box-shadow: inset 0 3px 0 -1px rgba(63, 224, 255, 0.5);
  animation: ar-mark-in 0.34s ease backwards;
  animation-delay: calc(var(--i, 0) * 11ms);
}
#ar-crowd-marks i::before {
  content: '';
  position: absolute;
  left: 50%; bottom: 100%;
  width: 17px; height: 17px;
  margin-left: -8.5px; margin-bottom: -2px;
  background: #050d10;
  border-radius: 50%;
  box-shadow: inset 0 3px 0 -1px rgba(63, 224, 255, 0.5);
}
/* Depth, cheaply: a crowd is not one row of identical people. Coprime
   cycles over height, width and opacity so the pattern does not visibly
   repeat across sixty marks. */
#ar-crowd-marks i:nth-child(2n) { height: 48px; }
#ar-crowd-marks i:nth-child(3n) { height: 35px; opacity: 0.82; }
#ar-crowd-marks i:nth-child(5n) { height: 53px; width: 37px; }
#ar-crowd-marks i:nth-child(7n) { opacity: 0.66; }
@keyframes ar-mark-in { from { opacity: 0; transform: translateY(8px); } }

#ar-crowdline { font-family: 'Courier New', monospace; }
#ar-status {
  text-align: center; margin-top: 6px;
  font-family: 'Courier New', monospace; font-size: 17px; opacity: 0.7;
}
#ar-status:empty { display: none; }

/* ---------------------------------------------------------------- */
/* ---- the create form and the join card ---- */
#tc-wrap { max-width: 1680px; margin: 3vh auto 0; }
#tj-wrap { max-width: 1100px; margin: 8vh auto 0; }
.tc-crumb {
  font-family: 'Courier New', monospace;
  font-size: 15px; letter-spacing: 4px;
  color: #86ff66; opacity: 0.7; margin-bottom: 16px;
}
.tc-crumb i { font-style: normal; opacity: 0.5; }
/* The preview marquee is quieter than the live one: it is a rehearsal and
   it must not look like the show has already started. */
.ar-marquee.is-preview { box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.18); }
.ar-marquee.is-preview .ar-mq-rail { opacity: 0.4; }
.ar-marquee.is-preview .ar-mq-phase { color: #7b8f78; }
/* An unnamed show says so in the marquee rather than sitting blank -- an
   empty title plate reads as a broken preview. */
.ar-mq-title.is-blank { opacity: 0.38; }

.tj-title {
  font-family: 'SD CyberPunk City', 'Courier New', monospace;
  font-weight: normal;
  font-size: clamp(34px, calc(2.7 * var(--dvw, 1vw)), 88px);
  letter-spacing: 2px; margin: 0 0 14px; color: #eaffea;
  text-shadow: var(--sw-glow-core, 0 0 6px rgba(134, 255, 102, 0.5));
}
.tc-wizard { margin-top: 34px; max-width: 900px; display: flex; flex-direction: column; }
.tc-form { margin-top: 34px; display: grid; gap: 22px; max-width: 900px; }
.tc-wizard .holo-step { display: none; }
.tc-wizard .holo-step.active { display: block; animation: fadeIn 0.18s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.holo-step-q {
  font-family: 'Courier New', monospace; font-size: 12px;
  letter-spacing: 3px; color: #58a058; margin-bottom: 18px;
  text-transform: uppercase; text-shadow: 0 0 8px rgba(88, 160, 88, 0.3);
}
#tc-name {
  width: 100%; padding: 16px 18px; margin-bottom: 0;
  background: rgba(8, 18, 8, 0.95); color: #d8ffd8;
  border: 1px solid #2c4a2c; font-size: 20px; letter-spacing: 1px;
  font-family: 'Courier New', monospace; border-radius: 0;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
#tc-name:focus { outline: none; border-color: #58a058; box-shadow: 0 0 12px rgba(88, 160, 88, 0.25); }
.tc-mode-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.tc-source-group { margin-top: 22px; }
.tc-source-group + .tc-source-group { margin-top: 26px; }
.tc-source-row { display: flex; flex-wrap: wrap; gap: 10px; }
.tc-source-btn {
  padding: 12px 20px; background: rgba(8, 18, 8, 0.9); color: #9fc79f;
  border: 1px solid #2c4a2c; cursor: pointer; letter-spacing: 1px; font-size: 14px;
  border-radius: 0; font-family: 'Courier New', monospace;
  transition: border-color 0.12s ease, color 0.12s ease;
}
.tc-source-btn:hover { color: #d8ffd8; border-color: #477a47; }
.tc-source-btn.is-on { background: #1d3a1d; color: #eaffea; border-color: #58a058; box-shadow: 0 0 8px rgba(88, 160, 88, 0.2); }
.tc-review-box {
  padding: 18px; background: rgba(8, 18, 8, 0.95); border: 1px solid #2c4a2c;
  border-radius: 0;
}
.tc-review-row {
  display: flex; justify-content: space-between; font-family: 'Courier New', monospace;
  font-size: 16px; color: #9fc79f; padding: 10px 0; border-bottom: 1px solid rgba(88, 160, 88, 0.15);
}
.tc-review-row:last-child { border-bottom: none; }
.tc-review-row span:first-child { color: #58a058; letter-spacing: 1px; }
.tc-review-row span:last-child { color: #d8ffd8; }
.tc-nav {
  display: flex; justify-content: space-between; align-items: center;
  margin-top: 28px; padding-top: 18px; border-top: 1px solid #2c4a2c;
}
.tc-nav-counter { font-family: 'Courier New', monospace; font-size: 12px; color: #58a058; letter-spacing: 2px; text-transform: uppercase; }
.tc-nav-buttons { display: flex; gap: 11px; }
.tc-hidden { display: none !important; }
#tc-next-btn, #tc-create-btn, #tc-back-btn {
  display: inline-block; margin: 0; padding: 14px 26px;
  font-family: 'Courier New', monospace; cursor: pointer; letter-spacing: 2px;
  font-size: 15px; border-radius: 0; transition: all 0.12s ease;
}
#tc-next-btn, #tc-create-btn {
  background: #1d3a1d; color: #eaffea; border: 1px solid #58a058;
}
#tc-next-btn:hover, #tc-create-btn:hover {
  background: #274f27; border-color: #7ac47a; text-shadow: 0 0 8px rgba(134, 255, 102, 0.3);
}
#tc-back-btn {
  background: transparent; color: #9fc79f; border: 1px solid #2c4a2c;
}
#tc-back-btn:hover { color: #d8ffd8; border-color: #477a47; }
.ar-mq-show { white-space: normal; word-wrap: break-word; overflow-wrap: break-word; }
.tc-panel {
  padding: 24px; background: rgba(8, 18, 8, 0.7); border: 1px solid #2c4a2c;
  border-radius: 6px;
}
.tc-panel-title {
  display: block; font-family: 'Courier New', monospace; font-size: 12px;
  letter-spacing: 3px; color: #58a058; opacity: 0.8; margin-bottom: 18px;
  text-transform: uppercase;
}
.tc-label {
  display: block; font-family: 'Courier New', monospace; font-size: 13px;
  letter-spacing: 2px; color: #9fc79f; margin: 16px 0 8px;
  text-transform: uppercase;
}
#tc-name {
  width: 100%; padding: 14px 16px;
  background: rgba(8, 18, 8, 0.9); color: #d8ffd8;
  border: 1px solid #2c4a2c; font-size: 20px; letter-spacing: 1px;
  font-family: 'Courier New', monospace; border-radius: 4px;
}
#tc-name:focus { outline: none; border-color: #58a058; box-shadow: 0 0 0 2px rgba(88, 160, 88, 0.3); }
#tc-modes { display: flex; flex-wrap: wrap; gap: 10px; }
.tc-mode {
  padding: 12px 20px; background: rgba(8, 18, 8, 0.9); color: #9fc79f;
  border: 1px solid #2c4a2c; cursor: pointer; letter-spacing: 1px; font-size: 14px;
  transition: border-color 0.12s ease, color 0.12s ease; border-radius: 4px;
}
.tc-mode:hover { color: #d8ffd8; border-color: #477a47; }
.tc-mode.is-on { background: #1d3a1d; color: #eaffea; border-color: #58a058; box-shadow: 0 0 8px rgba(88, 160, 88, 0.2); }

/* The primary judges/crowd choice reads heavier than the dependent
   sub-choice below it (CROWD CAN VOTE) so the two don't look like equally
   weighted, unrelated questions stacked on one step. */
.tc-mode--primary { padding: 16px 26px; font-size: 16px; letter-spacing: 1.5px; }
.tc-mode--primary.is-on { box-shadow: 0 0 12px rgba(88, 160, 88, 0.3); }

/* Sub-panel: visually branches off "THE CROWD" -- an indented, bordered,
   dimmer card rather than a second row of full-weight pills, so it reads
   as "only applies if you picked Crowd above" instead of a second
   unrelated question. Dimmed (not hidden) via inline opacity/pointerEvents
   in tcPaintSecondPool() when Judges is picked -- see main.js. */
.tc-subpanel { display: flex; align-items: stretch; margin: 4px 0 20px; transition: opacity 0.15s ease; }
.tc-subpanel-connector { flex: 0 0 22px; position: relative; margin-right: 12px; }
.tc-subpanel-connector::before {
  content: ''; position: absolute; top: 0; bottom: 50%; left: 10px;
  width: 1px; background: #2c4a2c;
}
.tc-subpanel-connector::after {
  content: ''; position: absolute; top: 50%; left: 10px; right: 0;
  height: 1px; background: #2c4a2c;
}
.tc-subpanel-body {
  flex: 1 1 auto; padding: 14px 18px; background: rgba(8, 18, 8, 0.5);
  border: 1px solid #223822; border-left: 2px solid #3a6a3a; border-radius: 0 4px 4px 0;
}
.tc-subpanel-body .holo-label { color: #7fa87f; font-size: 12px; letter-spacing: 2px; }
.tc-subpanel-body .tc-mode { padding: 9px 16px; font-size: 12px; }

.tc-cap-row { display: flex; align-items: center; gap: 16px; }
.tc-cap-row button {
  width: 48px; height: 48px; font-size: 24px; background: rgba(8, 18, 8, 0.9);
  color: #d8ffd8; border: 1px solid #2c4a2c; cursor: pointer; border-radius: 4px;
  transition: border-color 0.12s ease;
}
.tc-cap-row button:hover { border-color: #58a058; }
#tc-cap {
  font-family: 'Courier New', monospace;
  font-size: 32px; min-width: 60px; text-align: center;
  font-variant-numeric: tabular-nums; color: #eaffea;
}
.tc-note {
  font-family: 'Courier New', monospace; font-size: 14px; color: #9fc79f;
  line-height: 1.5; margin-top: 4px;
}
.tc-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 0; }
#tc-mode-settings { transition: opacity 0.2s ease; }
#tc-mode-settings.hidden { display: none; }
.tc-mode-setting { display: flex; flex-direction: column; gap: 10px; }
.tc-mode-setting-group { display: flex; align-items: center; gap: 16px; margin: 12px 0; }
.tc-range-row { display: flex; align-items: center; gap: 16px; }
.tc-range-row input[type="range"] { flex: 1; max-width: 200px; }
.tc-range-val { font-family: 'Courier New', monospace; font-size: 16px; color: #eaffea; min-width: 50px; }
#tc-create, #tj-compete, #tj-watch, #tc-enter, #tc-copy, #ar-start {
  display: inline-block; margin: 0; padding: 16px 30px;
  background: #1d3a1d; color: #eaffea; border: 1px solid #58a058;
  cursor: pointer; letter-spacing: 2px; font-size: 19px;
}
#tc-create:hover, #tj-compete:hover, #tj-watch:hover,
#tc-enter:hover, #tc-copy:hover, #ar-start:not(:disabled):hover {
  background: #274f27; border-color: #7ac47a;
}
#ar-start:disabled { opacity: 0.4; cursor: default; }
.tc-ghost {
  display: inline-block; margin: 0; padding: 16px 30px;
  background: transparent; color: #9fc79f; border: 1px solid #2c4a2c;
  cursor: pointer; letter-spacing: 2px; font-size: 19px;
}
.tc-ghost:hover { color: #d8ffd8; border-color: #477a47; }
.tc-link-plate {
  padding: 18px; background: rgba(8, 18, 8, 0.9); border: 1px dashed #58a058;
  font-family: 'Courier New', monospace; font-size: 20px; word-break: break-all;
  user-select: all; max-width: 860px;
}
.tj-buttons { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 30px; }
#tc-done.hidden { display: none; }

/* Keyboard focus has to survive every restyle above -- the quality floor,
   not a feature. */
#screen-tcreate button:focus-visible,
#screen-tjoin button:focus-visible,
#screen-arena button:focus-visible,
#tc-name:focus-visible {
  outline: 2px solid #86ff66; outline-offset: 3px;
}

/* MEDIA QUERIES RESOLVE IN VISUAL PX, NOT THE LAYOUT PX EVERY SIZE ABOVE
   IS WRITTEN IN. Under swApplyZoom's 0.5 the layout viewport is twice the
   width a query sees, so a breakpoint written to match the 1680px marquee
   has to be stated as ~840 -- a 1500 here fires on a large desktop and
   folds the billing for no reason. Halve every number you reason about in
   layout space before writing it into one of these. */
@media (max-width: 780px) {
  .ar-marquee {
    grid-template-columns: 6px minmax(0, 1fr) auto;
    row-gap: 18px;
  }
  .ar-mq-show {
    grid-column: 2 / -1; grid-row: 2;
    padding-left: 26px; border-left: none;
    border-top: 1px solid rgba(57, 255, 20, 0.16); padding-top: 16px;
  }
  .ar-mq-readout { grid-column: 3; grid-row: 1; }
}
@media (max-width: 560px) {
  #ar-map { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); }
  #ar-board[data-size='close'] #ar-map { grid-template-columns: repeat(2, 1fr); }
  .ar-mq-face .player-icon { width: 84px; height: 84px; font-size: 38px; }
  #ar-botbar { margin-left: 0; width: 100%; }
  #screen-tcreate, #screen-tjoin, #screen-arena { padding: 22px 18px; }
}

@media (prefers-reduced-motion: reduce) {
  .ar-plate, .ar-bar-track i { transition: none; }
  #ar-crowd-marks i { animation: none; }
}

/* Listen-screen arena dress. Anchored bottom-centre so it never fights
   the rack or the rig for their corners. */
#bs-arena-tally {
  position: absolute; left: 50%; bottom: 86px; transform: translateX(-50%);
  display: flex; gap: 18px; padding: 6px 14px; background: rgba(6, 14, 6, 0.85);
  border: 1px solid #2c4a2c; font-family: 'Courier New', monospace;
  font-size: 13px; letter-spacing: 1px; z-index: 6;
}
#bs-arena-tally.hidden { display: none; }
.bs-at-cell b { color: #eaffea; margin-left: 4px; }
.bs-at-cell i { font-style: normal; opacity: 0.6; margin-left: 3px; }
#bs-crowdrig {
  position: absolute; left: 50%; bottom: 30px; transform: translateX(-50%);
  text-align: center; z-index: 6;
}
#bs-crowdrig.hidden { display: none; }
.bs-cr-label { font-family: 'Courier New', monospace; font-size: 11px; letter-spacing: 3px; opacity: 0.75; margin-bottom: 5px; }
#bs-cr-row { display: flex; gap: 5px; }
.bs-cr-btn {
  width: 34px; height: 34px; background: #0b160b; color: #cfe8cf;
  border: 1px solid #2c4a2c; cursor: pointer; font-size: 14px;
}
.bs-cr-btn.is-on { background: #1d3a1d; color: #eaffea; border-color: #58a058; }
/* THE HOST TRANSPORT. Three flat plates in a row where NEXT alone used
   to sit -- same corner, same house green, nothing added but two more
   buttons the size of the first. "Super simple" is the brief: no
   scrubber, no waveform, no separate PAUSE and PLAY icons swapped in
   and out of the DOM -- one button whose glyph and title flip. */
#bs-transport {
  position: absolute; right: 18px; bottom: 30px; z-index: 6;
  display: flex; gap: 8px;
}
#bs-transport.hidden { display: none; }
#bs-transport button {
  padding: 10px 16px; background: #1d3a1d; color: #eaffea;
  border: 1px solid #58a058; cursor: pointer; font-size: 16px; line-height: 1;
}
#bs-transport button:hover:not(:disabled) { background: #274f27; border-color: #7ac47a; }
#bs-transport button:disabled { opacity: 0.35; cursor: default; }
/* PAUSED reads on the clock itself, not just on the button -- a frozen
   number with no explanation looks like the app hung. */
#bs-timer.is-paused { opacity: 0.65; }
#bs-timer.is-paused::after {
  content: ' ⏸ PAUSED';
  font-size: 0.28em; letter-spacing: 3px; vertical-align: middle;
  opacity: 0.8;
}

/* A teaser gone LIVE (the tournament tile): full presence, pointer back,
   and the flag flips from the gold SOON to the house green -- same slot
   players learned, now a door. */
.mp-tile-door { cursor: pointer; opacity: 1 !important; }
.mp-tile-door .mp-tile-flag { background: rgba(120, 255, 150, 0.85); }

/* ================= LIVE SHOWS IN THE SERVER BROWSER =================
   Tournaments list above the rooms, in their own section. CYAN, and the
   colour is the same argument it is inside the arena: green is the field,
   cyan is the WATCHING side, gold is the champion. A show in the browser is
   overwhelmingly a thing you go to watch, so it wears the audience's
   colour and needs no fourth signal invented for it.

   The row reuses .term-row's grid untouched -- six children, same six
   columns -- so the section lines up under the same headers as the rooms
   below it. Only the rail, the counts cell and the button differ. */
.term-section {
  display: flex; align-items: center; gap: 14px;
  padding: 16px 30px 8px;
  font-family: 'Courier New', monospace;
  font-size: 15px; letter-spacing: 4px;
}
.term-section-name { color: #7fdcf5; }
.term-section-rule {
  flex: 1; height: 1px;
  background: linear-gradient(to right, rgba(63, 224, 255, 0.45), rgba(63, 224, 255, 0.05));
}
.term-section-count { color: #cdf3ff; opacity: 0.7; }
#show-list-wrap.hidden { display: none; }

.term-row.term-row-show {
  border-left: 2px solid rgba(63, 224, 255, 0.55);
  background: rgba(63, 224, 255, 0.045);
}
.term-row.term-row-show:hover {
  background: rgba(63, 224, 255, 0.1);
  border-color: rgba(63, 224, 255, 0.4);
  border-left-color: #3fe0ff;
}
.term-row.term-row-show .term-row-name { color: #e6faff; }
.term-row.term-row-show .term-row-mode { color: #8fd4e8; }

/* TWO NUMBERS: the field, then the gallery. The crowd is dimmed and
   prefixed rather than given a column of its own -- it is the second fact
   about a show, and a second column would have to appear in the rooms'
   header too, where it would mean nothing. */
.term-row-showcount { display: flex; align-items: baseline; gap: 7px; }
.term-row-showcount b { font-weight: 400; color: #cdeecb; }
.term-row-showcount i {
  font-style: normal; font-size: 0.78em;
  color: #7fdcf5; opacity: 0.8;
}

/* TWO DOORS, STACKED. The action column is a fixed 130px shared with the
   rooms table -- widening it for shows alone would pull the fr columns and
   knock every row out of line with the headers above -- so the pair stacks
   instead of sitting side by side. Costs a little row height, keeps every
   column aligned. */
.term-row-actions {
  justify-self: end;
  display: flex; flex-direction: column; align-items: stretch;
  gap: 6px;
}
.term-row.term-row-show .term-row-join {
  justify-self: stretch;
  border-color: rgba(63, 224, 255, 0.5);
  color: #3fe0ff;
  text-align: center;
  padding: 5px 10px;
  font-size: 12px;
}
.term-row.term-row-show .term-row-join:hover:not(:disabled) {
  background: rgba(63, 224, 255, 0.16);
}
/* SPECTATE is the quieter of the two: entering a field is the bigger act,
   and the two should not shout at the same volume. */
.term-row.term-row-show .term-row-join.term-row-watch {
  border-color: rgba(63, 224, 255, 0.28);
  color: #9fd8e8;
}
/* A DEAD COMPETE STAYS ON THE ROW rather than disappearing: a control that
   vanishes reads as a bug, and its title says why it is out. */
.term-row.term-row-show .term-row-join:disabled {
  border-color: rgba(120, 150, 160, 0.25);
  color: #5f7780;
  cursor: not-allowed;
}

/* ============ THE CUT IS A STANDINGS BOARD, NOT A ROSTER ============
   The board spends the round in roster order, which is right while
   everyone is equal. At the cut it re-seats by rank, because THE ORDER IS
   THE VERDICT: read top-left to bottom-right and you have the result,
   instead of hunting sixteen scattered plates for the word ADVANCES.

   Three things arrive with it. A RANK, so a position can be named. The
   SCORE -- the weighted number that actually decides, which the board
   never showed at all: it drew the two inputs (FIELD and CROWD) and hid
   what they resolve to, so a higher field score being cut beside a lower
   one looked like a bug in the maths rather than the crowd doing its job.
   And a CUT LINE on the first casualty, so the boundary has a name. */

.ar-plate-rank {
  position: absolute; top: 8px; left: 10px;
  font-family: 'Courier New', monospace;
  font-size: 15px; letter-spacing: 1px;
  color: #86ff66; opacity: 0.55;
  font-variant-numeric: tabular-nums;
}
.ar-plate-rank:empty { display: none; }

/* THE DECIDING NUMBER, sized to be read first -- bigger than either input
   under it, because it outranks them. */
.ar-plate-score {
  font-family: 'Courier New', monospace;
  font-size: 30px; line-height: 1;
  letter-spacing: 1px; color: #eaffea;
  font-variant-numeric: tabular-nums;
}
.ar-plate-score.hidden, .ar-plate-score:empty { display: none; }
.ar-plate.is-cut .ar-plate-score,
.ar-plate.is-falling .ar-plate-score { color: #93a493; }

/* THE CUT LINE, on the first plate below it. A left edge rather than a
   rule across the grid: the board wraps, so a literal line has no stable
   place to sit, and the boundary only needs to be nameable. */
.ar-plate.is-cut-line::before {
  content: 'CUT LINE';
  position: absolute;
  top: -1px; left: -1px; right: -1px;
  padding: 3px 0 4px;
  text-align: center;
  font-family: 'Courier New', monospace;
  font-size: 11px; letter-spacing: 4px;
  color: #0a1408;
  background: #ff5252;
  z-index: 2;
}
.ar-plate.is-cut-line { border-top-color: #ff5252; }

/* ---- SWITCHED OFF, STILL A PLATE ----
   The old cut state was opacity alone, which left the faces as muddy
   half-lit blobs floating on the room's own background -- it read as a
   rendering fault rather than as a light going out. The plate STAYS a
   plate: its own dark fill, its own dim rim, and the face desaturated
   rather than faded, which is what "out" actually looks like. */
.ar-plate.is-cut, .ar-plate.is-falling {
  box-shadow: inset 0 0 0 1px rgba(150, 168, 150, 0.3);
  background: linear-gradient(180deg, rgba(20, 24, 20, 0.97), rgba(10, 12, 10, 0.99));
  opacity: 1;
}
/* Greyscale is the state; the dimming rides ON it rather than replacing
   it, so a colour never survives at half strength looking like mud. */
.ar-plate.is-cut .ar-plate-face,
.ar-plate.is-falling .ar-plate-face {
  filter: grayscale(1) brightness(0.62);
}
.ar-plate.is-cut .ar-plate-name,
.ar-plate.is-falling .ar-plate-name { color: #93a493; }
.ar-plate.is-cut .ar-plate-meta,
.ar-plate.is-falling .ar-plate-meta { opacity: 0.4; }
.ar-plate.is-cut .ar-plate-rank,
.ar-plate.is-falling .ar-plate-rank { color: #93a493; opacity: 0.5; }
/* The bars go monochrome with the rest -- a green FIELD bar on a dead
   plate still reads as a live score. */
.ar-plate.is-cut .ar-bar-track i,
.ar-plate.is-falling .ar-bar-track i { background: #6d7c6d !important; }
.ar-plate.is-cut .ar-bar b,
.ar-plate.is-falling .ar-bar b { color: #93a493; }

/* THE FALL. Still transform+opacity only, and it no longer fades the
   plate out of existence -- it drops and settles at a readable dimness,
   because a standings board people are reading is not the moment to make
   half the rows unreadable. */
.ar-plate.is-falling { transform: translateY(10px) scale(0.97); opacity: 0.78; }
.ar-plate.is-cut:not(.is-falling) { opacity: 0.78; }

/* The survivors are the only lit thing while the ceremony runs -- kept,
   and now it reads against a plate rather than against the room. */
#ar-board.is-cutting .ar-plate:not(.is-cut):not(.is-falling) {
  box-shadow: inset 0 0 0 1px rgba(57, 255, 20, 0.95);
}

@media (prefers-reduced-motion: reduce) {
  .ar-plate.is-falling { transform: none; }
}
