:root {
  --bg-1: #fce6c0;
  --bg-2: #f7c4a4;
  --ink: #5a3a1a;
  /* Deeper ink for borders, button shadows, text strokes — gives surfaces a
     real "edge" instead of the wishy-washy translucent brown borders the
     previous pass used. */
  --ink-deep: #3a1810;
  --accent: #d24a2e;
  --accent-2: #ff7e3a;
  --accent-3: #ffb245;
  --accent-deep: #8a2818;
  --danger: #e84a5f;
  --danger-glow: rgba(232, 74, 95, 0.6);
  --panel-bg: #fff7e6;
  --panel-bg-2: #ffe8c4;
  --hud-bg: rgba(255, 255, 255, 0.55);
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  /* Display font for headings + button labels. Body keeps system stack via
     the html/body rule below — Fredoka loads from Google Fonts. */
  --font-display: 'Fredoka', -apple-system, "Segoe UI", Roboto, sans-serif;
}

* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;
  /* `manipulation` keeps tap-to-click responsive but kills the iOS double-tap
     zoom across the whole document — including HUD blocks, queue slots, and
     the gap between elements. Stricter `touch-action: none` on body and
     #game still wins via specificity for the playfield itself. */
  touch-action: manipulation;
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  /* Layered backdrop: a centered warm radial "spotlight" sits over the
     standard top→bottom cream gradient. The spotlight makes the playfield
     feel framed and pulls the eye toward the center without an explicit
     vignette darkening the corners. Subtle — opacity is in the gradient
     itself, not on a separate layer. */
  background:
    radial-gradient(ellipse 90% 65% at 50% 38%,
      rgba(255, 240, 210, 0.55) 0%,
      rgba(255, 240, 210, 0) 60%),
    linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
  /* Fredoka is the primary UI font — friendly, rounded, gives the chrome a
     "designed game" feel instead of the system-stack default that reads as
     generic web app. Falls back to system stack if the Google Fonts load
     fails so the UI stays readable. */
  font-family: 'Fredoka', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  color: var(--ink);
  overflow: hidden;
  user-select: none;
  touch-action: none;
  overscroll-behavior: none;
  -webkit-user-select: none;
}

/* Ambient atmosphere — two parallax sparkle layers + a subtle warm
   "spotlight" radial that pulls focus to the center of the viewport.
   The two layers drift at different speeds: the back layer (::before)
   has small distant stars at 22s, the front layer (::after) has larger
   warmer sparkles at 38s. Counter-direction drift on the front layer
   amplifies the parallax — back layer rises, front layer falls slowly,
   and the eye reads it as depth. */
body::before {
  content: '';
  position: fixed;
  inset: -10% -5% -10% -5%;
  pointer-events: none;
  z-index: 0;
  opacity: 0.55;
  background-image:
    radial-gradient(circle 1.5px at 10% 20%, rgba(255,255,255,0.95), transparent 100%),
    radial-gradient(circle 1px   at 25% 70%, rgba(255,255,255,0.85), transparent 100%),
    radial-gradient(circle 1.2px at 45% 35%, rgba(255,255,255,0.9),  transparent 100%),
    radial-gradient(circle 1px   at 60% 85%, rgba(255,255,255,0.8),  transparent 100%),
    radial-gradient(circle 1.5px at 75% 50%, rgba(255,255,255,0.95), transparent 100%),
    radial-gradient(circle 1px   at 90% 15%, rgba(255,255,255,0.8),  transparent 100%),
    radial-gradient(circle 1.2px at 18% 55%, rgba(255,210,150,0.9),  transparent 100%),
    radial-gradient(circle 1px   at 55% 12%, rgba(255,210,150,0.85), transparent 100%),
    radial-gradient(circle 1px   at 82% 78%, rgba(255,210,150,0.85), transparent 100%);
  background-size: 100% 200%, 100% 200%, 100% 200%, 100% 200%, 100% 200%,
                   100% 200%, 100% 200%, 100% 200%, 100% 200%;
  animation: sparkleDrift 22s linear infinite;
}

/* Front sparkle layer — bigger, warmer, slower, drifting in the opposite
   direction. The size difference + counter-drift sells the parallax. */
body::after {
  content: '';
  position: fixed;
  inset: -10% -5% -10% -5%;
  pointer-events: none;
  z-index: 0;
  opacity: 0.4;
  background-image:
    radial-gradient(circle 2.5px at 15% 30%, rgba(255,200,140,0.9), transparent 100%),
    radial-gradient(circle 2px   at 70% 60%, rgba(255,210,150,0.8), transparent 100%),
    radial-gradient(circle 2.5px at 35% 80%, rgba(255,235,180,0.85),transparent 100%),
    radial-gradient(circle 2px   at 85% 25%, rgba(255,180,100,0.8), transparent 100%),
    radial-gradient(circle 2.5px at 50% 50%, rgba(255,200,140,0.85),transparent 100%);
  background-size: 100% 200%, 100% 200%, 100% 200%, 100% 200%, 100% 200%;
  animation: sparkleDriftSlow 38s linear infinite;
}

@keyframes sparkleDrift {
  from { background-position: 0% 0%, 30% 10%, 60% 5%, 80% 30%, 0% 50%,
                              50% 80%, 25% 60%, 70% 40%, 10% 90%; }
  to   { background-position: 0% -100%, 30% -90%, 60% -95%, 80% -70%, 0% -50%,
                              50% -20%, 25% -40%, 70% -60%, 10% -10%; }
}

@keyframes sparkleDriftSlow {
  from { background-position: 0% 100%, 30% 80%, 60% 60%, 80% 40%, 50% 20%; }
  to   { background-position: 0% 0%,   30% -20%,60% -40%,80% -60%,50% -80%; }
}

#app {
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100vh;
  height: 100dvh;
  width: 100vw;
  padding: calc(8px + var(--safe-top)) 8px calc(8px + var(--safe-bottom));
  position: relative;
}

/* ------------ HUD ------------ */
.hud {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  width: 100%;
  max-width: 460px;
  gap: 8px;
  margin-bottom: 8px;
  min-height: 64px;
}

.hud-self {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  /* Right padding bumped to 50px to clear the floating settings gear (40px
     icon + 8px from the viewport edge). Without this, the NEXT label gets
     hidden behind the gear during gameplay. */
  padding: 8px 50px 8px 12px;
  /* Sticker treatment matching .panel and .big-btn — 2-stop cream gradient,
     dark border, inner highlight, solid bottom edge shadow. The HUD now
     reads as a deliberate "scoreboard" surface instead of a translucent
     pill that just blurs whatever's behind it. */
  background: linear-gradient(180deg, #fff7e6 0%, #ffe8c4 100%);
  border: 2.5px solid var(--ink-deep);
  border-radius: 16px;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    inset 0 -1.5px 0 rgba(120, 70, 30, 0.08),
    0 3px 0 var(--ink-deep),
    0 5px 12px rgba(58, 24, 16, 0.22);
  flex: 1;
  justify-content: flex-end;
  min-height: 64px;
}

/* Same paper grain treatment as .panel — keeps the surface language
   consistent across panel + HUD. The HUD's children are flex items
   already and won't fight z-index because the noise is at z=0 and they
   sit above by default flow. */
.hud-self::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  mix-blend-mode: multiply;
  opacity: 0.07;
  z-index: 0;
}
.hud-self > * { position: relative; z-index: 1; }

.hud-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 50px;
  padding: 0 4px;
}

.hud-label {
  font-family: var(--font-display);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.4px;
  color: var(--ink-deep);
  /* Bumped from 0.6 → 0.85 + 700 weight so SCORE / BEST / RD / NEXT
     read at a glance against the cream HUD gradient. The previous
     0.6 was readable in a quiet preview but disappeared during a
     busy run when the eye is on the playfield. */
  opacity: 0.85;
  text-transform: uppercase;
}

/* Score / Best numbers. Display font + tabular-nums + a subtle dark stroke
   gives the digits a "scoreboard" feel — they read as deliberate game
   numerals instead of whatever the system stack happened to be. */
.hud-value {
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
  color: var(--ink-deep);
  text-shadow: 0 1.5px 0 rgba(255, 255, 255, 0.5);
  display: inline-block;
  transform-origin: center center;
  letter-spacing: 0.5px;
}

.hud-value.score-bump {
  animation: scoreBump 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes scoreBump {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.18); color: var(--accent); }
  100% { transform: scale(1); }
}

#next-canvas {
  width: 56px;
  height: 56px;
  display: block;
  /* Soft "ground" shadow underneath — sells the floating-fruit feel
     without adding any extra DOM. Lower-only via the drop-shadow
     filter so the shadow lives below the canvas, not around it. */
  filter: drop-shadow(0 4px 4px rgba(58, 24, 16, 0.18));
}
/* Drop-roll-in entrance — fired by triggerNextLaunchAnimation in
 * main.js each time the player drops. The preview canvas repaints
 * with the new nextTier and the .is-rolling-in class plays a smooth
 * slide-in from the right edge of the slot.
 *
 * Easing is a clean ease-out quint (cubic-bezier(0.22, 1, 0.36, 1))
 * — strong deceleration, NO overshoot. Earlier passes used an
 * easeOutBack curve combined with a manual overshoot keyframe at
 * 55%, which stacked two springs and read as "wobbling into place".
 * The motion is now dominated by translation (reads as "sliding"),
 * with a tiny scale change for a hint of weight. Pure transform +
 * opacity, GPU-cheap. */
#next-canvas.is-rolling-in {
  animation: nextRollInRight 300ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes nextRollInRight {
  0%   { transform: translateX(28px) scale(0.92); opacity: 0; }
  100% { transform: translateX(0)    scale(1);    opacity: 1; }
}
#hud-next-block.cursed #next-canvas {
  animation: none;
}
@media (prefers-reduced-motion: reduce) {
  #next-canvas.is-rolling-in { animation: none; }
}

.hud-rounds.hidden,
.hud-block-best.hidden,
.hud-cascade.hidden {
  display: none;
}

/* Cascade-only HUD block — pressure pips + score multiplier readout. */
.hud-cascade {
  min-width: 56px;
}
.hud-pressure-pips {
  display: flex;
  gap: 2px;
  margin-top: 2px;
}
.hud-pressure-pips .pip {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: rgba(120, 70, 30, 0.22);
  transition: background 200ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hud-pressure-pips .pip.lit {
  background: var(--accent-2);
}
.hud-pressure-pips .pip.lit.high {
  background: var(--accent);
}
.hud-cascade.bump-pressure .pip.lit:last-child {
  transform: scale(1.6);
}
.hud-multiplier {
  font-size: 13px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--accent-2);
  margin-top: 2px;
  line-height: 1.1;
}

/* CURSE_PREVIEW overlay — covers the NEXT canvas with a "?" while active. */
#hud-next-block { position: relative; }
.next-curse-overlay {
  position: absolute;
  top: 18px;          /* below the "NEXT" label */
  left: 50%;
  transform: translateX(-50%);
  width: 56px;
  height: 56px;
  border-radius: 10px;
  background: rgba(40, 0, 60, 0.85);
  color: #ffd866;
  font-size: 38px;
  font-weight: 900;
  display: none;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  border: 2px solid rgba(255, 200, 100, 0.4);
}
#hud-next-block.cursed .next-curse-overlay {
  display: flex;
}

/* Solo mode picker — same panel aesthetic as the main menu. */
.solo-picker-panel {
  text-align: center;
}
.solo-picker-panel h2 {
  margin: 0 0 14px;
  font-size: 22px;
  color: var(--accent);
}

.hud-rounds-dots {
  display: flex;
  gap: 4px;
  margin-top: 2px;
}

.hud-rounds-dots .dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: rgba(0,0,0,0.15);
}

.hud-rounds-dots .dot.win {
  background: #2ec27e;
  box-shadow: 0 0 6px rgba(46, 194, 126, 0.6);
}

.hud-rounds-dots .dot.loss {
  background: var(--danger);
}

/* ------------ PiP (opponent) ------------ */
.pip-slot {
  /* Reduced 130 → 96 to close the dead-space gap between the PiP and the
     HUD-self block. Aspect ratio stays at 380:600 so the canvas content
     scales down proportionally; everything inside the PiP is positioned by
     percentage so nothing breaks. */
  width: 96px;
  flex-shrink: 0;
  position: relative;
}

.pip-slot.hidden {
  display: none;
}

.pip {
  position: relative;
  width: 100%;
  aspect-ratio: 380 / 600;
  background: rgba(255, 255, 255, 0.4);
  /* Soft drop shadow only — the dark chunky frame from the previous pass
     was too loud at this size, made the mini board look like a UI chip
     stuck to the HUD instead of a playfield mirror. */
  border-radius: 14px;
  overflow: hidden;
  box-shadow: 0 4px 12px rgba(58, 24, 16, 0.22);
  transition: box-shadow 0.3s ease;
  z-index: 5;
}

.pip canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.pip-frame {
  position: absolute;
  inset: 0;
  border-radius: 12px;
  pointer-events: none;
  /* Border thickness bumped from 3 → 4px so the danger state reads
     instantly. transparent default keeps the resting frame invisible
     so only the active states (danger / hit / combo / big-flash)
     show a colored ring. */
  border: 4px solid transparent;
  transition: border-color 0.3s ease;
}

.pip.danger .pip-frame {
  border-color: var(--danger);
  /* Stronger glow than before so peripheral vision picks up the
     opponent's near-overflow without the player having to look at
     the PiP. dangerPulse animation untouched — same timing. */
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.5) inset,
    0 0 12px 2px var(--danger-glow);
  animation: dangerPulse 0.7s ease-in-out infinite alternate;
}

/* Big-moment flash — bright border + glow that decays. Never scales the PiP
   (growing it into the playfield was confusing during a match). */
.pip.big-flash .pip-frame {
  border-color: #ffd866;
  box-shadow: 0 0 20px 6px rgba(255, 216, 102, 0.85),
              0 0 0 2px rgba(255, 255, 255, 0.6) inset;
  animation: pipBigFlash 0.7s ease-out;
}

@keyframes pipBigFlash {
  0%   { box-shadow: 0 0 0 0 rgba(255, 216, 102, 0); border-color: #fff; }
  20%  { box-shadow: 0 0 28px 10px rgba(255, 216, 102, 0.95); border-color: #fff; }
  100% { box-shadow: 0 0 0 0 rgba(255, 216, 102, 0); border-color: transparent; }
}

.pip.combo-flash .pip-frame {
  border-color: var(--accent-2);
  box-shadow: 0 0 18px var(--accent-2);
}

/* PiP hit marker — fires when one of the player's attacks lands on the AI.
   Two crossed lines forming an "X" pop in over the PiP and fade out, plus
   a brief shake of the PiP itself for impact. */
.pip-hit-marker {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 7;
  opacity: 0;
}

.hit-x-line {
  position: absolute;
  width: 70%;
  height: 4px;
  background: #fff;
  border-radius: 999px;
  box-shadow: 0 0 8px rgba(255, 80, 80, 0.95),
              0 0 14px rgba(255, 80, 80, 0.55),
              0 0 2px rgba(0, 0, 0, 0.6);
}

.hit-x-1 { transform: rotate(45deg); }
.hit-x-2 { transform: rotate(-45deg); }

.pip.hit {
  animation: pipHitShake 0.32s ease-out;
}

.pip.hit .pip-hit-marker {
  animation: pipHitMarkerPop 0.5s ease-out forwards;
}

@keyframes pipHitShake {
  0%, 100% { transform: translate(0, 0); }
  20%      { transform: translate(-3px,  2px); }
  40%      { transform: translate( 3px, -2px); }
  60%      { transform: translate(-2px,  2px); }
  80%      { transform: translate( 2px, -1px); }
}

@keyframes pipHitMarkerPop {
  0%   { opacity: 0; transform: scale(0.45); }
  20%  { opacity: 1; transform: scale(1.30); }
  40%  { transform: scale(1.0); }
  100% { opacity: 0; transform: scale(0.95); }
}

@keyframes dangerPulse {
  from { box-shadow: 0 0 6px 1px var(--danger-glow); }
  to   { box-shadow: 0 0 16px 4px var(--danger-glow); }
}

.pip-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 6px 8px;
}

.pip-top {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 0.5px;
}

.pip-name {
  background: rgba(0,0,0,0.55);
  color: #fff;
  padding: 2px 6px;
  border-radius: 6px;
  text-shadow: 0 1px 1px rgba(0,0,0,0.4);
}

.pip-danger {
  background: var(--danger);
  color: #fff;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  display: none;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 900;
}

.pip.danger .pip-danger {
  display: flex;
}

.pip-bottom {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
}

.pip-score {
  font-size: 15px;
  font-weight: 900;
  color: #fff;
  /* Dark pill behind the digits so the score reads clearly over any
     PiP backdrop (cream playfield, fruit-colored bodies, etc.). The
     existing text-shadow stays as a fallback for the very edge of
     the digit shapes that bleed past the pill. */
  background: rgba(0, 0, 0, 0.55);
  padding: 1px 7px;
  border-radius: 6px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  font-variant-numeric: tabular-nums;
}

.pip-combo {
  background: linear-gradient(135deg, var(--accent-2), var(--accent-3));
  color: #fff;
  padding: 2px 6px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 900;
  box-shadow: 0 2px 6px rgba(255, 126, 58, 0.4);
  animation: comboPop 0.4s ease-out;
}

.pip-combo.hidden {
  display: none;
}

@keyframes comboPop {
  0% { transform: scale(0.4); opacity: 0; }
  50% { transform: scale(1.25); }
  100% { transform: scale(1); opacity: 1; }
}

.pip-incoming-strip {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: rgba(0,0,0,0.15);
  display: flex;
  gap: 1px;
  padding: 0 1px;
}

.pip-incoming-strip .pip-incoming-blip {
  flex: 1;
  background: var(--accent);
  border-radius: 2px;
  animation: incomingFlash 0.6s ease-in-out infinite alternate;
}

@keyframes incomingFlash {
  from { opacity: 0.5; }
  to { opacity: 1; }
}

/* ------------ Banners (combo, incoming, round) ------------ */
.combo, .incoming-banner, .round-banner, .slow-mo-banner, .hazard-banner {
  position: absolute;
  top: 88px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  pointer-events: none;
  font-weight: 900;
  letter-spacing: 1px;
  text-align: center;
}

/* Hazard warning banner — shown during the 2s telegraph between hazard
   pick and hazard fire in Cascade mode. Amber to communicate "incoming bad
   thing — brace". Sits just below the slow-mo banner position so the two
   never overlap (different modes, but defensive). */
.hazard-banner {
  top: 175px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  background: linear-gradient(135deg, #ff9b3a, #c54040);
  color: #fff;
  padding: 10px 22px;
  border-radius: 12px;
  font-size: 18px;
  letter-spacing: 1.5px;
  box-shadow: 0 6px 22px rgba(197, 64, 64, 0.55), 0 0 0 2px rgba(255,255,255,0.4) inset;
  animation: hazardPulse 0.45s ease-in-out infinite alternate;
  white-space: nowrap;
}
.hazard-banner.hidden { display: none; }
.hazard-banner-icon { font-size: 22px; }

@keyframes hazardPulse {
  from { transform: translateX(-50%) scale(1); filter: brightness(1); }
  to   { transform: translateX(-50%) scale(1.06); filter: brightness(1.18); }
}

/* Slow-mo indicator — sits below the combo banner, cool-blue palette to
   contrast against the warm fruit visuals. Pulsing animation tells the
   player the effect is live; the countdown reads as "X.Y seconds left".
   Sits above the playfield without blocking any drop interaction (z-index
   is above particles but the element has pointer-events: none). */
.slow-mo-banner {
  top: 130px;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: linear-gradient(135deg, #2a73ff, #4ec3ff);
  color: #fff;
  padding: 8px 18px;
  border-radius: 999px;
  font-size: 16px;
  box-shadow: 0 6px 18px rgba(80, 180, 255, 0.55), 0 0 0 2px rgba(255,255,255,0.4) inset;
  animation: slowMoPulse 0.9s ease-in-out infinite alternate;
}
.slow-mo-banner.hidden { display: none; }
.slow-mo-banner .slow-mo-icon {
  font-size: 18px;
  line-height: 1;
}
.slow-mo-banner .slow-mo-count {
  font-variant-numeric: tabular-nums;
  min-width: 30px;
  text-align: right;
  opacity: 0.92;
}

@keyframes slowMoPulse {
  from { filter: brightness(1) saturate(1); transform: translateX(-50%) scale(1); }
  to   { filter: brightness(1.18) saturate(1.2); transform: translateX(-50%) scale(1.04); }
}

/* MAGNET active — subtle cyan inset glow on the playfield border, plus a
   gentle pulsing fixed overlay so the player feels the effect without a
   countdown badge eating screen space. The class lives on `#app` so the
   overlay covers exactly the play area. */
#app.magnet-active::after {
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 5;
  box-shadow: inset 0 0 80px 18px rgba(126, 240, 255, 0.45);
  animation: magnetPulse 1.0s ease-in-out infinite alternate;
}

@keyframes magnetPulse {
  from { box-shadow: inset 0 0 60px 14px rgba(126, 240, 255, 0.32); }
  to   { box-shadow: inset 0 0 95px 22px rgba(126, 240, 255, 0.55); }
}

/* Combo banner — gets the logo-stroke treatment the title uses, so the
   "x4 COMBO" text reads as a baked-in design element rather than gradient
   text on a pill. The chunky orange→amber sticker pill stays underneath
   for tier-1/2 combos; higher tiers swap the gradient + scale up via the
   tier-specific rules below. */
.combo {
  font-family: var(--font-display);
  background: linear-gradient(180deg, #ffc04a 0%, #ff7e3a 55%, #d24a2e 100%);
  color: #fff;
  padding: 7px 18px;
  border: 2.5px solid var(--accent-deep);
  border-radius: 999px;
  font-size: 17px;
  font-weight: 700;
  -webkit-text-stroke: 0.75px rgba(58, 12, 6, 0.6);
  paint-order: stroke fill;
  text-shadow: 0 2px 0 rgba(58, 12, 6, 0.45);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.55),
    0 4px 0 var(--accent-deep),
    0 6px 14px rgba(255, 126, 58, 0.45);
  transition: opacity 0.2s, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
  transform-origin: top center;
}

.combo.hidden {
  opacity: 0;
  transform: translateX(-50%) scale(0.8);
}

/* Bigger combos = bigger, more dramatic badge. */
.combo[data-tier="2"] { transform: translateX(-50%) scale(1.0); }
.combo[data-tier="3"] {
  transform: translateX(-50%) scale(1.18);
  background: linear-gradient(135deg, #ff5e2e, #ffae45);
  box-shadow: 0 5px 16px rgba(255, 90, 30, 0.55);
}
.combo[data-tier="4"] {
  transform: translateX(-50%) scale(1.34);
  background: linear-gradient(135deg, #ff3e60, #ff9e3a);
  box-shadow: 0 6px 20px rgba(255, 60, 80, 0.65);
  letter-spacing: 1.5px;
}
.combo[data-tier="5"] {
  transform: translateX(-50%) scale(1.5);
  background: linear-gradient(135deg, #c779ff, #ff3e60, #ffae45);
  box-shadow: 0 8px 24px rgba(199, 121, 255, 0.6);
  letter-spacing: 2px;
}
.combo[data-tier="6"] {
  transform: translateX(-50%) scale(1.7) rotate(-2deg);
  background: linear-gradient(135deg, #ffd866, #ff6ad5, #4ec3ff, #86d050);
  box-shadow: 0 10px 30px rgba(255, 200, 100, 0.7);
  letter-spacing: 2.5px;
  animation: comboPulse 0.45s ease-in-out infinite alternate;
}

@keyframes comboPulse {
  from { filter: brightness(1) saturate(1); }
  to   { filter: brightness(1.15) saturate(1.25); }
}

.incoming-banner {
  top: 130px;
  background: var(--danger);
  color: #fff;
  padding: 10px 22px;
  border-radius: 12px;
  font-size: 18px;
  font-weight: 900;
  letter-spacing: 1.5px;
  box-shadow: 0 6px 20px rgba(232, 74, 95, 0.55), 0 0 0 2px rgba(255,255,255,0.4) inset;
  animation: bannerPulse 0.45s ease-in-out infinite alternate;
  white-space: nowrap;
  transform-origin: center center;
}

.incoming-banner.hidden {
  display: none;
}

@keyframes shake {
  from { transform: translateX(-50%) translateY(0); }
  to   { transform: translateX(-52%) translateY(-1px); }
}

@keyframes bannerPulse {
  from { transform: translateX(-50%) scale(1.0);
         box-shadow: 0 6px 20px rgba(232, 74, 95, 0.55),
                     0 0 0 2px rgba(255,255,255,0.4) inset; }
  to   { transform: translateX(-50%) scale(1.13);
         box-shadow: 0 10px 28px rgba(232, 74, 95, 0.85),
                     0 0 0 3px rgba(255,255,255,0.6) inset; }
}

/* Round banner — display font with the title's stroke + drop shadow
   treatment so a "Round 1" / "Round 2" announcement reads as a designed
   moment rather than a black-pill toast. No fill panel anymore — the
   stroked text floats on its own which feels more cinematic. */
.round-banner {
  top: 30%;
  font-family: var(--font-display);
  color: #fff;
  padding: 14px 32px;
  font-size: 36px;
  font-weight: 700;
  letter-spacing: 2px;
  -webkit-text-stroke: 4px var(--ink-deep);
  paint-order: stroke fill;
  text-shadow:
    0 4px 0 var(--ink-deep),
    0 8px 22px rgba(0, 0, 0, 0.45);
  animation: roundIn 1.6s ease-out forwards;
}

.round-banner.hidden {
  display: none;
}

@keyframes roundIn {
  0%   { opacity: 0; transform: translateX(-50%) scale(0.4); }
  20%  { opacity: 1; transform: translateX(-50%) scale(1.1); }
  35%  { transform: translateX(-50%) scale(1); }
  85%  { opacity: 1; transform: translateX(-50%) scale(1); }
  100% { opacity: 0; transform: translateX(-50%) scale(0.95); }
}

/* ------------ Main game ------------ */
#game-wrap {
  flex: 1;
  width: 100%;
  max-width: 460px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  min-height: 0;
}

/* Wrapper around the single Pixi canvas. Carries the .shake /
   .big-shake animation classes. The earlier chunky black frame on this
   wrapper was visually too loud against the cream playfield — the
   playfield IS the frame for the action, and a heavy outline made it
   feel boxed in. Now: rounded corners + a soft drop shadow only, no
   visible border. The board reads as "lifted off the page" without a
   competing dark outline. */
#game-stack {
  position: relative;
  display: inline-block;
  font-size: 0;             /* kill inline-block whitespace baseline gap */
  border-radius: 18px;
  /* Stronger lifted shadow than the previous single soft shadow —
     gives the board a more deliberate "physical play surface" feel
     without competing with the fruit. Two-stop drop for a sharper
     near-edge with a softer ambient glow underneath. */
  box-shadow:
    0 10px 24px rgba(58, 24, 16, 0.28),
    0 2px 4px rgba(58, 24, 16, 0.18);
  transition: box-shadow 220ms ease;
}

/* Faint paper grain — sits BEHIND the canvas (default DOM order
   places ::before below sibling content). Pixi renders with
   backgroundAlpha:0 so transparent regions of the WebGL output let
   the grain show through. Static SVG noise — paints once, no
   animation, GPU-cheap. */
#game-stack::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  mix-blend-mode: multiply;
  opacity: 0.06;
  z-index: 0;
}

/* State overlay — sits ABOVE the canvas (z:2 over the canvas's
   default static stacking). Default invisible; per-state class
   rules below give it a tint / haze / glow / pulse. Pure
   transform + opacity transitions keep it cheap. */
#game-stack::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  background: transparent;
  transition: opacity 280ms ease;
}

#game {
  position: relative;
  z-index: 1;
  display: block;
  /* Layered surface: top highlight (fades quickly) over a warm cream
     vertical gradient. Pixi's WebGL clear is alpha 0 so this CSS
     background reads through wherever the canvas content is
     transparent. The thin inset shadow gives a subtle inner
     vignette — frames the playfield without competing with fruit. */
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0) 16%),
    linear-gradient(180deg, #fff5d8 0%, #ffe5b8 100%);
  box-shadow:
    inset 0 0 28px 4px rgba(58, 24, 16, 0.07),
    inset 0 1px 0 rgba(255, 255, 255, 0.7);
  border-radius: 18px;
  cursor: pointer;
  touch-action: none;
  /* iOS-specific: stop the system from synthesizing double-tap zoom on rapid taps */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* === Gameplay-reactive board state classes ===
 *
 * Toggled by SoloMode and PvPMatch on #game-stack. Most are
 * mutually-exclusive in practice (board-cascade is solo-only,
 * board-pvp is pvp-only), but rules are ordered so that more
 * urgent states (danger) win when overlapping with ambient ones
 * (cascade / pvp). board-combo is additive via box-shadow on the
 * wrapper (composes with the base shadow). board-milestone is a
 * brief 600ms flash + ring pulse on the same ::after overlay.
 *
 * All effects are transform / opacity / box-shadow only — no
 * filter, no backdrop-filter — so they're cheap on mobile. */

/* PvP ambient: mildly more energetic frame accent — cool red-tinted
   shadow halo that subtly says "this is a competitive surface". */
#game-stack.board-pvp {
  box-shadow:
    0 0 0 2px rgba(232, 74, 95, 0.22),
    0 12px 28px rgba(232, 74, 95, 0.18),
    0 2px 4px rgba(58, 24, 16, 0.18);
}

/* Cascade pressure tint: warm amber haze fading from top, signals
   the rising-pressure mode without ever obscuring fruit. */
#game-stack.board-cascade::after {
  opacity: 1;
  background: linear-gradient(180deg, rgba(255, 178, 69, 0.10) 0%, rgba(255, 178, 69, 0) 60%);
}

/* Danger: red top haze + frame glow. Declared AFTER pvp/cascade so
   it wins composition when both are set. Static — no pulse — to
   avoid distracting motion at the worst possible moment. */
#game-stack.board-danger::after {
  opacity: 1;
  background: linear-gradient(180deg, rgba(232, 74, 95, 0.22) 0%, rgba(232, 74, 95, 0) 40%);
  box-shadow:
    inset 0 0 0 2px rgba(232, 74, 95, 0.50),
    inset 0 0 22px 6px rgba(232, 74, 95, 0.20);
}

/* Combo ≥ 3: edge shimmer ring that doesn't darken the playfield.
   Pure box-shadow on the wrapper. Intensity scales with combo via
   the --combo-tier CSS variable (set by JS to combo value clamped
   1-6). Bigger ring spread + softer outer glow + higher alpha at
   higher tiers. The .board-pvp.board-combo override layers in the
   PvP red shadow underneath so PvP boards keep their identity. */
#game-stack.board-combo {
  /* Tier defaults to 3 (entry threshold) if the variable isn't
     set. calc() smoothly scales each shadow component. */
  box-shadow:
    0 0 0 calc((var(--combo-tier, 3) - 2) * 1px + 1px) rgba(255, 200, 100, 0.55),
    0 0 calc(var(--combo-tier, 3) * 4px + 6px) calc(var(--combo-tier, 3) * 1.5px) rgba(255, 178, 69, calc(var(--combo-tier, 3) * 0.05 + 0.20)),
    0 10px 24px rgba(58, 24, 16, 0.28),
    0 2px 4px rgba(58, 24, 16, 0.18);
}
#game-stack.board-pvp.board-combo {
  box-shadow:
    0 0 0 calc((var(--combo-tier, 3) - 2) * 1px + 1px) rgba(255, 200, 100, 0.55),
    0 0 calc(var(--combo-tier, 3) * 4px + 8px) calc(var(--combo-tier, 3) * 2px) rgba(255, 178, 69, calc(var(--combo-tier, 3) * 0.06 + 0.22)),
    0 12px 28px rgba(232, 74, 95, 0.18),
    0 2px 4px rgba(58, 24, 16, 0.18);
}

/* Milestone (warm) — brief warm cream flash + thin gold ring on
   watermelonBurst. JS adds the class then removes it after 600ms;
   the animation matches. After the class is removed, ::after
   returns to whatever ambient state class is still active. */
#game-stack.board-milestone::after {
  opacity: 1;
  background: radial-gradient(ellipse at 50% 32%, rgba(255, 235, 200, 0.55) 0%, rgba(255, 235, 200, 0) 60%);
  box-shadow: inset 0 0 0 3px rgba(255, 220, 140, 0.70);
  animation: boardMilestoneFlash 600ms ease-out;
}
@keyframes boardMilestoneFlash {
  0%   { opacity: 0; transform: scale(0.985); }
  25%  { opacity: 1; transform: scale(1.008); }
  100% { opacity: 0; transform: scale(1); }
}

/* Milestone (strong) — bigger / longer flash + thicker ring on
   cosmicExploded. Layered on top of .board-milestone via additional
   class, so the warm base is replaced with a more saturated rainbow-
   tinged glow. 900ms lifetime to match cosmic's spectacle. */
#game-stack.board-milestone-strong::after {
  background: radial-gradient(ellipse at 50% 36%, rgba(255, 220, 200, 0.70) 0%, rgba(255, 200, 220, 0) 65%);
  box-shadow:
    inset 0 0 0 4px rgba(255, 220, 140, 0.85),
    inset 0 0 28px 8px rgba(255, 180, 220, 0.30);
  animation: boardMilestoneFlashStrong 900ms ease-out;
}
@keyframes boardMilestoneFlashStrong {
  0%   { opacity: 0; transform: scale(0.97); }
  18%  { opacity: 1; transform: scale(1.018); }
  60%  { opacity: 0.85; transform: scale(1.004); }
  100% { opacity: 0; transform: scale(1); }
}

/* Save moment — brief green/gold ring pulse when the player escapes
   overflow. Distinct color from milestone so the player reads it as
   "you made it out" not "you scored big". 700ms lifetime — slightly
   longer than warm milestone so it doesn't feel rushed. */
#game-stack.board-save::after {
  opacity: 1;
  background: radial-gradient(ellipse at 50% 22%, rgba(134, 208, 80, 0.40) 0%, rgba(134, 208, 80, 0) 60%);
  box-shadow:
    inset 0 0 0 3px rgba(134, 208, 80, 0.75),
    inset 0 0 18px 6px rgba(255, 220, 140, 0.25);
  animation: boardSaveFlash 700ms ease-out;
}
@keyframes boardSaveFlash {
  0%   { opacity: 0; transform: scale(0.985); }
  20%  { opacity: 1; transform: scale(1.012); }
  100% { opacity: 0; transform: scale(1); }
}

/* Reduced motion: kill the keyframe pulses on milestone / strong-
   milestone / save (they all rely on a transform scale pulse).
   Static states (danger/cascade/pvp/combo) use only opacity
   transitions which are mild enough to keep. The ::after's opacity
   transition still applies on state changes — prefers-reduced-motion
   concerns motion, not all transitions. */
@media (prefers-reduced-motion: reduce) {
  #game-stack.board-milestone::after,
  #game-stack.board-milestone-strong::after,
  #game-stack.board-save::after {
    animation: none;
  }
}

/* Mobile perf profile — skip the brief celebration animations on
   the mobile profile. The static ambient states (danger / cascade /
   pvp / combo) still apply since they're just box-shadow + a single
   opacity transition. */
.perf-mobile #game-stack.board-milestone::after,
.perf-mobile #game-stack.board-milestone-strong::after,
.perf-mobile #game-stack.board-save::after {
  animation: none;
}

#game-stack.shake {
  animation: gameShake 0.5s ease-in-out;
}

#game-stack.big-shake {
  animation: gameBigShake 0.85s ease-in-out;
}

@keyframes gameShake {
  0%, 100% { transform: translate(0,0); }
  20% { transform: translate(-4px, 2px); }
  40% { transform: translate(3px, -3px); }
  60% { transform: translate(-2px, 4px); }
  80% { transform: translate(4px, 2px); }
}

@keyframes gameBigShake {
  0%, 100% { transform: translate(0, 0) rotate(0); }
  10% { transform: translate(-9px,  5px) rotate(-0.6deg); }
  20% { transform: translate( 8px, -7px) rotate( 0.7deg); }
  30% { transform: translate(-7px,  9px) rotate(-0.5deg); }
  40% { transform: translate( 9px,  4px) rotate( 0.5deg); }
  50% { transform: translate(-8px, -6px) rotate(-0.4deg); }
  60% { transform: translate( 7px,  8px) rotate( 0.3deg); }
  70% { transform: translate(-6px, -5px) rotate(-0.3deg); }
  80% { transform: translate( 5px,  6px) rotate( 0.2deg); }
  90% { transform: translate(-3px, -3px) rotate( 0); }
}

/* Danger flash — inset red ring on the canvas + warm red halo on the
   wrapper. Without the dark base border, the red ring is the only frame
   visible during the flash, so it reads as alarm-only without competing
   chrome. */
#game.danger-flash {
  box-shadow:
    inset 0 0 0 4px var(--danger),
    inset 0 0 18px 4px rgba(232, 74, 95, 0.55);
}
#game-stack:has(#game.danger-flash) {
  box-shadow: 0 6px 24px rgba(232, 74, 95, 0.5);
}

/* HUD compact stats block — three stacked rows: 🍉, ↗ outgoing, ↙ incoming */
.hud-block-stats {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  min-width: 38px;
  padding: 2px 4px;
}

.hud-stat {
  display: flex;
  align-items: center;
  gap: 3px;
  font-weight: 900;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.hud-stat.hidden { display: none; }

.hud-stat .hud-stat-icon {
  display: inline-block;
  font-weight: 900;
  width: 16px;
  height: 16px;
  text-align: center;
  line-height: 16px;
}
.hud-stat canvas.hud-stat-icon {
  width: 18px;
  height: 18px;
}

.hud-stat .hud-stat-num {
  min-width: 14px;
  text-align: left;
}

.hud-stat-wms { color: #1d5028; }
.hud-stat-cosmic { color: #6e3aa8; }
.hud-stat-out { color: #1d6f50; }
.hud-stat-in  { color: #b8252b; }

.hud-stat-out .out-arrow { color: #2ec27e; }
.hud-stat-in  .in-arrow  { color: #e84a5f; }

.hud-stat.bump {
  animation: hudStatBump 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes hudStatBump {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.45); }
  100% { transform: scale(1); }
}

/* NEXT-block reroll pulse — fired when REROLL_NEXT swaps the queued tier.
   Squash + stretch + spin so the player can't miss it: the block flattens
   horizontally, then snaps tall with a 360° spin while the green halo
   pulses, settling back to neutral. Reads as "slot machine reveal". */
.hud-block.next-rerolled {
  animation: nextRerollBump 0.65s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes nextRerollBump {
  0%   { transform: scale(1)         rotate(0deg);   filter: brightness(1) drop-shadow(0 0 0 rgba(134, 208, 80, 0)); }
  20%  { transform: scale(1.30, 0.7) rotate(0deg);   filter: brightness(1.15) drop-shadow(0 0 6px rgba(134, 208, 80, 0.6)); }
  55%  { transform: scale(0.85, 1.35) rotate(180deg); filter: brightness(1.40) drop-shadow(0 0 12px rgba(134, 208, 80, 0.95)); }
  100% { transform: scale(1)         rotate(360deg); filter: brightness(1) drop-shadow(0 0 0 rgba(134, 208, 80, 0)); }
}

@media (max-width: 380px) {
  .hud-block-stats { min-width: 32px; padding: 2px 2px; }
  .hud-stat { font-size: 11px; gap: 2px; }
  .hud-stat .hud-stat-icon { width: 13px; height: 13px; line-height: 13px; }
  .hud-stat canvas.hud-stat-icon { width: 15px; height: 15px; }
}

/* fx layer for attack trails, popups - fullscreen so trails can fly across the page */
.fx-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 40;
}

.fx-trail {
  position: absolute;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  box-shadow: 0 0 12px rgba(0,0,0,0.3);
  pointer-events: none;
  z-index: 8;
  will-change: transform;
}

.fx-popup {
  position: absolute;
  font-weight: 900;
  font-size: 16px;
  color: #fff;
  text-shadow: 0 2px 4px rgba(0,0,0,0.5);
  background: rgba(0,0,0,0.55);
  padding: 6px 14px;
  border-radius: 999px;
  pointer-events: none;
  z-index: 8;
  animation: popupFloat 1.4s ease-out forwards;
  white-space: nowrap;
}

.fx-popup.send  { background: linear-gradient(135deg, var(--accent-2), var(--accent-3)); }
.fx-popup.hit   { background: var(--danger); }
.fx-popup.combo { background: linear-gradient(135deg, #6e51ff, #ff7e3a); }
.fx-popup.melon { background: linear-gradient(135deg, #2ea84a, #6fcf3a); }
.fx-popup.brace { background: linear-gradient(135deg, #2a73ff, #4ec3ff); }

@keyframes popupFloat {
  0%   { opacity: 0; transform: translate(-50%, 0)   scale(0.6); }
  15%  { opacity: 1; transform: translate(-50%, -10px) scale(1.15); }
  30%  { transform: translate(-50%, -12px) scale(1); }
  80%  { opacity: 1; transform: translate(-50%, -32px) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -48px) scale(1); }
}

/* ------------ Attack stacks (manual fire model) ------------
   Outgoing slots are static buttons under the manual-fire model. The only
   ambient motion is the brightness ramp on a stale slot (rare). Incoming
   slots are short telegraphs (~500ms) with the existing shake. */
.attack-stack {
  position: fixed;
  top: 38%;
  display: flex;
  flex-direction: column;
  gap: 6px;
  z-index: 9;
  pointer-events: none;
  max-height: 60vh;
}

.attack-stack.outgoing {
  right: 6px;
  align-items: flex-end;
}

.attack-stack.incoming {
  left: 6px;
  align-items: flex-start;
}

/* Single-shot shake when an attack tries to enqueue at cap. Removing+re-adding
   .shake-once with a reflow re-triggers it. Static red border state stays via
   .cap-full. */
.attack-stack.outgoing.shake-once {
  animation: stackShakeOnce 280ms ease-in-out;
}
@keyframes stackShakeOnce {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-7px); }
  40% { transform: translateX(7px); }
  60% { transform: translateX(-4px); }
  80% { transform: translateX(4px); }
}

/* ------------ PvP round-start countdown overlay ------------ */
/* 5..1 → GO! before each round. Doesn't pause the game internally —
   PvPMatch.tick() freezes physics + AI separately via roundEnded. The
   overlay just blocks input + draws focus. The big number is what the
   eye lands on; "Round N" sits as a small label above it. */
.round-countdown {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  /* Warm sepia overlay using the app's --ink-deep palette instead of
     pure black. Keeps the "draws focus" intent of a darkened backdrop
     while reading as part of MelonDrop's brown-and-cream world rather
     than a generic modal scrim. The radial vignette pulls the eye to
     the centered words. */
  background: radial-gradient(ellipse 80% 60% at center,
    rgba(58, 24, 16, 0.45) 0%,
    rgba(58, 24, 16, 0.85) 100%);
  z-index: 200;                /* above queue stacks (60), below game-over */
  pointer-events: auto;        /* blocks taps on the playfield */
  -webkit-tap-highlight-color: transparent;
  touch-action: none;
}
.round-countdown.hidden { display: none; }

/* "Round N" label — small sticker pill above the big word. Same visual
   language as the menu's chunky-bordered surfaces (cream gradient,
   dark border, subtle drop shadow). The slight rotation matches the
   menu title's playful tilt. */
.round-countdown-label {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 2.5px;
  text-transform: uppercase;
  color: var(--ink-deep);
  background: linear-gradient(180deg, #fff7e6 0%, #ffe0a8 100%);
  border: 2.5px solid var(--ink-deep);
  border-radius: 999px;
  padding: 5px 16px 4px;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 3px 0 var(--ink-deep),
    0 5px 12px rgba(58, 24, 16, 0.45);
  transform: rotate(-3deg);
  animation: countdownLabelIn 360ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes countdownLabelIn {
  0%   { transform: rotate(-3deg) translateY(-12px) scale(0.8); opacity: 0; }
  100% { transform: rotate(-3deg) translateY(0)     scale(1);   opacity: 1; }
}

/* Big word — borrows the menu title's chunky sticker treatment: thick
   webkit-text-stroke for the dark outline, paint-order so the stroke
   sits behind the fill, layered text-shadow for the deep-ink "lift"
   under the letters. Per-step color fills (READY=amber, SET=coral,
   GO=watermelon green) are applied via .ready / .set / .go below. */
.round-countdown-num {
  position: relative;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 88px;
  line-height: 1;
  letter-spacing: 2px;
  -webkit-text-stroke: 5px var(--ink-deep);
  paint-order: stroke fill;
  text-shadow:
    0 5px 0 var(--ink-deep),
    0 10px 22px rgba(58, 24, 16, 0.55);
  transform: rotate(-2deg);
  /* Sized so it never wraps even on narrow phones. The slight rotate
     gives the same hand-placed-sticker feel as the menu title. */
}

/* Anticipation — warm amber, the "wait for it" color. */
.round-countdown-num.ready { color: #ffb245; }
/* Tension — coral red, ratcheting toward the start. */
.round-countdown-num.set   { color: #ff6a4a; }
/* Release — watermelon green with a celebratory size bump and a
   slightly wider letter-spacing so the word feels louder. */
.round-countdown-num.go {
  color: #6ed46a;
  font-size: 102px;
  letter-spacing: 6px;
}

/* Pop-in animation re-applied each tick by removing+re-adding .pop.
   The keyframe overshoots scale 1.0 then settles, with a tiny extra
   wobble in rotation so it reads as a "thump" instead of a flat zoom. */
.round-countdown-num.pop {
  animation: countdownPop 540ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes countdownPop {
  0%   { transform: rotate(-2deg) scale(0.55); opacity: 0; }
  35%  { transform: rotate(-3.5deg) scale(1.20); opacity: 1; }
  60%  { transform: rotate(-2deg) scale(1.00); opacity: 1; }
  100% { transform: rotate(-2deg) scale(1.00); opacity: 1; }
}

/* GO! gets a celebratory burst — three concentric ring pseudo-elements
   that expand outward when the .go class lands. Pure transform +
   opacity animation so they composite cheaply. ::before is the inner
   bright ring, ::after is the outer wider ring, and an additional
   inset glow on the text itself sells the "fanfare" beat. */
.round-countdown-num.go::before,
.round-countdown-num.go::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 60px;
  height: 60px;
  margin: -30px 0 0 -30px;
  border-radius: 50%;
  border: 4px solid #6ed46a;
  pointer-events: none;
  transform: scale(0.4);
  opacity: 0;
  z-index: -1;
  animation: countdownGoBurst 600ms ease-out forwards;
}
.round-countdown-num.go::after {
  border-color: #ffb245;
  animation-delay: 80ms;
  animation-duration: 720ms;
}
@keyframes countdownGoBurst {
  0%   { transform: scale(0.3); opacity: 0;    border-width: 6px; }
  20%  { opacity: 1; }
  100% { transform: scale(7);   opacity: 0;    border-width: 1px; }
}

/* Compact tweak for very narrow phones — keep the sticker hierarchy
   readable without overflowing the viewport on a 320-360px screen. */
@media (max-width: 380px) {
  .round-countdown-num    { font-size: 72px; -webkit-text-stroke-width: 4px; }
  .round-countdown-num.go { font-size: 84px; letter-spacing: 4px; }
  .round-countdown-label  { font-size: 13px; padding: 4px 12px 3px; }
}

/* Reduced-motion: drop the rotate wobble and burst rings; keep the
   label/number visible with a flat fade-in so the countdown still
   communicates without spinning. */
@media (prefers-reduced-motion: reduce) {
  .round-countdown-label  { animation: none; transform: none; }
  .round-countdown-num    { transform: none; }
  .round-countdown-num.pop { animation: countdownPopReduced 220ms ease-out; }
  .round-countdown-num.go::before,
  .round-countdown-num.go::after { display: none; }
  @keyframes countdownPopReduced {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

/* ------------ PvP round timer pill ------------
   Sticker pill with the same border + bottom-shadow language as the HUD
   and buttons. Dark fill (deliberate contrast against the cream surfaces)
   so it reads as a "scoreboard clock" rather than another cream pill. */
.round-timer {
  position: absolute;
  top: 8px;
  left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-display);
  background: linear-gradient(180deg, #2a1a10 0%, #1a0d06 100%);
  color: #ffe6b3;
  font-size: 14px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.5px;
  padding: 4px 14px;
  border: 2px solid #000;
  border-radius: 999px;
  pointer-events: none;
  z-index: 50;                    /* above body sprites, below overlays */
  box-shadow:
    inset 0 1px 0 rgba(255, 230, 179, 0.18),
    0 2px 0 #000,
    0 4px 8px rgba(0, 0, 0, 0.4);
  transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
}
.round-timer.hidden { display: none; }
.round-timer.urgent {
  background: linear-gradient(180deg, #c83030 0%, #8a1a1a 100%);
  color: #ffd866;
  border-color: #5a0d0d;
  box-shadow:
    inset 0 1px 0 rgba(255, 216, 102, 0.25),
    0 2px 0 #5a0d0d,
    0 4px 14px rgba(255, 80, 80, 0.6);
  animation: roundTimerPulse 480ms ease-in-out infinite alternate;
}
@keyframes roundTimerPulse {
  from { transform: translateX(-50%) scale(1); }
  to   { transform: translateX(-50%) scale(1.10); }
}

/* Trading-card layout. The card is a positioning context for absolutely
   placed badges (top-left arrow, top-right count) and a bottom ribbon
   banner. The icon canvas sits in the middle of the card via flex
   centering, with margins that leave room for the badges above and the
   ribbon below. Bumped to 88×112 to make room for the bigger 40px icon
   and the bottom ribbon that extends slightly past the card edge. */
.queue-slot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 22px 6px 18px;     /* top: clear badges, bottom: clear ribbon */
  border: 2.5px solid var(--ink-deep);
  border-radius: 12px;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1.5px 0 rgba(0, 0, 0, 0.18),
    0 3px 0 var(--ink-deep),
    0 5px 12px rgba(0, 0, 0, 0.32);
  color: #fff;
  font-family: var(--font-display);
  width: 88px;
  height: 112px;
  box-sizing: border-box;
  overflow: visible;
  transform-origin: center;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}

/* Outgoing = tappable button. Pointer-events ON so taps register on the slot;
   the parent .attack-stack stays pointer-events:none so dead space doesn't
   accidentally swallow drags from the launcher area. Background gradient
   comes from `.rarity-*` classes, not from .outgoing — rarity replaces the
   old generic green/blue treatment. */
.queue-slot.outgoing {
  pointer-events: auto;
  cursor: pointer;
  transition: transform 80ms ease, filter 80ms ease, opacity 200ms ease;
}

/* Visual hierarchy: bottom (oldest) slot at full opacity is the primary
   action target. Slots queued after it sit at 0.85 opacity. */
.queue-slot.outgoing.secondary { opacity: 0.85; }
.queue-slot.outgoing.primary { opacity: 1; }

/* Press feedback: shadow-compress + brightness lift. Matches the press
   language on the menu/HUD buttons — the slot physically depresses. */
.queue-slot.outgoing.pressing {
  transform: translateY(2px);
  filter: brightness(1.15);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1px 0 rgba(0, 0, 0, 0.18),
    0 1px 0 var(--ink-deep),
    0 2px 6px rgba(0, 0, 0, 0.32);
}

/* Entrance: slide up + fade in, 180ms, no bounce. */
.queue-slot.entering {
  animation: slotEnter 180ms ease-out;
}
@keyframes slotEnter {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Cap-full state — every outgoing slot in the stack gets a red border to
   signal "queue is full, taps will be dropped". The chunky inner highlight
   + bottom shadow stay so the slots still look like stickers. */
.attack-stack.outgoing.cap-full .queue-slot.outgoing {
  border-color: #c83040;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 200, 200, 0.55),
    inset 0 -1.5px 0 rgba(0, 0, 0, 0.18),
    0 3px 0 #6a0d12,
    0 5px 14px rgba(232, 74, 95, 0.55);
}

.queue-slot.outgoing.cap-full-badge {
  position: relative;
}
.queue-slot.outgoing.cap-full-badge::after {
  content: 'FULL — TAP';
  position: absolute;
  top: -8px;
  right: -4px;
  font-size: 9px;
  font-weight: 900;
  letter-spacing: 1.2px;
  background: #e84a5f;
  color: #fff;
  padding: 2px 6px;
  border-radius: 999px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.35);
  white-space: nowrap;
}

/* Tutorial: one-shot pulse on the slot the player should tap. The pulse
   is on filter (drop-shadow) + brightness now instead of box-shadow, so
   the chunky base shadow stays intact under it. */
.queue-slot.outgoing.tutorial-pulse {
  animation: tutorialPulse 700ms ease-in-out 2;
}
@keyframes tutorialPulse {
  0%, 100% { filter: brightness(1) drop-shadow(0 0 0 rgba(255, 230, 100, 0)); }
  50%      { filter: brightness(1.18) drop-shadow(0 0 14px rgba(255, 230, 100, 0.95)); }
}

/* Card-fly: 5-phase "I just played this card" sequence. The slot is moved
   onto #fx-layer with position:fixed; the transform transitions are driven
   inline from JS so we can reuse the same element across phases. CSS only
   handles the filter/glow effects that don't conflict with the transform. */

/* Phase 1 — pluck. Brief green confirmation glow as the player taps. */
.queue-slot.fire-flash {
  filter: brightness(1.4) drop-shadow(0 0 12px rgba(110, 240, 130, 0.95));
}

/* Phase 2 — travel. Carries a soft trailing glow so the eye tracks it. */
.queue-slot.fire-flying {
  filter: brightness(1.18) drop-shadow(0 4px 14px rgba(255, 230, 140, 0.6));
}

/* Phase 3 — showcase hold. Gentle pulse on filter only (no transform — JS
   has the slot pinned at the showcase position via translate). Reads as
   "ready to throw — see what's coming, opponent". */
.queue-slot.fire-showcase {
  animation: cardShowcaseHold 700ms ease-in-out;
}
@keyframes cardShowcaseHold {
  0%, 100% { filter: brightness(1.15) drop-shadow(0 6px 16px rgba(255, 230, 140, 0.5)); }
  50%      { filter: brightness(1.35) drop-shadow(0 8px 22px rgba(110, 240, 130, 0.8)); }
}

/* Phase 4 — throw. Slight motion blur via filter for the launch beat. */
.queue-slot.fire-throwing {
  filter: brightness(1.25) drop-shadow(0 4px 18px rgba(232, 74, 95, 0.55));
}

/* Incoming = receive-side telegraph. Stays red regardless of attack rarity —
   incoming color signals "danger to me", not the attack's identity. The icon
   + label inside the slot already convey what kind of attack it is. Chunky
   sticker treatment with darker red border + matching solid bottom edge. */
.queue-slot.incoming {
  background: linear-gradient(180deg, #ff5e72 0%, #b8252b 100%);
  border-color: #5a0d0d;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 200, 200, 0.4),
    inset 0 -1.5px 0 rgba(80, 0, 10, 0.4),
    0 3px 0 #5a0d0d,
    0 5px 14px rgba(232, 74, 95, 0.5);
  animation: incomingShake 0.32s ease-in-out infinite alternate;
}

/* ---- Rarity treatment (outgoing cards) -------------------------------
   Each rarity gets:
     - top-down gradient (lighter top, darker bottom — sticker bevel)
     - inner highlight + inner-bottom shadow for depth
     - 3px solid bottom-edge shadow in a darker rarity-tinted color
     - soft outer drop-shadow halo in the rarity color (the "glow")
   The base .queue-slot border + bevel still apply; rarity overrides only
   what differs (background + bottom-edge color + halo color). */
.queue-slot.outgoing.rarity-common {
  background: linear-gradient(180deg, #4ad860 0%, #1a7a2a 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1.5px 0 rgba(10, 50, 20, 0.4),
    0 3px 0 #0e4a18,
    0 5px 16px rgba(46, 200, 80, 0.45);
}
.queue-slot.outgoing.rarity-uncommon {
  background: linear-gradient(180deg, #4ea8ff 0%, #1c4a8a 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1.5px 0 rgba(8, 24, 60, 0.4),
    0 3px 0 #0e2a5a,
    0 5px 16px rgba(60, 140, 240, 0.5);
}
.queue-slot.outgoing.rarity-rare {
  background: linear-gradient(180deg, #c97aff 0%, #5a1e8a 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1.5px 0 rgba(40, 8, 60, 0.4),
    0 3px 0 #2e0a4a,
    0 5px 16px rgba(180, 90, 255, 0.55);
}
.queue-slot.outgoing.rarity-epic {
  background: linear-gradient(180deg, #ffb04a 0%, #a04500 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1.5px 0 rgba(60, 20, 0, 0.4),
    0 3px 0 #5a2400,
    0 5px 16px rgba(255, 140, 30, 0.6);
}
.queue-slot.outgoing.rarity-legendary {
  background: linear-gradient(180deg, #ff5050 0%, #8b0000 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 200, 200, 0.5),
    inset 0 -1.5px 0 rgba(60, 0, 0, 0.45),
    0 3px 0 #4a0000,
    0 5px 18px rgba(255, 60, 60, 0.65);
}

/* Pure transform-based shake so the chunky sticker shadow stays intact
   underneath. Animating box-shadow here would override the base styles. */
@keyframes incomingShake {
  from { transform: translateX(0)   scale(1.02); }
  to   { transform: translateX(2px) scale(1.08); }
}

/* Top-left: arrow badge. Small white circle with the up/down arrow inside.
   Reads as a "card direction" indicator (↗ outgoing / ↙ incoming) without
   the arrow floating bare on the colored background. */
.queue-slot .slot-arrow-badge {
  position: absolute;
  top: 5px;
  left: 5px;
  width: 20px;
  height: 20px;
  background: linear-gradient(180deg, #ffffff 0%, #f4ead8 100%);
  border: 1.5px solid var(--ink-deep);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
  color: var(--ink-deep);
  line-height: 1;
  text-shadow: none;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
  pointer-events: none;
}

/* Top-right: count badge. Yellow circle with "+N" inside. Hidden when
   the attack is single-application (count=1) — see _updateSlotContent. */
.queue-slot .slot-count-badge {
  position: absolute;
  top: 4px;
  right: 4px;
  min-width: 22px;
  height: 22px;
  padding: 0 4px;
  background: linear-gradient(180deg, #ffe066 0%, #ffb83a 100%);
  border: 1.5px solid var(--ink-deep);
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--ink-deep);
  line-height: 1;
  text-shadow: none;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
  pointer-events: none;
}
.queue-slot .slot-count-badge.hidden { display: none; }

/* Center: big icon canvas — the visual centerpiece of the card. The
   drop-shadow filter keeps the glyph legible on any rarity gradient. */
.queue-slot canvas.slot-icon {
  width: 40px;
  height: 40px;
  display: block;
  pointer-events: none;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5));
}

/* Bottom nameplate — flush within the card border, NOT a popout. Earlier
   pass had a white ribbon extending 3px past the card edges with its own
   dark border + 2px bottom shadow; the doubled-border + popout edge
   doubled up against the card's own bottom shadow and read as "label
   glued onto the card" rather than part of the card.
   This version is a single dark translucent rounded rect inset 5px from
   each card edge — reads as inscribed/stamped onto the card surface,
   works cleanly on any rarity color underneath, and never collides with
   the card's outer chrome. */
.queue-slot .slot-banner {
  position: absolute;
  bottom: 5px;
  left: 5px;
  right: 5px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  border-radius: 5px;
  padding: 3px 5px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.4px;
  text-align: center;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    inset 0 -1px 0 rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

.queue-slot .slot-banner-label {
  font-weight: 700;
}
.queue-slot .slot-banner-time {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  opacity: 0.85;
}

.queue-slot.fading {
  animation: slotFade 0.35s ease-out forwards;
}

@keyframes slotFade {
  from { opacity: 1; transform: scale(1.05); }
  to   { opacity: 0; transform: scale(0.7); }
}

/* ------------ Ammo indicator (PvP launcher-adjacent dots) ------------
   Fixed position, but the previous 96px top was landing on top of the
   chunky HUD panel — the safe-area inset on iPhones plus the HUD's own
   ~72px height (after panel padding + border) pushed the HUD bottom
   past 96. Bumped to a generous 132 so it clears the HUD on every
   device. The indicator is always visually below the scoreboard now. */
.ammo-indicator {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 132px);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 10px;
  z-index: 8;
  pointer-events: none;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(20, 12, 8, 0.28);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  transition: box-shadow 220ms ease;
}
.ammo-indicator.hidden { display: none; }

.ammo-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.18);
  border: 1.5px solid rgba(255, 255, 255, 0.35);
  transition: background 200ms ease, transform 180ms ease, opacity 200ms ease;
}
.ammo-dot.lit {
  border-color: rgba(255, 255, 255, 0.85);
  box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
  transform: scale(1.05);
}

/* When at cap, one subtle border pulse so the player notices. */
.ammo-indicator.full {
  animation: ammoFullPulse 600ms ease-out 1;
}
@keyframes ammoFullPulse {
  0%   { box-shadow: 0 0 0 0 rgba(255, 220, 100, 0); }
  60%  { box-shadow: 0 0 0 10px rgba(255, 220, 100, 0.5); }
  100% { box-shadow: 0 0 0 14px rgba(255, 220, 100, 0); }
}

/* ------------ Tutorial label "TAP TO FIRE" ------------ */
.tutorial-label {
  position: fixed;
  z-index: 11;
  background: linear-gradient(135deg, #ffd866, #ff9d3a);
  color: #2a1a05;
  padding: 8px 12px;
  border-radius: 12px;
  font-weight: 900;
  font-size: 12px;
  letter-spacing: 0.6px;
  display: flex;
  align-items: center;
  gap: 6px;
  box-shadow: 0 4px 16px rgba(0,0,0,0.35);
  pointer-events: none;
  animation: tutorialFloat 1.4s ease-in-out infinite alternate;
  white-space: nowrap;
}
.tutorial-label::after {
  content: '';
  position: absolute;
  right: -6px;
  top: 50%;
  margin-top: -6px;
  border-style: solid;
  border-width: 6px 0 6px 8px;
  border-color: transparent transparent transparent #ff9d3a;
}
.tutorial-finger {
  font-size: 16px;
  filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3));
}
@keyframes tutorialFloat {
  from { transform: translateY(0); }
  to   { transform: translateY(-3px); }
}

/* ------------ PiP hit flash (border colored to attack on land) ------------ */
.pip.hit-flash {
  --hit-flash-color: #ff6b6b;
  animation: pipHitFlash 220ms ease-out 1;
}
@keyframes pipHitFlash {
  0%   { box-shadow: 0 0 0 0 var(--hit-flash-color), 0 0 0 0 var(--hit-flash-color) inset; }
  40%  { box-shadow: 0 0 24px 6px var(--hit-flash-color), 0 0 0 4px var(--hit-flash-color) inset; }
  100% { box-shadow: 0 0 0 0 var(--hit-flash-color), 0 0 0 0 var(--hit-flash-color) inset; }
}

/* Incoming-warn: orange ring pulse around the PiP during the card-fly
   showcase hold. Tells the opponent "an attack is in flight, brace" so the
   landing isn't a surprise. Duration matches the showcase hold (700ms). */
.pip.incoming-warn {
  animation: pipIncomingWarn 700ms ease-in-out 1;
}
@keyframes pipIncomingWarn {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 170, 60, 0); }
  35%      { box-shadow: 0 0 0 7px rgba(255, 170, 60, 0.7), 0 0 26px 6px rgba(255, 170, 60, 0.45); }
  65%      { box-shadow: 0 0 0 5px rgba(255, 170, 60, 0.5), 0 0 22px 4px rgba(255, 170, 60, 0.30); }
}

.fx-popup.pip-hit-popup {
  font-size: 22px;
  background: linear-gradient(135deg, #ffe066, #ff7e3a);
  color: #2a1a05;
  text-shadow: 0 1px 2px rgba(0,0,0,0.25);
}

/* ------------ Screen-edge red pulse for incoming attacks ------------ */
.incoming-edge-pulse {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 38;
  box-shadow: inset 0 0 60px 18px rgba(232, 74, 95, 0.5);
  animation: edgePulse 0.5s ease-in-out infinite alternate;
}

.incoming-edge-pulse.hidden { display: none; }

.incoming-edge-pulse.crush {
  box-shadow: inset 0 0 100px 30px rgba(20, 0, 0, 0.7);
  animation: edgePulseCrush 0.32s ease-in-out infinite alternate;
}

@keyframes edgePulse {
  from { box-shadow: inset 0 0 50px 12px rgba(232, 74, 95, 0.35); }
  to   { box-shadow: inset 0 0 80px 24px rgba(232, 74, 95, 0.7); }
}

@keyframes edgePulseCrush {
  from { box-shadow: inset 0 0 80px 20px rgba(40, 0, 0, 0.55); }
  to   { box-shadow: inset 0 0 120px 36px rgba(80, 0, 0, 0.85); }
}

/* ------------ Overlays ------------ */
.overlay {
  position: fixed;
  inset: 0;
  /* Scrim opacity bumped from 0.55 → 0.72 so the busy gameplay
     underneath (board, HUD, particles) doesn't compete with the
     menu / overlay content. The .perf-mobile profile strips the
     blur entirely; desktop keeps it as 3px for an extra polish
     beat without crossing into the heavy-blur territory. */
  background: rgba(20, 12, 8, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 50;
  padding: 16px;
  transition: opacity 0.25s ease;
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

.overlay.hidden {
  opacity: 0;
  pointer-events: none;
}

.panel {
  /* Layered surface: a 2-stop cream gradient gives depth, the dark border
     plus inner highlight makes the panel read as an embossed card instead
     of a flat overlay. The drop shadow is deeper than before so the panel
     "lifts off" the dimmed backdrop. */
  position: relative;
  background: linear-gradient(180deg, #fff7e6 0%, #ffe8c4 100%);
  padding: 24px 22px;
  border: 3px solid var(--ink-deep);
  border-radius: 22px;
  text-align: center;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    inset 0 -2px 0 rgba(120, 70, 30, 0.08),
    0 6px 0 var(--ink-deep),
    0 18px 40px rgba(58, 24, 16, 0.45);
  width: 100%;
  max-width: 360px;
  max-height: calc(100vh - 32px);
  overflow-y: auto;
  /* Spring-out entrance triggered by toggling the overlay's `.hidden` class.
     The transform is driven by the parent .overlay state below so the
     entrance plays every show, not just on initial DOM insertion. */
  transform: scale(1) translateY(0);
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Paper grain — inline SVG fractal noise tiled across the panel at low
   opacity. Breaks up the flat cream gradient with subtle texture so the
   surface reads as a printed sticker instead of a glossy CSS fill. The
   noise is multiply-blended so it darkens fibers; light highlights stay
   intact. Using ::before keeps the noise behind content. */
.panel::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  mix-blend-mode: multiply;
  opacity: 0.07;
  z-index: 0;
}

/* All direct children sit above the noise overlay. */
.panel > * {
  position: relative;
  z-index: 1;
}

.overlay.hidden .panel {
  transform: scale(0.92) translateY(8px);
}

/* ------------ Settings / pause button + menu ------------ */

/* Floating gear icon. Always pinned to the top-right of the viewport (above
   the safe-area inset on notched phones). Hidden on the main menu via the
   `hidden` class — main.js shows/hides based on currentMode. */
/* Chunky sticker treatment for the settings gear. Same depth language as
   the menu buttons + HUD — dark border, inner highlight, solid bottom
   shadow. Press collapses the bottom shadow rather than just scaling, so
   the gear reads as a real button instead of a faded floating glyph. */
.settings-btn {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 8px);
  right: calc(env(safe-area-inset-right, 0px) + 8px);
  width: 42px;
  height: 42px;
  border-radius: 50%;
  border: 2.5px solid var(--ink-deep);
  background: linear-gradient(180deg, #fff7e6 0%, #ffe0a8 100%);
  color: var(--ink-deep);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 3px 0 var(--ink-deep),
    0 5px 10px rgba(58, 24, 16, 0.28);
  z-index: 60;     /* above HUD (z=10) AND overlays (z=50). Originally 35
                      so the gear sat below the menu/game-over/etc. backdrops,
                      but the gear is now usable on the main menu (to set
                      name + audio before starting a game) so it has to
                      live above the overlay layer. Settings overlay itself
                      always opens above this anyway because clicking the
                      gear runs openSettings → removes .hidden. */
  transition: transform 100ms ease, box-shadow 100ms ease, filter 160ms ease;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
.settings-btn:hover { filter: brightness(1.04); }
.settings-btn:active {
  transform: translateY(2px);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 1px 0 var(--ink-deep),
    0 2px 5px rgba(58, 24, 16, 0.28);
}
.settings-btn.hidden { display: none; }
.settings-btn svg { display: block; }

.settings-panel {
  text-align: left;
  max-width: 340px;
}
.settings-panel h2 {
  text-align: center;
  margin: 0 0 14px 0;
  font-size: 20px;
}
.settings-list {
  list-style: none;
  padding: 0;
  margin: 0 0 18px 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.settings-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 4px;
  border-bottom: 1px dashed rgba(120, 70, 30, 0.18);
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
  font-weight: 700;
  font-size: 14px;
  color: var(--text);
}
.settings-value {
  font-weight: 900;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
  color: var(--accent-2);
}

/* Toggle switch — placeholder UI; data-on attribute reflects state.
   JS reads/writes data-on and updates aria-pressed + the .toggle-state text. */
.settings-toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 6px;
  border-radius: 8px;
  font-family: inherit;
  touch-action: manipulation;
}
.settings-toggle .toggle-track {
  width: 36px;
  height: 20px;
  border-radius: 999px;
  background: rgba(120, 70, 30, 0.25);
  position: relative;
  transition: background 180ms ease;
}
.settings-toggle .toggle-thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 3px rgba(0,0,0,0.25);
  transition: transform 180ms cubic-bezier(0.34, 1.5, 0.64, 1);
}
.settings-toggle[data-on="1"] .toggle-track { background: var(--accent-2); }
.settings-toggle[data-on="1"] .toggle-thumb { transform: translateX(16px); }
.settings-toggle .toggle-state {
  font-weight: 800;
  font-size: 12px;
  letter-spacing: 0.4px;
  color: var(--muted);
  min-width: 24px;
  text-align: left;
}
.settings-toggle[data-on="1"] .toggle-state { color: var(--accent-2); }

/* Display-name input lives inside a settings-row; sized to coexist with the
   row label rather than dominate it. Matches the .join-row input visual
   register (translucent white field, accent-2 focus) but compact. */
.settings-name-input {
  flex: 0 1 160px;
  min-width: 0;
  padding: 8px 10px;
  font-size: 14px;
  font-weight: 700;
  text-align: right;
  border: 2px solid rgba(120, 70, 30, 0.2);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.85);
  color: var(--ink);
  font-family: inherit;
}
.settings-name-input:focus {
  outline: none;
  border-color: var(--accent-2);
}

.settings-actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* First-time display-name prompt — shown before Random Match / Join Room if
   the player hasn't picked a name yet. Mirrors the panel aesthetic but with
   a single big input + Continue button. */
.name-prompt-panel { text-align: center; }
.name-prompt-panel h2 {
  margin: 0 0 8px;
  font-size: 22px;
  color: var(--accent);
}
.name-prompt-panel .muted { margin: 0 0 14px; }
#name-prompt-input {
  width: 100%;
  box-sizing: border-box;
  padding: 14px 16px;
  min-height: 56px;
  font-size: 18px;
  font-weight: 700;
  text-align: center;
  border: 2px solid rgba(120, 70, 30, 0.2);
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.9);
  color: var(--ink);
  font-family: inherit;
  margin-bottom: 14px;
}
#name-prompt-input:focus {
  outline: none;
  border-color: var(--accent-2);
}

/* Panel headings borrow the title treatment in miniature — display font,
   thin dark stroke, light drop shadow. Less pronounced than the menu title
   so they don't compete inside dialogs. */
.panel h1 {
  margin: 0 0 14px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 32px;
  line-height: 1.1;
  color: var(--accent-2);
  -webkit-text-stroke: 2.5px var(--ink-deep);
  paint-order: stroke fill;
  text-shadow: 0 3px 0 var(--ink-deep);
  letter-spacing: 0.3px;
}

.panel h2 {
  margin: 0 0 12px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 22px;
  line-height: 1.1;
  color: var(--accent-2);
  -webkit-text-stroke: 2px var(--ink-deep);
  paint-order: stroke fill;
  text-shadow: 0 2px 0 var(--ink-deep);
  letter-spacing: 0.3px;
}

.panel p {
  margin: 6px 0;
  font-size: 15px;
  font-weight: 500;
}

.panel .muted { color: rgba(90, 58, 26, 0.65); }

/* Menu screen — title is the marquee element of the menu. The chunky-outlined
   stroke + warm drop shadow + slight tilt gives the impression of a designed
   logo rather than a colored <h1> tag. `paint-order: stroke fill` puts the
   stroke BEHIND the fill so the letterform stays at full body weight. */
.title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 52px;
  line-height: 1;
  margin: 6px 0 4px;
  color: #ffb245;
  letter-spacing: 1px;
  -webkit-text-stroke: 4px var(--ink-deep);
  paint-order: stroke fill;
  text-shadow:
    0 4px 0 var(--ink-deep),
    0 8px 18px rgba(58, 24, 16, 0.45);
  transform: rotate(-2.5deg);
  display: inline-block;
  animation: titleEntrance 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.title span {
  color: #fff5d8;
}

@keyframes titleEntrance {
  0%   { transform: rotate(-2.5deg) translateY(-30px) scale(0.7); opacity: 0; }
  60%  { transform: rotate(-2.5deg) translateY(6px)   scale(1.06); opacity: 1; }
  100% { transform: rotate(-2.5deg) translateY(0)     scale(1); }
}

.subtitle {
  margin: 0 0 18px;
  font-size: 13px;
  font-weight: 500;
  opacity: 0.75;
  letter-spacing: 0.3px;
}

.menu-buttons {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 8px;
}

/* "Sticker" buttons. The look comes from layered shadows that read as
   real depth: a 4px solid bottom shadow (the "edge" of the sticker), an
   inner highlight (the bevel), and a soft drop shadow underneath. Pressing
   collapses the bottom shadow to 1px and shifts the button down 3px so the
   button literally compresses into the page — much more tactile than the
   original translate-Y on a flat box. */
.big-btn {
  position: relative;
  /* Grid keeps the icon pinned to column 1 spanning both rows, while the
     label sits on row 1 and the sub-label on row 2 below it. Lets long
     labels like "Random Match" have the full second column width without
     wrapping or competing with the sub-label for horizontal space. */
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-rows: auto auto;
  align-items: center;
  column-gap: 12px;
  padding: 12px 16px;
  min-height: 60px;
  width: 100%;
  background: linear-gradient(180deg, #ffffff 0%, #ffeacc 100%);
  color: var(--ink);
  border: 2.5px solid var(--ink-deep);
  border-radius: 16px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 16px;
  cursor: pointer;
  text-align: left;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.9),
    inset 0 -2px 0 rgba(120, 70, 30, 0.08),
    0 4px 0 var(--ink-deep),
    0 6px 14px rgba(58, 24, 16, 0.25);
  transition:
    transform 90ms ease,
    box-shadow 90ms ease,
    filter 160ms ease;
}

.big-btn .btn-icon { grid-column: 1; grid-row: 1 / 3; }
.big-btn .btn-emoji { grid-column: 1; grid-row: 1 / 3; }
.big-btn .btn-label { grid-column: 2; grid-row: 1; }
.big-btn .btn-sub { grid-column: 2; grid-row: 2; }
/* Buttons with no sub-label (e.g. .join-btn, plain CTAs) — center the
   label vertically by spanning both rows. */
.big-btn:not(:has(.btn-sub)) .btn-label { grid-row: 1 / 3; }

.big-btn:hover {
  filter: brightness(1.04);
}

.big-btn:active {
  transform: translateY(3px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.9),
    inset 0 -1px 0 rgba(120, 70, 30, 0.06),
    0 1px 0 var(--ink-deep),
    0 2px 6px rgba(58, 24, 16, 0.25);
}

/* Primary CTA — the orange sticker. Idle "breathe" pulses the outer halo
   so the eye gets pulled there without the button ever moving. */
.big-btn.primary {
  background: linear-gradient(180deg, #ffc04a 0%, #ff7e3a 55%, #d24a2e 100%);
  color: #fff;
  border-color: var(--accent-deep);
  text-shadow: 0 1.5px 0 rgba(58, 12, 6, 0.35);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -2px 0 rgba(122, 24, 16, 0.25),
    0 4px 0 var(--accent-deep),
    0 6px 14px rgba(255, 126, 58, 0.45),
    0 0 0 0 rgba(255, 178, 69, 0.55);
  animation: btnBreathe 2.6s ease-in-out infinite;
}

.big-btn.primary:active {
  animation: none;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1px 0 rgba(122, 24, 16, 0.2),
    0 1px 0 var(--accent-deep),
    0 2px 6px rgba(255, 126, 58, 0.45);
}

@keyframes btnBreathe {
  0%, 100% {
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.55),
      inset 0 -2px 0 rgba(122, 24, 16, 0.25),
      0 4px 0 var(--accent-deep),
      0 6px 14px rgba(255, 126, 58, 0.45),
      0 0 0 0 rgba(255, 178, 69, 0.5);
  }
  50% {
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.55),
      inset 0 -2px 0 rgba(122, 24, 16, 0.25),
      0 4px 0 var(--accent-deep),
      0 6px 18px rgba(255, 126, 58, 0.6),
      0 0 0 6px rgba(255, 178, 69, 0.18);
  }
}

/* Custom-icon container. Replaces the previous .btn-emoji glyph with a
   colored disc that "holds" the SVG — turns the icon into a design element
   instead of a floating bare graphic. Dark inset gives the sticker a real
   pocket. */
.btn-icon {
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 12px;
  background: rgba(255, 245, 220, 0.85);
  border: 2px solid var(--ink-deep);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.7),
    inset 0 -1.5px 0 rgba(120, 70, 30, 0.18);
}

.big-btn.primary .btn-icon {
  background: rgba(255, 245, 220, 0.95);
  border-color: var(--accent-deep);
}

.btn-icon svg {
  display: block;
}

/* Legacy emoji glyph — used by some older code paths and the slow-mo /
   hazard banners. Keep it sized consistently with the icon disc. */
.btn-emoji {
  font-size: 26px;
  flex-shrink: 0;
}

.btn-label {
  font-weight: 700;
  font-size: 17px;
  letter-spacing: 0.2px;
  line-height: 1.15;
}

.btn-sub {
  display: block;
  font-size: 12px;
  font-weight: 500;
  opacity: 0.72;
  margin-top: 2px;
  letter-spacing: 0.2px;
}

.big-btn.primary .btn-sub {
  opacity: 0.92;
}

.join-row {
  display: flex;
  gap: 6px;
  margin-top: 6px;
  align-items: stretch;
}

.join-row input {
  flex: 1 1 0;
  min-width: 0; /* prevent flex item content from overflowing parent */
  width: 100%;
  padding: 12px 14px;
  min-height: 56px;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 2px;
  text-align: center;
  /* Border weight + color now matches the .big-btn outline so input
     and button read as one paired component. Inset highlight + slight
     shadow give the input the same sticker depth as the button next
     to it instead of looking like a translucent text field. */
  border: 2.5px solid var(--ink-deep);
  border-radius: 14px;
  background: linear-gradient(180deg, #ffffff 0%, #fff5dc 100%);
  color: var(--ink);
  font-family: inherit;
  text-transform: uppercase;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    inset 0 -1px 0 rgba(120, 70, 30, 0.08),
    0 2px 0 var(--ink-deep);
}

.join-row input:focus {
  outline: none;
  border-color: var(--accent-2);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    inset 0 -1px 0 rgba(120, 70, 30, 0.08),
    0 2px 0 var(--ink-deep),
    0 0 0 3px rgba(255, 126, 58, 0.35);
}

.join-btn {
  width: auto;
  min-width: 96px;
  flex: 0 0 auto;
}

.text-btn {
  background: none;
  border: none;
  color: var(--accent);
  font-size: 14px;
  font-weight: 700;
  margin-top: 12px;
  cursor: pointer;
  padding: 8px 16px;
  font-family: inherit;
}

/* Secondary action button — sticker language matching .big-btn but
   compact, neutral palette, and visually subordinate. Used by the
   Solo picker's Back action so it doesn't look like a stranded red
   text link next to the chunky Endless / Cascade buttons. */
.btn-back {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  margin: 8px auto 0;
  padding: 8px 22px;
  min-height: 38px;
  font-family: var(--font-display);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.4px;
  color: var(--ink-deep);
  background: linear-gradient(180deg, #fff7e6 0%, #ffe0a8 100%);
  border: 2.5px solid var(--ink-deep);
  border-radius: 999px;
  cursor: pointer;
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 2px 0 var(--ink-deep),
    0 4px 8px rgba(58, 24, 16, 0.22);
  transition: transform 90ms ease, box-shadow 90ms ease, filter 160ms ease;
  font-family: inherit;
}
.btn-back:hover { filter: brightness(1.04); }
.btn-back:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 1px 0 var(--ink-deep),
    0 2px 4px rgba(58, 24, 16, 0.22);
}

.hint {
  margin: 14px 0 0;
  font-size: 12px;
  opacity: 0.55;
}

/* Spinner */
.spinner {
  width: 56px;
  height: 56px;
  border: 5px solid rgba(120, 70, 30, 0.15);
  border-top-color: var(--accent-2);
  border-radius: 50%;
  margin: 0 auto 16px;
  animation: spin 0.9s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

/* Round/match stats */
.round-stats {
  list-style: none;
  padding: 0;
  margin: 12px 0;
  text-align: left;
}

.round-stats li {
  display: flex;
  justify-content: space-between;
  padding: 6px 4px;
  border-bottom: 1px dashed rgba(120, 70, 30, 0.18);
  font-size: 14px;
}

.round-stats li:last-child {
  border-bottom: none;
}

.round-stats .stat-label {
  font-weight: 600;
  opacity: 0.7;
}

.round-stats .stat-value {
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}

.round-stats .stat-row.player .stat-value { color: #2a73ff; }
.round-stats .stat-row.opponent .stat-value { color: var(--danger); }

/* ----- Main-menu backdrop ----- */
/*
 * Themed surface that replaces the stale gameplay board behind the
 * main menu. Lives at z:1 — above the body's parallax sparkle
 * layers, below the menu overlay (z:50) and the settings gear
 * (z:60). Decorative-only (pointer-events: none).
 *
 * Mobile-perf-safe: pure transform / opacity / static gradients.
 * No filter, no backdrop-filter, no animation that targets paint
 * properties. The 220ms opacity fade on body.is-menu add is the
 * only motion.
 *
 * Visual layers (top → bottom in `background:`):
 *   1-6. Soft fruit-colored "blob" silhouettes via radial-gradient
 *        circles at fixed viewport positions (watermelon green,
 *        strawberry red, cosmic purple, orange, plum, accent).
 *   7.   Warm cream center spotlight (matches the body gradient's
 *        spotlight so the panel sits in the brightest area).
 *   8.   Base linear gradient (cream → peach), same stops as body.
 */
#menu-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  visibility: hidden;
  opacity: 0;
  transition: opacity 220ms ease;
  background:
    radial-gradient(circle 120px at 12% 18%, rgba( 58, 161,  74, 0.13) 0%, transparent 70%),
    radial-gradient(circle 100px at 88% 22%, rgba(232,  74,  95, 0.13) 0%, transparent 70%),
    radial-gradient(circle 140px at 18% 82%, rgba(157, 108, 255, 0.10) 0%, transparent 70%),
    radial-gradient(circle  90px at 82% 78%, rgba(255, 178,  69, 0.13) 0%, transparent 70%),
    radial-gradient(circle  70px at 50%  6%, rgba(122,  58, 138, 0.10) 0%, transparent 70%),
    radial-gradient(circle  60px at  8% 50%, rgba(255, 126,  58, 0.10) 0%, transparent 70%),
    radial-gradient(ellipse 90% 65% at 50% 42%, rgba(255, 240, 210, 0.55) 0%, rgba(255, 240, 210, 0) 60%),
    linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
}
body.is-menu #menu-backdrop {
  visibility: visible;
  opacity: 1;
}

/* Settings gear visibility on blocking overlays. The gear (z:60)
 * normally sits above any modal scrim (z:50), but on these specific
 * overlays the panel already carries its own action buttons so the
 * gear is redundant + visually noisy. Uses :has() so the rule
 * reacts to overlay show/hide without any JS toggle. Main menu
 * (#menu) and settings overlay itself intentionally keep the gear
 * visible. main.js's setSettingsVisible / updateSettingsVisibility-
 * ForUiState helpers mirror this rule for the imperative path
 * (ESC handler, mode transitions). */
body:has(#game-over:not(.hidden)) .settings-btn,
body:has(#match-end:not(.hidden)) .settings-btn,
body:has(#round-end:not(.hidden)) .settings-btn,
body:has(#matchmaking:not(.hidden)) .settings-btn,
body:has(#leaderboard-overlay:not(.hidden)) .settings-btn,
body:has(#solo-picker:not(.hidden)) .settings-btn,
body:has(#name-prompt:not(.hidden)) .settings-btn,
body:has(.round-countdown:not(.hidden)) .settings-btn {
  display: none !important;
}

/* Settings overlay must stack above EVERY other overlay including
 * the round-countdown (z:200). Otherwise an ESC press during
 * gameplay-with-countdown (defensive — the ESC handler short-
 * circuits during countdowns, but the countdown can be in-flight
 * mid-mutation) would open settings UNDER the countdown scrim.
 * Settings is the user's escape hatch — always make it the top
 * surface when explicitly opened. */
#settings-overlay {
  z-index: 220;
}

/* ----- Main-menu visual reset ----- */
/*
 * `body.is-menu` is added by resetVisualStateForMenu() in main.js
 * and removed when a mode actually starts. While set, it suppresses
 * any gameplay surface that would otherwise leak through the menu's
 * translucent scrim — the Pixi canvas keeps showing whatever frame
 * it last rendered until the next mode kicks off, and the HUD's
 * cream sticker peeks past the menu panel on small phones.
 *
 * The settings gear (z-index 60, sits above the menu overlay at
 * z-index 50) deliberately stays visible so the player can edit
 * name / sound / music before starting a game.
 */
body.is-menu #game-wrap {
  /* visibility: hidden keeps layout space (so #app's flex column
     doesn't reflow) but stops the Pixi canvas from being painted at
     all. opacity: 0 alone wouldn't help since Pixi still composites
     the WebGL backing store. */
  visibility: hidden;
}
body.is-menu .hud {
  /* Hide the HUD strip entirely while the menu is up. It would
     otherwise sit at the top of the page above the menu panel and
     show a ghost score / NEXT preview on small viewports. */
  visibility: hidden;
}
body.is-menu #fx-layer,
body.is-menu .attack-stack,
body.is-menu .ammo-indicator,
body.is-menu .incoming-edge-pulse,
body.is-menu #combo,
body.is-menu .incoming-banner,
body.is-menu .round-banner,
body.is-menu .slow-mo-banner,
body.is-menu .hazard-banner {
  /* Defensive — these are already hidden via JS class toggles in
     resetVisualStateForMenu(), but a fresh page load can momentarily
     paint them between the DOM appearing and the JS toggle running.
     display:none short-circuits any race. */
  display: none !important;
}

/* Mobile adjustments */
@media (max-width: 380px) {
  .pip-slot { width: 84px; }
  .hud-block { min-width: 44px; }
  .hud-value { font-size: 18px; }
  #next-canvas { width: 50px; height: 50px; }
  .title { font-size: 30px; }
  .panel { padding: 18px 18px; }
}

/* Phone-width polish (≤480px): tighten HUD-to-board gap, shrink the
   NEXT preview, compact the PvP attack cards, and slim the incoming-
   attack banner so it doesn't cover the active drop lane. Mobile-only
   — the desktop layout is anchored above this media query. */
@media (max-width: 480px) {
  /* HUD: tighter vertical rhythm so the board gets every pixel it
     can on phone-sized viewports. */
  .hud { margin-bottom: 4px; }
  /* NEXT preview shrinks from 56 → 50 on phones (down to 50 was
     already the <380px rule; this just extends it up to 480). */
  #next-canvas { width: 50px; height: 50px; }

  /* PvP attack cards: primary (oldest, bottom-most, the actual tap
     target) stays thumb-friendly. Secondary (later-queued) cards
     compact + partially tucked via a small negative margin so the
     stack reads as a "queue feeding the primary" instead of three
     equally-loud cards. */
  .queue-slot { width: 76px; height: 96px; padding: 18px 4px 14px; }
  .queue-slot.outgoing.primary { width: 84px; height: 104px; padding: 20px 6px 16px; }
  .queue-slot.outgoing.secondary {
    width: 64px;
    height: 80px;
    padding: 14px 4px 12px;
    opacity: 0.75;
    /* Pulls each non-primary card up into the one below it, ~12px
       of overlap. Reads as "tucked behind" without losing the badge
       / nameplate readability. */
    margin-bottom: -12px;
  }
  .attack-stack { gap: 4px; }

  /* Incoming attack banner: smaller + higher so it sits between the
     HUD and the playfield instead of covering the spawn lane. */
  .incoming-banner {
    top: 96px;
    padding: 6px 14px;
    font-size: 14px;
    letter-spacing: 1px;
  }
}

@media (max-height: 640px) {
  .hud { min-height: 56px; }
  .hud-self { padding: 6px 8px; min-height: 56px; }
  .pip-slot { width: 78px; }
}

/* Landscape: keep mobile-first but breathe */
@media (orientation: landscape) and (max-height: 500px) {
  #app { padding: 4px 8px; }
  .hud { margin-bottom: 4px; min-height: 48px; }
  .pip-slot { width: 72px; }
  .hud-self { min-height: 48px; padding: 4px 8px; }
  .hud-value { font-size: 16px; }
  #next-canvas { width: 44px; height: 44px; }
}

/* ============== Desktop layout (centered shell, ≥ 900px) ==============
 *
 * Mobile (< 900px) keeps the existing layout: the board fills the
 * viewport and `position: fixed` UI (attack stacks, settings gear) is
 * pinned to the screen edges. That works on a phone where the board IS
 * the screen, but on a 1920px desktop the same rules strand the
 * outgoing-stack cards 1500+ px to the right of the centered board,
 * which reads as "mobile layout stretched across a wide window".
 *
 * Fix: the existing `#app` is already a centered flex column with
 * `align-items: center`, so the board (`#game-wrap`, `max-width:
 * 460px`) sits on the horizontal midline. We re-anchor the few
 * viewport-pinned elements to the BOARD's edges using `calc(50% ±
 * shell-half ± gap)` math. No new DOM wrapper — the centered shell is
 * virtual, keyed off the existing board max-width.
 *
 * Anything that's deliberately fullscreen (`.fx-layer`, modal
 * `.overlay` panels, `.incoming-edge-pulse`) stays as-is — those are
 * true overlays, not chrome.
 *
 * Breakpoint: 900px. Below that, the mobile layout is unchanged. */
:root {
  /* Half of #game-wrap's max-width (460 / 2). Used by the desktop
   * media-query math below to anchor side UI to the board's edges. If
   * the board max-width ever changes, update this in lockstep. */
  --shell-half: 230px;
  --shell-side-gap: 12px;
}

@media (min-width: 900px) {
  /* Outgoing stack: anchor LEFT edge of the stack to (board-right + gap).
   * Switching from `right:` to `left:` flips the slots' alignment so
   * they read as "next to the board, growing rightward". */
  .attack-stack.outgoing {
    right: auto;
    left: calc(50% + var(--shell-half) + var(--shell-side-gap));
    align-items: flex-start;
  }
  /* Incoming stack: mirror — RIGHT edge anchored to (board-left - gap). */
  .attack-stack.incoming {
    left: auto;
    right: calc(50% + var(--shell-half) + var(--shell-side-gap));
    align-items: flex-end;
  }
  /* Settings gear: drag it from the viewport's top-right corner to the
   * board's top-right corner. Mobile keeps env(safe-area-inset-right)
   * since notched phones need that buffer; on desktop it's irrelevant
   * and we want strict shell alignment instead. */
  .settings-btn {
    right: calc(50% - var(--shell-half));
  }
}

/* ------------ Leaderboard overlay ------------ */
.leaderboard-panel {
  max-width: 380px;
  text-align: left;
}
.leaderboard-panel h2 {
  text-align: center;
}

/* Reusable corner-X close button. Same chunky sticker language as the
   menu buttons + HUD gear, just smaller and pinned absolute to the
   panel's top-right corner. Reusable across overlays — any panel that
   wants this pattern just needs `.panel-close` inside it. */
.panel-close {
  position: absolute;
  /* Aligned to the panel's 22px border-radius so the close sits
     just inside the rounded corner instead of floating awkwardly
     near the panel edge. */
  top: 12px;
  right: 12px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 2.5px solid var(--ink-deep);
  background: linear-gradient(180deg, #fff7e6 0%, #ffe0a8 100%);
  color: var(--ink-deep);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.85),
    0 2px 0 var(--ink-deep),
    0 4px 8px rgba(58, 24, 16, 0.25);
  transition: transform 90ms ease, box-shadow 90ms ease, filter 160ms ease;
  z-index: 2;
}
.panel-close:hover { filter: brightness(1.05); }
.panel-close:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.85),
    0 1px 0 var(--ink-deep),
    0 2px 4px rgba(58, 24, 16, 0.25);
}
.panel-close svg { display: block; }

/* Tab strip — six tabs laid out as a clean 3×2 grid. Flex-wrap was
   leaving the last button (PvP) stranded on its own row when label
   widths varied; 3-column grid gives a balanced 3+3 regardless. */
.leaderboard-tabs {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  /* Bumped from 4 → 6px so adjacent tabs don't visually mash
     together; the resting tabs already have their own 2px border so
     a tighter gap was reading as one striped surface. */
  gap: 6px;
  margin: 0 0 14px;
  /* Account for the bump on the active tab below — without this,
     the lifted tab clips against the next row. */
  padding-bottom: 2px;
}
.leaderboard-tab {
  font-family: var(--font-display);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.3px;
  background: rgba(255, 255, 255, 0.65);
  color: var(--ink-deep);
  border: 2px solid var(--ink-deep);
  border-radius: 8px;
  padding: 6px 9px;
  cursor: pointer;
  transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
  font-family: inherit;
  /* Resting drop shadow gives the inactive tabs the same sticker
     depth as the active one, just lighter. */
  box-shadow: 0 1.5px 0 rgba(58, 24, 16, 0.18);
}
.leaderboard-tab:hover {
  filter: brightness(1.05);
}
.leaderboard-tab.active {
  background: linear-gradient(180deg, #ffc04a 0%, #ff7e3a 100%);
  color: #fff;
  text-shadow: 0 1.5px 0 rgba(58, 12, 6, 0.35);
  /* Active tab lifts slightly + adds a chunkier solid-edge shadow so
     the selected board is unambiguous at a glance. */
  transform: translateY(-1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.6),
    0 2.5px 0 var(--ink-deep),
    0 4px 8px rgba(255, 126, 58, 0.35);
}
.leaderboard-tab:active {
  transform: translateY(1px);
}
.leaderboard-tab.active:active {
  transform: translateY(0);
}

/* List area. Bounded height with scroll so a long board doesn't push the
   close button off-screen. */
.leaderboard-list {
  max-height: 50vh;
  overflow-y: auto;
  margin: 0 0 12px;
  padding: 0 2px;
}

.leaderboard-rows {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.leaderboard-row {
  display: grid;
  grid-template-columns: 36px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: rgba(255, 255, 255, 0.55);
  border: 1.5px solid rgba(120, 70, 30, 0.2);
  border-radius: 8px;
  font-family: var(--font-display);
  font-size: 13px;
  color: var(--ink-deep);
}

.leaderboard-row.you {
  background: linear-gradient(180deg, #ffe066 0%, #ffb83a 100%);
  border-color: var(--ink-deep);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.6),
    0 2px 0 var(--ink-deep);
  font-weight: 700;
}

.lb-rank {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: rgba(58, 24, 16, 0.75);
}
.leaderboard-row.you .lb-rank { color: var(--ink-deep); }

.lb-name {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-weight: 600;
}

.lb-value {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--accent);
}
.leaderboard-row.you .lb-value { color: var(--ink-deep); }

/* "Nearby" divider — separates the top-25 from the player's local
   ranking neighborhood when they're outside top 25. Just a row of
   muted dots, no border / fill so it doesn't compete with the rows. */
.leaderboard-divider {
  list-style: none;
  text-align: center;
  font-weight: 700;
  letter-spacing: 6px;
  color: rgba(58, 24, 16, 0.35);
  padding: 8px 0 4px;
  font-size: 14px;
  user-select: none;
}

/* Below-list summary — player's rank and score for the active board. */
.leaderboard-yours-line {
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 600;
  text-align: center;
  padding: 8px 12px;
  margin-bottom: 10px;
  background: rgba(255, 240, 210, 0.7);
  border: 1.5px solid rgba(120, 70, 30, 0.25);
  border-radius: 8px;
  color: var(--ink-deep);
}

/* Game-over rank line, inserted under the score by main.js after submit. */
.game-over-rank {
  font-size: 13px;
  font-weight: 600;
  margin: 8px 0 12px;
}
.game-over-rank strong {
  color: var(--accent);
  font-weight: 800;
}

/* ----- Solo harvest report ----- */
/*
 * Themed wrapper for the solo game-over overlay. Inherits .panel for
 * the cream sticker surface and .round-stats for the label/value rows;
 * just adds the title + subtitle treatment and elevates the Score row
 * as the headline. Keeps the layout consistent with the PvP round/match
 * overlays so the whole "results" surface family reads as one system.
 */
.harvest-title {
  margin: 0 0 4px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 28px;
  color: var(--ink-deep);
  letter-spacing: 0.5px;
  line-height: 1.1;
}
.harvest-subtitle {
  margin: 0 0 14px;
  font-size: 13px;
  font-weight: 500;
}
.harvest-stats {
  margin: 0 0 14px;
}
/* Score row is the headline — bigger label, accent-colored value, a
 * solid divider underneath instead of the dashed receipt line that
 * the other rows use. Reads as "this is the takeaway". */
.harvest-stats .stat-row-headline {
  border-bottom-style: solid;
  border-bottom-color: rgba(120, 70, 30, 0.35);
  border-bottom-width: 2px;
  padding: 10px 4px 8px;
  margin-bottom: 4px;
}
.harvest-stats .stat-row-headline .stat-label {
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  opacity: 0.85;
}
.harvest-stats .stat-row-headline .stat-value {
  font-size: 28px;
  color: var(--accent);
  line-height: 1;
}


/* Run insight — one-line takeaway between subtitle and stats. Same
 * weight as a small section header, accent color so it pops. The
 * &nbsp; placeholder in HTML keeps the line height stable while
 * _showGameOver populates the real text. */
.harvest-insight {
  margin: 0 0 10px;
  font-size: 14px;
  font-weight: 700;
  color: var(--accent);
  letter-spacing: 0.4px;
  min-height: 1.2em;
  text-align: center;
}

/* ----- Fruit evolution ladder (harvest report) ----- */
/*
 * Horizontal strip of all 10 fruit tiers shown beneath the harvest
 * stats. Reached tiers (≤ biggestTierReached) are full-color; the
 * peak tier gets a gold accent ring; unreached tiers are dimmed +
 * grayscale so the player reads "you climbed this far, X tiers to
 * go". One <li> per FRUITS entry, populated by _showGameOver.
 *
 * Uses the cached PNG assets via <img src> — `loadFruitAssets()`
 * already pulled them into the browser cache, so no extra network
 * round-trip. The grayscale + opacity is a static filter (set once
 * per render, not animated) so it doesn't trip the mobile-perf
 * filter-strip rules during gameplay.
 */
.ladder-caption {
  margin: 4px 0 4px;
  text-align: center;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 1.2px;
  text-transform: uppercase;
  opacity: 0.7;
}
.fruit-ladder {
  list-style: none;
  margin: 0 0 14px;
  padding: 8px 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  flex-wrap: nowrap;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.45);
  border: 1.5px solid rgba(120, 70, 30, 0.18);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.ladder-rung {
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  /* Unreached default — washed-out, faded back. The player's eye is
     drawn to the colorful reached tiers and the gold-ringed peak. */
  opacity: 0.28;
  filter: grayscale(70%);
  transition: transform 220ms ease;
}
.ladder-rung img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
.ladder-rung.reached {
  opacity: 1;
  filter: none;
}
/* Peak tier — the highest fruit they actually produced this run. Gold
 * accent ring + slight scale-up + soft pulse so the eye lands on it
 * first. The pulse breathes the ring's spread and outer glow over a
 * 1.6s alternating cycle. Reduced-motion + perf-mobile fallbacks
 * below strip it to a static ring. */
.ladder-rung.peak {
  transform: scale(1.35);
  border-radius: 50%;
  box-shadow:
    0 0 0 2px var(--accent-3),
    0 2px 6px rgba(255, 178, 69, 0.45);
  animation: ladderPeakPulse 1.6s ease-in-out infinite alternate;
}
@keyframes ladderPeakPulse {
  from {
    box-shadow:
      0 0 0 2px var(--accent-3),
      0 2px 6px rgba(255, 178, 69, 0.40);
  }
  to {
    box-shadow:
      0 0 0 3px var(--accent-3),
      0 0 14px 4px rgba(255, 178, 69, 0.65);
  }
}
@media (prefers-reduced-motion: reduce) {
  .ladder-rung.peak { animation: none; }
}
.perf-mobile .ladder-rung.peak {
  animation: none;
}
@media (max-width: 380px) {
  .fruit-ladder { gap: 2px; padding: 6px 4px; }
  .ladder-rung { width: 22px; height: 22px; }
  .ladder-rung.peak { transform: scale(1.3); }
}

/* === body.is-game-over board treatment ===
 *
 * The harvest panel sits over the playfield via the .overlay scrim
 * (z:50 + 0.72 alpha + 3px blur on desktop). That alone leaves the
 * frozen board readable through the scrim — fine for a brief
 * round-end transition, but for solo game-over we want a stronger
 * end-of-run beat. These rules dim/blur the board, hide gameplay
 * chrome, and let the harvest panel be the only meaningful surface.
 *
 * Mobile-perf override below strips the filter — opacity-only path
 * stays cheap on Android. The board stays faintly visible so the
 * player still sees their final state, just heavily backgrounded. */
body.is-game-over #game-wrap {
  filter: blur(3px) saturate(0.55) brightness(0.7);
  opacity: 0.5;
  transition: filter 320ms ease, opacity 320ms ease;
  pointer-events: none;
}
body.is-game-over .hud,
body.is-game-over #combo,
body.is-game-over .incoming-banner,
body.is-game-over .round-banner,
body.is-game-over .slow-mo-banner,
body.is-game-over .hazard-banner,
body.is-game-over .ammo-indicator,
body.is-game-over .attack-stack,
body.is-game-over .incoming-edge-pulse {
  visibility: hidden;
}

/* Mobile perf: no filter (expensive on Android compositor). The
 * opacity drop alone keeps the board "there but pushed back". */
.perf-mobile body.is-game-over #game-wrap {
  filter: none;
  opacity: 0.35;
}
@media (prefers-reduced-motion: reduce) {
  body.is-game-over #game-wrap {
    transition: none;
  }
}

/* Harvest panel entrance — overrides the generic .overlay → .panel
 * spring with a more deliberate end-of-run pop. Fires whenever the
 * #game-over overlay's hidden→visible toggle re-mounts the panel.
 * Pure transform + opacity. The animation only runs while
 * body.is-game-over is set so it doesn't fire on stale class
 * resolution at boot. */
body.is-game-over #game-over .panel {
  animation: harvestPanelPop 480ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes harvestPanelPop {
  0%   { transform: scale(0.86) translateY(18px); opacity: 0; }
  55%  { transform: scale(1.04) translateY(-3px); opacity: 1; }
  100% { transform: scale(1)    translateY(0);   opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  body.is-game-over #game-over .panel {
    animation: harvestPanelFadeIn 220ms ease-out;
  }
  @keyframes harvestPanelFadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

/* ----- Phase 7: mobile perf profile ----- */
/*
 * `.perf-mobile` is added to <html> by perfPolicy.applyPerfRootClass()
 * when the device matches the mobile/low-end heuristic. Rules in this
 * block strip the most expensive CSS effects on mobile (backdrop-filter
 * blur + filter keyframe animations) while keeping desktop visuals
 * unchanged. Targets:
 *
 *   - backdrop-filter: blur() compositing — costly on mobile GPUs.
 *     Replaced with an opaque background so the panel still reads as
 *     layered above the playfield.
 *   - Animations whose ONLY effect is filter: brightness/saturate/
 *     drop-shadow (comboPulse, bannerPulse, tutorialPulse, etc.) are
 *     disabled entirely. The base styling already has a solid color
 *     and shadow; losing the pulse is invisible to most players and
 *     saves the per-frame compositing cost.
 *   - Animations that combine transform with filter (comboPop,
 *     wmSpawn, card-fly stages) keep the transform; just the static
 *     filter property on the element itself is stripped via
 *     `filter: none !important`. Animation keyframes still write
 *     filter values during the run, but those values land on the
 *     same compositing-light path the rest of the time.
 *   - Hover brightness — desktop-only, but stripping unconditionally
 *     is fine since hover doesn't fire on mobile.
 *
 * Adding a new animation? Keep it transform/opacity-only when
 * possible. If filter is unavoidable, add a .perf-mobile override
 * here.
 */
.perf-mobile #pip-slot,
.perf-mobile .pip-shell,
.perf-mobile .pvp-stats-shell {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
  background-color: rgba(255, 245, 230, 0.92) !important;
}

/* Disable filter-only pulse/glow animations entirely on mobile. The
 * underlying selectors keep their base box-shadow + color so the
 * elements still read as accents; just the per-frame brightness/
 * drop-shadow pulse goes away. */
.perf-mobile .combo-banner,
.perf-mobile .incoming-banner,
.perf-mobile .hazard-banner,
.perf-mobile .slowmo-banner {
  animation: none !important;
}

/* Filter-driven hover effects — never fire on touch anyway. */
.perf-mobile .settings-btn:hover,
.perf-mobile .panel-close:hover,
.perf-mobile .big-btn:hover {
  filter: none !important;
}

/* Card-fly stages: strip the static drop-shadow + brightness on the
 * transformed elements. The pluck/travel/showcase/throw transforms
 * still run; the glow layer is what's expensive to composite. */
.perf-mobile .queue-slot.outgoing.flying,
.perf-mobile .queue-slot.outgoing.showcase,
.perf-mobile .queue-slot.outgoing.thrown,
.perf-mobile .queue-slot.outgoing.impact {
  filter: none !important;
}

/* Tutorial pulse — pure brightness flicker, kill it on mobile. */
.perf-mobile .next-bump-target,
.perf-mobile .ammo-indicator-block {
  animation: none !important;
}

/* ----- Phase 8: extended mobile CSS perf overrides -----
 *
 * The block below extends the Phase 7 strips with the rest of the
 * decorative effects that show up under flame charts as paint /
 * compositing hot spots on mid-tier Android. Same shape as Phase 7:
 * disable per-frame filter and big-blur box-shadow keyframes; let the
 * underlying static styling carry the visual weight. Where an effect
 * conveys readable game state (incoming attack, board danger), we
 * substitute an opacity-only pulse so the signal still reads.
 *
 * Override via `?perfCss=0` (force off) or `?perfCss=1` (force on).
 * Default follows the same mobile heuristic as DPR / antialias caps.
 */

/* Body sparkle drift — two parallax radial-gradient layers animated via
 * background-position. On mobile each frame triggers a paint of two
 * full-viewport gradient stacks, plus the compositor mixes them with
 * the playfield underneath. Scope to `body.is-playing` so the menu
 * still has the ambient sparkle (no playfield paint to compete with).
 * Renamed from the older `body.in-game` class — same role, but now
 * driven by the unified setAppState() helper in main.js. */
.perf-mobile body.is-playing::before,
.perf-mobile body.is-playing::after {
  animation: none !important;
}

/* Filter-driven keyframes outside Phase 7's banner kill-list. Same
 * justification: they're decorative pulses whose only output is a
 * brightness / drop-shadow flicker. The underlying gradient + border
 * already make these elements stand out; the pulse is gravy. */
.perf-mobile .hud-block.next-rerolled,
.perf-mobile .queue-slot.fire-flying,
.perf-mobile .queue-slot.fire-showcase,
.perf-mobile .queue-slot.fire-throwing {
  animation: none !important;
  filter: none !important;
}

/* Large blurred box-shadow keyframe pulses. These are the most
 * expensive class of decorative effect on mobile — every frame the
 * compositor re-rasterizes a big soft shadow. Disable the animation
 * AND clear any inset glow so we don't accidentally leave a frozen
 * mid-frame state on the element. The base styling already carries
 * the static border / accent color, so the warning is still readable
 * even without the pulse. */
.perf-mobile .incoming-edge-pulse,
.perf-mobile .incoming-edge-pulse.crush,
.perf-mobile #app.magnet-active::after,
.perf-mobile .pip.big-flash .pip-frame,
.perf-mobile .pip.hit-flash {
  animation: none !important;
  box-shadow: none !important;
}

/* Pip incoming warn — keep the warning readable as a single short
 * opacity pulse instead of the box-shadow keyframe. The .incoming-edge-pulse
 * div above the playfield has been silenced, so we want SOMETHING to
 * tell the player an attack is inbound. */
.perf-mobile .pip.incoming-warn {
  animation: pipIncomingWarnLite 700ms ease-in-out 1 !important;
  box-shadow: none !important;
}
@keyframes pipIncomingWarnLite {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.55; }
}

/* Danger pulse — applied to the PiP frame when the opponent's board
 * is close to overflow. Replace the box-shadow keyframe with a pure
 * opacity pulse so the player still gets a "they're in trouble"
 * signal at near-zero paint cost. */
.perf-mobile .pip.danger .pip-frame {
  animation: dangerPulseLite 0.7s ease-in-out infinite alternate !important;
  box-shadow: none !important;
}
@keyframes dangerPulseLite {
  from { opacity: 0.7; }
  to   { opacity: 1; }
}

/* Ammo full pulse — single-shot 600ms ring. The ring is decorative
 * feedback for "your queue just hit cap"; .ammo-indicator.full
 * already recolors the indicator border so the signal still reads. */
.perf-mobile .ammo-indicator.full {
  animation: none !important;
}

/* Hover brightness across the rest of the buttons + queue slots. Touch
 * never fires :hover, but mobile browsers sometimes synthesize it on
 * tap-and-hold which forces a layer repaint. */
.perf-mobile .queue-slot:hover,
.perf-mobile .menu-card:hover {
  filter: none !important;
}

/* ------------ Boot loader ------------
 *
 * Themed loading screen shown immediately on first paint and dismissed
 * by main.js once the real readiness signal fires (Fredoka loaded +
 * fruit PNGs settled), with a 12s hard timeout as a failsafe. The
 * critical visibility + background gradient are inlined in <head>;
 * everything below is the polish layer (animations, typography,
 * sub-card styling). When this stylesheet is still in flight the
 * loader already looks intentional thanks to the inline styles —
 * these rules just lift it from "intentional" to "branded".
 *
 * Animations are transform/opacity-only so they stay cheap even on
 * mid-tier mobile. prefers-reduced-motion strips them entirely.
 */
.boot-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 28px 36px 24px;
  border-radius: 22px;
  background: linear-gradient(180deg, #fff7e6 0%, #ffe8c4 100%);
  border: 3px solid var(--ink-deep);
  box-shadow:
    inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
    0 4px 0 var(--ink-deep),
    0 8px 24px rgba(58, 24, 16, 0.28);
  /* Subtle entrance — the card pops in slightly so the user feels
     "something is happening" instead of a static splash. */
  animation: bootCardIn 360ms cubic-bezier(0.34, 1.56, 0.64, 1);
  max-width: 320px;
  text-align: center;
}
@keyframes bootCardIn {
  from { transform: scale(0.86); opacity: 0; }
  to   { transform: scale(1);    opacity: 1; }
}

/* Watermelon container — the SVG spins; the seed pseudo-elements
   orbit. Wrapping the SVG in its own bobbing container lets us
   compose two animations (bob on container, spin on SVG) without one
   overriding the other on a single element. */
.boot-watermelon {
  position: relative;
  width: 104px;
  height: 104px;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: bootBob 2.4s ease-in-out infinite;
}
.boot-watermelon svg {
  display: block;
  width: 104px;
  height: 104px;
  animation: bootSpin 3.6s linear infinite;
  filter: drop-shadow(0 4px 6px rgba(58, 24, 16, 0.22));
  transform-origin: 50% 54%;     /* match the SVG's body center, not its bbox center */
}
@keyframes bootBob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-6px); }
}
@keyframes bootSpin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

/* Orbiting seeds — three little dark dots circling the watermelon.
   Pure transform animation so the GPU compositor can run it on its
   own layer with no paint cost per frame. Different durations + start
   rotations give the orbit a "loose flock" feel rather than three
   ducks on the same string. */
.boot-seed {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 7px;
  height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: #2a1408;
  box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.55);
  transform-origin: 0 0;
}
.boot-seed-1 { animation: bootSeedOrbit 3.2s linear infinite; }
.boot-seed-2 { animation: bootSeedOrbit 3.2s linear infinite -1.07s; }
.boot-seed-3 { animation: bootSeedOrbit 3.2s linear infinite -2.13s; }
@keyframes bootSeedOrbit {
  from { transform: rotate(0deg)   translate(60px) rotate(0deg);   opacity: 0.85; }
  50%  { opacity: 1; }
  to   { transform: rotate(360deg) translate(60px) rotate(-360deg); opacity: 0.85; }
}

/* Title — Fredoka, two-tone like the menu's chunky logo treatment.
   Chunky text-shadow gives the same sticker feel as .title without
   the full SVG-stroke escalation; this is a loader, not the menu. */
.boot-title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 38px;
  letter-spacing: 0.5px;
  line-height: 1;
  color: #3aa14a;
  text-shadow:
    -1.5px 1.5px 0 var(--ink-deep),
     1.5px 1.5px 0 var(--ink-deep),
     1.5px -1.5px 0 var(--ink-deep),
    -1.5px -1.5px 0 var(--ink-deep),
     0 3px 0 var(--ink-deep);
}
.boot-title span {
  color: var(--accent-2);
}

/* Subtitle — playful copy that main.js cycles through. The opacity
   transition smooths each text swap so the user reads "loading
   message" rather than "text glitching". */
.boot-subtitle {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 14px;
  color: #6a4520;
  opacity: 0.85;
  transition: opacity 220ms ease;
  min-height: 1.2em;
}

/* Indeterminate loading dots — three dots that pulse with staggered
   delays. Pure opacity + transform animation; cheap and reads as
   "still working". Sized to match the subtitle so the card has a
   consistent visual rhythm. */
.boot-dots {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  margin-top: 4px;
}
.boot-dots span {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--accent-2);
  border: 1.5px solid var(--ink-deep);
  animation: bootDotPulse 1.1s ease-in-out infinite;
}
.boot-dots span:nth-child(2) { animation-delay: 0.18s; }
.boot-dots span:nth-child(3) { animation-delay: 0.36s; }
@keyframes bootDotPulse {
  0%, 100% { transform: scale(0.7); opacity: 0.55; }
  50%      { transform: scale(1.0); opacity: 1;    }
}

/* Compact tweaks for narrow viewports — keep the same layout but tighten
   the padding so the card fits comfortably above the keyboard / safe
   areas on small phones. */
@media (max-width: 380px) {
  .boot-card { padding: 22px 26px 20px; max-width: 280px; }
  .boot-title { font-size: 32px; }
  .boot-watermelon, .boot-watermelon svg { width: 88px; height: 88px; }
  .boot-seed { width: 6px; height: 6px; margin: -3px 0 0 -3px; }
  @keyframes bootSeedOrbit {
    from { transform: rotate(0deg)   translate(50px) rotate(0deg);   opacity: 0.85; }
    50%  { opacity: 1; }
    to   { transform: rotate(360deg) translate(50px) rotate(-360deg); opacity: 0.85; }
  }
}

/* Accessibility — full strip of motion under the OS-level preference.
   The loader still appears, with the static visual + a single subtle
   dot-pulse so the user can tell it's not frozen. */
@media (prefers-reduced-motion: reduce) {
  .boot-card,
  .boot-watermelon,
  .boot-watermelon svg,
  .boot-seed { animation: none !important; }
  .boot-dots span { animation-duration: 1.8s; }
}
