  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
  :root {
    --bg-1: #ffd6e7;
    --bg-2: #ffe9cc;
    --bg-3: #d6e7ff;
    --glass: rgba(255, 255, 255, 0.55);
    --glass-strong: rgba(255, 255, 255, 0.78);
    --glass-border: rgba(255, 255, 255, 0.85);
    --ink: #4a3149;
    --ink-soft: #8a708a;
    --accent: #ff6b9d;
    --accent-2: #ff9a6b;
    --shadow: 0 12px 32px rgba(120, 80, 140, 0.18);
    --shadow-soft: 0 4px 14px rgba(120, 80, 140, 0.10);
    /* HUD + tier-strip + power-up bar + gaps + body-padding. */
    /* HUD + tier strip + power-ups. The strip grew when it took on the rail
       and the caption, so the jar has to be told about it — otherwise a short
       phone runs out of height and the playfield spills off screen. */
    /* Down from 214. The HUD lost a card and the strip lost its container and
       ~18px of its own height, and every pixel saved goes straight to the jar —
       which is the one thing on screen that is supposed to dominate. */
    --vert-reserved: 186px;
    /* Decorative blob colors — overridden per theme */
    --blob-1: #ffafd1;
    --blob-2: #b9d6ff;

    /* ── ONE UI MATERIAL ──────────────────────────────────────────────────
       The interface was running three unrelated materials at once: the HUD had
       become opaque milky, the power-ups pastel colour, and the collection strip
       was still blurred glass. Glass was the odd one out in a way that mattered:
       backdrop-filter saturate(140%) pulls the world's colour INTO the panel, so
       that component alone changed character on every theme and could never sit
       with the other two.
       One surface, one hairline, one shadow — and the ROLE decides how much of
       each a component takes. Information takes the least, interaction the most. */
    --ui-top: rgba(255,254,253,0.95);
    --ui-bot: rgba(250,246,243,0.90);
    --ui-base: #fdfaf8;
    --ui-edge: rgba(255,255,255,0.75);
    --ui-hair: 0 0 0 1px rgba(90, 75, 130, 0.05);
    --ui-lift:
      inset 0 -1px 1px rgba(255,255,255,0.85),
      inset 0 1px 0 rgba(255,255,255,0.95),
      0 1px 2px rgba(72, 60, 95, 0.07),
      0 4px 12px rgba(72, 60, 95, 0.10);
    --ui-ink: #453f50;
    --ui-ink-soft: #6d6678;
    --ui-ink-quiet: #918a9c;
    /* Progression accent. Deliberately NOT var(--accent): that is redefined per
       world and lands on hot pink or red in most of them, which is exactly the
       harshness the track had. One warm, low-saturation coral everywhere. */
    --ui-progress: #d08268;
    --ui-progress-deep: #c26a4e;
  }
  html, body { height: 100%; overflow: hidden; }
  /* The theme wash lives on <html>, not <body>, and is deliberately NOT
     background-attachment: fixed. Both details are about Android.

     A root-element background is painted across the whole canvas by definition,
     so it can never come up short when the WebView is resized — which is exactly
     what happens at launch, while the system-bar insets are still settling.
     `background-attachment: fixed` instead pins the paint to the viewport rect
     the compositor layer was built with, and the strip a resize exposes at the
     bottom is left unpainted: an opaque black band, with the cards still drawn
     over it. It was also buying nothing here — html and body are both
     overflow:hidden, so this page never scrolls and there is nothing for a fixed
     background to hold still against.

     background-color under the layers is the floor: whatever else happens, the
     bottom of the stack is a theme colour and never black.

     Layers, top→bottom: a faint film grain (kills gradient banding + adds a
     tactile candy texture) → a flat white veil that lightens/desaturates the
     wash so cards always pop, even on cool/saturated themes → the theme
     gradient. Grain is a tiny tiled SVG — static, cheap, mobile-safe. */
  html {
    background:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)' opacity='0.04'/%3E%3C/svg%3E") repeat,
      linear-gradient(180deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.15) 100%),
      linear-gradient(135deg, var(--bg-1) 0%, var(--bg-2) 50%, var(--bg-3) 100%);
    background-size: 160px 160px, cover, cover;
    background-color: var(--bg-2);
  }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", system-ui, sans-serif;
    color: var(--ink);
    /* No background here on purpose. <body> painting its own background would
       cover body::before/::after, which are the blur orbs at z-index:-1. */
    display: flex; flex-direction: column;
    align-items: center;
    /* Anchored to the bottom, not centred. Centring split the leftover height
       evenly, which on tall phones parked the power-up row 90-112px above the
       screen edge — measured 94 on a 390x844, 112 on a 412x915. The controls are
       what the thumb reaches for, so the slack belongs ABOVE the board, not under
       the buttons. */
    /* `safe` matters: plain end alignment pushes the overflow off the TOP when
       the content is taller than the screen, and overflow:hidden then eats it
       silently — measured -10px on a 360x640 and -15px on a 320x568, i.e. the
       cloud was being cut. safe falls back to start alignment exactly in that
       case, so nothing is ever lost off the top edge.
       Centred rather than bottom-anchored now: with the gaps above absorbing most
       of the slack, whatever remains is small enough that splitting it reads as
       margin on both sides instead of a hole on one. */
    justify-content: flex-end;
    justify-content: safe flex-end;
    min-height: 100dvh;
    /* Grows a little with the screen, and always clears a gesture bar. Android
       WebViews often report env() as 0 even with gesture nav, so the floor is a
       real number rather than a trust in the inset. */
    padding: 8px 8px max(clamp(14px, 2.2vh, 26px), calc(env(safe-area-inset-bottom) + 8px));
    user-select: none; -webkit-user-select: none;
    overscroll-behavior: none;
  }
  body::before, body::after {
    content: ''; position: fixed; width: 400px; height: 400px;
    border-radius: 50%; filter: blur(84px); opacity: 0.42;
    pointer-events: none; z-index: -1;
    animation: float 16s ease-in-out infinite alternate;
  }
  body::before { background: var(--blob-1); top: -150px; left: -150px; transition: background 0.5s ease; }
  body::after  { background: var(--blob-2); bottom: -150px; right: -150px; animation-delay: -8s; transition: background 0.5s ease; }
  body { transition: background 0.5s ease; }
  @keyframes float {
    from { transform: translate(0, 0) scale(1); }
    to   { transform: translate(38px, 28px) scale(1.07); }
  }
  @media (prefers-reduced-motion: reduce) {
    body::before, body::after { animation: none; }
  }

  @media (max-height: 620px) { :root { --vert-reserved: 188px; } }


  #wrap {
    /* width is constrained by EITHER viewport width OR available height (keeping 420:640 aspect).
       Whichever is smaller wins — playfield never overflows the phone. */
    width: min(420px, calc(100vw - 16px), calc((100vh - var(--vert-reserved)) * 420 / 500));
    width: min(420px, calc(100vw - 16px), calc((100dvh - var(--vert-reserved)) * 420 / 500));
    /* The gap scales with the screen so a tall phone spends its spare height on
       breathing room BETWEEN things rather than as one dead band. On a 412x883
       the board is already as wide as the phone allows, so ~158px has nowhere
       else to go — dumped at the top it was a 150px hole above the HUD, dumped
       at the bottom it was a 94px hole under the controls. */
    /* Raised a little: the height freed by shrinking the HUD-to-board gap is
       spread across every gap here rather than all landing back above the HUD. */
    display: flex; flex-direction: column; gap: clamp(6px, 2.2vh, 21px);
    align-items: stretch;
    position: relative;
  }

  /* HUD */
  /* Three elements at three weights, not four cards at one weight. The grid gave
     every panel a comparable track and that evenness WAS the clutter: nothing
     told the eye where to land. Now SCORE takes all the slack and the two
     secondary readouts stay exactly as wide as their contents. */
  /* Three heights, not three equal boxes. Everything sitting on one baseline at
     one height is what made the strip read as a row of widgets — the score is the
     primary reading, so it is the one that rises. stretch is gone for the same
     reason: equal height forced equal presence. */
  #hud {
    display: flex; gap: 7px;
    align-items: flex-start;
    justify-content: space-between;
    padding-top: 2px;
  }
  /* Both side readouts hang from the SAME bottom edge, so their captions land on
     one line whatever the globe or the next piece measures. */
  #hud > .action-panel { flex: 0 0 auto; align-self: flex-end; margin-top: 0; }
  #hud > .score-panel  { flex: 0 1 auto; min-width: 140px; max-width: 186px; margin-top: -22px; }
  #hud > .next-panel   { flex: 0 0 auto; align-self: flex-end; margin-top: 0; }
  .panel {
    /* Shared shadow stack, held in a variable because the attract/wish states
       below add a ring on top of it and must not fork their own copy. */
    /* INFORMATION MODULE. Same family as the controls, deliberately a weaker
       member of it: no contact shadow, a shallower ambient one, and a slightly
       thinner surface. Everything up here was carrying the same lift as the
       power-ups, and equal depth is what made four different jobs read as one
       repeated component. Depth is the hierarchy signal, not size. */
    --hud-shadow:
      inset 0 1px 0 rgba(255,255,255,0.90),
      0 2px 7px rgba(72, 60, 95, 0.07),
      var(--ui-hair);
    /* Same construction as the power-up buttons: warm off-white over a solid
       accent layer, with the white held just short of opaque so roughly 5% of
       the world's own accent comes through. That is the whole of the world
       tinting — enough that the card belongs to Halloween or to Aqua, never
       enough for the world to decide the card's contrast.
       backdrop-filter is deliberately gone. blur(14px) saturate(140%) pulled the
       world's colour INTO the card, so the HUD looked different on every theme
       and could not match the bottom buttons; it also cost a live GPU pass on
       five elements every frame, on the cheap Androids this game mostly runs on. */
    /* Genuinely translucent. The old stack ended in a SOLID --ui-base, so the two
       white layers above it had nothing to be translucent over — the card was
       opaque by construction whatever their alphas said. Dropping the base lets
       the world's own gradient come through, which is the whole look.
       No backdrop-filter: a plain white veil already reads as glass over these
       soft grounds, and a live blur on five elements is a per-frame GPU cost on
       the phones this actually ships to. */
    background: linear-gradient(180deg,
      rgba(255,255,255,0.60) 0%, rgba(255,255,255,0.44) 100%);
    border: 1px solid rgba(255,255,255,0.58);
    /* 14, against the power-ups' full capsule and the board's 22 — three
       different radii for three different roles, instead of one soft-card
       radius stamped on everything. */
    border-radius: 14px;
    padding: 7px 12px 8px;
    box-shadow: var(--hud-shadow);
    text-align: center;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
  }
  /* Labels are captions, not headings. 700 at 1.2px tracking was shouting over
     the number it was supposed to introduce; the colour now carries the quietness
     instead of opacity, so the label stays crisp rather than going hazy. */
  .score-panel .label,
  .next-panel .label,
  .action-panel .label {
    font-size: 9.5px; font-weight: 500; opacity: 1;
    color: var(--ui-ink-quiet);
    letter-spacing: 0.9px; text-transform: uppercase;
  }
  /* Score/Best now inherit text-align: center from .panel — uniform row
     of tiles instead of the old scoreboard-edges asymmetry. */
  /* The live score and the record share one baseline row. Baseline alignment is
     what makes them read as one readout rather than as two stacked numbers. */
  /* Stacked, as a secondary metric should be. On one baseline with SCORE it
     had to shrink to 12px to stay subordinate, which is how it ended up reading
     as a footnote; given its own line it can be legible AND clearly second. */
  /* SCORE IS A CLOUD.
   *
   * Shape carries the hierarchy here, which is the one thing size could not do
   * without making the number silly.
   *
   * Built as a MASK, not as pseudo-element bumps. Bumps worked while the card was
   * opaque, but they paint over the card's own background, so the moment the
   * surface became translucent every overlap composited twice and the two
   * ellipses showed as lighter patches sitting on the cloud. A mask carves one
   * fill into the whole silhouette, so the alpha is identical everywhere and
   * there is nothing to double up. drop-shadow() traces the masked shape too,
   * which box-shadow never could.
   *
   * The box is padded at the top to make room for the bumps inside it — a mask
   * can only reveal what the element already covers. */
  .score-panel {
    position: relative;
    border: 0;
    box-shadow: none;
    border-radius: 0;
    background: rgba(255,255,255,0.56);
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 130' preserveAspectRatio='none'%3E%3Crect x='0' y='58' width='200' height='72' rx='34' fill='%23fff'/%3E%3Cellipse cx='44' cy='74' rx='36' ry='30' fill='%23fff'/%3E%3Cellipse cx='92' cy='46' rx='48' ry='42' fill='%23fff'/%3E%3Cellipse cx='150' cy='64' rx='40' ry='35' fill='%23fff'/%3E%3C/svg%3E");
    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 130' preserveAspectRatio='none'%3E%3Crect x='0' y='58' width='200' height='72' rx='34' fill='%23fff'/%3E%3Cellipse cx='44' cy='74' rx='36' ry='30' fill='%23fff'/%3E%3Cellipse cx='92' cy='46' rx='48' ry='42' fill='%23fff'/%3E%3Cellipse cx='150' cy='64' rx='40' ry='35' fill='%23fff'/%3E%3C/svg%3E");
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    filter:
      drop-shadow(0 1px 1px rgba(72, 60, 95, 0.10))
      drop-shadow(0 5px 11px rgba(72, 60, 95, 0.13));
  }
  /* Id-scoped so it outranks `.panel { padding }` inside the tall-screen media
     query, which sits later in the sheet and was flattening this to 5px — the
     label then sat ABOVE the mask's opaque area and got cut in half. The top
     padding is not spacing here, it is the room the bumps live in. */
  #hud .score-panel { padding: 27px 14px 9px; }
  /* Id-scoped for the same reason as the cloud above: `.panel { padding }` inside
     the tall-screen media query sits later in the sheet and was giving this 5px
     of bottom padding, which is exactly the amount its caption sat above WORLD's.
     #home-btn escaped it only because an id already outranks a class. */
  #hud .next-panel  { padding: 0 8px; }

  .score-line {
    margin-top: 1px;
    display: flex; flex-direction: column; align-items: center; gap: 2px;
  }
  /* The score carries its world's accent — the colour it already flashes to on
     a merge, now held all the time.
     Not the raw accent though: measured on the cloud across all 33, the raw
     values land between 1.66:1 (Faces) and 5.43:1 (Mystic), and a 27px bold
     figure needs 3:1. Pulling each accent 38% toward a deep ink keeps the hue
     unmistakable and lifts the WORST world to 3.19:1, which is the most accent
     that is readable everywhere. The plain-ink fallback covers WebViews without
     color-mix — readable rather than pretty, which is the right way round. */
  .score-panel .value {
    font-size: 27px; font-weight: 600; line-height: 1;
    color: var(--ui-ink); letter-spacing: -0.3px;
    font-variant-numeric: tabular-nums;
  }
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .score-panel .value { color: color-mix(in srgb, var(--accent) 62%, #332d45); }
  }
  /* Roughly a third of SCORE's size, and quieter in colour. BEST used to be 20px
     against SCORE's 27 in a card of its own — close enough that the eye had to
     read both to find the live one. */
  .score-best {
    display: flex; align-items: center; gap: 5px;
    font-size: 14px; font-weight: 500; line-height: 1;
    color: var(--ui-ink-soft);
    font-variant-numeric: tabular-nums;
  }
  /* The real 3D crown, not a system glyph — a flat emoji beside this art would
     be the only 2D thing on the screen. */
  .best-crown {
    width: 14px; height: 14px; display: block;
    filter: drop-shadow(0 1px 1.5px rgba(120, 90, 30, 0.28));
  }
  .score-best .best-k {
    font-size: 9.5px; font-weight: 500; letter-spacing: 0.7px;
    text-transform: uppercase; color: var(--ui-ink-quiet);
  }
  .score-pop { animation: scorePop 0.32s ease-out; display: inline-block; }
  @keyframes scorePop {
    0%   { transform: scale(1); }
    35%  { transform: scale(1.18); color: var(--accent); }
    100% { transform: scale(1); }
  }
  /* Same mechanism as #home-btn: a two-row grid with the caption last. It was a
     reversed flex column, and a flex box and a grid box distribute their leftover
     space differently — which left the two captions 4-5px out of line no matter
     what their own padding said. Same layout model, same box behaviour, one line. */
  .next-panel {
    display: grid;
    grid-template-areas: 'icon' 'label';
    grid-template-rows: 38px auto;
    align-items: center;
    justify-items: center; align-content: end;
    padding: 7px 14px 8px; min-width: 78px;
  }
  .next-panel .label { grid-area: label; }
  .next-img          { grid-area: icon; }
  .next-img {
    width: 42px; height: 42px;
    background-size: contain; background-repeat: no-repeat; background-position: center;
    display: flex; align-items: center; justify-content: center;
    font-size: 34px; line-height: 1;
    /* Room to breathe. No frame around it on purpose — the card IS the frame,
       and the piece is the one thing up here allowed to stay fully colourful. */
    margin: 2px 0 0;
    filter: drop-shadow(0 2px 4px rgba(60, 50, 90, 0.16));
    transition: transform 0.25s ease-out;
  }
  .next-img.bump { animation: bump 0.4s ease-out; }
  @keyframes bump {
    0% { transform: scale(0.6) rotate(-10deg); }
    60%{ transform: scale(1.15) rotate(6deg); }
    100%{ transform: scale(1) rotate(0); }
  }

  /* Stage — styled as a glass jar.
     - Mostly translucent body so the world's bg color shows through (water in aqua, cream in bakery, pink in critters)
     - Bright top rim like a jar opening
     - Big diagonal sun-shine on top-left (::before)
     - Vertical reflection stripe on the right (::after)
     - Bottom inset shadow for jar-base depth
  */
  /* The spare height on a tall phone lands HERE, above the board, and nowhere
     else. Centring split it evenly and reopened a 70-93px hole under the
     controls; bottom-anchoring alone piled 150px above the HUD. Between the HUD
     and the board is the one place a big gap reads as air rather than as a
     hole. */
  /* Gated on height, not just clamped by it: a 360x640 has no spare height at
     all, and a vh-scaled margin there pushed the control row 23px off the bottom
     of the screen. Only phones with slack to spend get to spend it. */
  #stage { margin-top: 0; }
  @media (min-height: 745px) {
    /* More of the spare height parked here means less of it left over at the top,
       because the column is bottom-anchored: growing it from the inside pushes
       its TOP edge up rather than pushing anything down. */
    #stage { margin-top: clamp(0px, 4vh, 40px); }
    /* The height freed by tightening the HUD-to-jar gap goes BELOW the stack, not
       back above it. Bottom-anchored, shrinking a gap inside the column only
       lowers the HUD — the jar stays put. Lifting the whole column off the bottom
       edge is what actually raises the jar. */
    body { padding-bottom: max(clamp(34px, 5.2vh, 54px), calc(env(safe-area-inset-bottom) + 22px)); }
  }

  /* Sits above the canvas and below the goal banner, so the jar reddens but
     nothing the player has to read is tinted. */
  #danger-veil {
    position: absolute; inset: 0;
    z-index: 5;
    pointer-events: none;
    opacity: 0;                      /* intensity — written from the loop */
    transition: opacity 240ms ease;
  }
  /* The band is deliberately short. It covers the rim and the air above it,
     which is where the trouble is; tinting the whole jar would hide the pieces
     the player is trying to read at the exact moment they most need to. */
  #danger-veil .danger-veil-glow {
    position: absolute; inset: 0;
    background: linear-gradient(180deg,
      rgba(255, 38, 76, 0.60) 0%,
      rgba(255, 52, 92, 0.40) 8%,
      rgba(255, 92, 120, 0.17) 19%,
      rgba(255, 120, 142, 0.05) 30%,
      rgba(255, 120, 142, 0) 42%);
    animation: dangerBreathe 1.25s ease-in-out infinite;
  }
  @keyframes dangerBreathe { 0%, 100% { opacity: 0.58 } 50% { opacity: 1 } }
  @media (prefers-reduced-motion: reduce) {
    #danger-veil .danger-veil-glow { animation: none; opacity: 0.85; }
  }

  #stage {
    position: relative;
    width: 100%;
    aspect-ratio: 420 / 500;
    border-radius: 38px 38px 30px 30px;
    overflow: hidden;
    background:
      /* Uniform frost — no brightening near the bottom, so there's no
         "horizon line" where the gradient changes opacity. The world bg
         glows through evenly across the entire play area. */
      linear-gradient(180deg,
        rgba(255,255,255,0.62) 0%,
        rgba(255,255,255,0.34) 14%,
        rgba(255,255,255,0.30) 50%,
        rgba(255,255,255,0.30) 100%);
    box-shadow:
      0 30px 56px rgba(40,70,110,0.28),
      0 14px 22px rgba(40,70,110,0.13),
      0 4px 8px rgba(40,70,110,0.08),
      inset 0 3px 0 rgba(255,255,255,1.0),
      inset 0 8px 14px rgba(255,255,255,0.42),
      inset -4px 0 16px rgba(255,255,255,0.30),
      inset 4px 0 16px rgba(255,255,255,0.26);
    border: 1.5px solid rgba(255,255,255,0.78);
    touch-action: none;
    cursor: pointer;
  }
  /* Glass shines render BEHIND the canvas (z-index: 0). The canvas is
     transparent except where bodies are drawn — so shines show through
     empty space (correct glass-jar look) but bodies stay crisp.
     Slow shimmer animation makes the glass feel alive. */
  #stage::before {
    content: '';
    position: absolute; inset: 0;
    background: radial-gradient(ellipse 55% 32% at 22% 12%,
      rgba(255,255,255,0.55) 0%,
      rgba(255,255,255,0.14) 48%,
      transparent 78%);
    pointer-events: none;
    z-index: 0;
    border-radius: inherit;
    animation: shineDrift 9s ease-in-out infinite alternate;
  }
  #stage::after {
    content: '';
    position: absolute;
    top: 14%; right: 5%;
    width: 8px; height: 50%;
    background: linear-gradient(180deg,
      transparent 0%,
      rgba(255,255,255,0.65) 28%,
      rgba(255,255,255,0.25) 75%,
      transparent 100%);
    border-radius: 8px;
    filter: blur(2.5px);
    pointer-events: none;
    z-index: 0;
    animation: stripeDrift 11s ease-in-out infinite alternate;
  }
  @keyframes shineDrift {
    from { opacity: 0.85; transform: translate(0, 0); }
    to   { opacity: 1.0;  transform: translate(10px, -4px); }
  }
  @keyframes stripeDrift {
    from { opacity: 0.9; transform: translateY(0); }
    to   { opacity: 1.0; transform: translateY(8px); }
  }
  #game-canvas {
    width: 100%; height: 100%; display: block; position: relative; z-index: 1;
    /* Canvas-level mask fades the bottom strip to transparent — body
       bottoms blend smoothly into the stage's frosted bg, no matter how
       big the merge or how many bodies pile up at the floor. The fade
       is part of the canvas element (CSS compositor), not painted per
       frame, so no performance hit on body drawing. */
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
    mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
  }

  /* HUD action panel — same labeled-tile pattern as the score/next/best
     panels: small caps label on top, icon below. Both home-btn and
     shop-btn share the same first-column grid cell; JS toggles which is
     visible based on context (shop on home screen, home during gameplay). */
  /* First-launch invitation. Ring + lift only — no colour fill, because the
     row already carries the world's own palette and a second colour on top of
     it reads as an error state rather than an invitation. */
  .theme-row.first-cta {
    animation: ctaPulse 1.9s ease-in-out infinite;
    position: relative;
  }
  /* Pointing hand under the play button. The disc pulses on its own, but a
     pulse only says "look here" — the hand says "press this". Sized under the
     tutorial's 48px because the row is smaller than the stage, and parked so
     the fingertip overlaps the disc's lower edge: the tap has to land ON the
     thing, not near it.
     Uses the 3D Fluent asset for the same reason the tutorial does — the
     native emoji renders flat on a lot of Android WebViews. */
  .cta-finger {
    position: absolute;
    right: 20px;           /* 14px row padding + (52px disc - 40px hand)/2 */
    bottom: 4px;
    width: 40px; height: 40px;
    pointer-events: none;
    z-index: 3;
    filter: drop-shadow(0 5px 10px rgba(0,0,0,0.26));
    animation: ctaFingerTap 1.9s ease-in-out infinite;
    will-change: transform;
  }
  .cta-finger img { width: 100%; height: 100%; display: block; }
  /* Press is quick, release is slower, then it waits — a real tap, not a hover.
     Timed so the hand arrives just before the disc reaches its widest, which
     reads as the disc reacting to the touch. */
  @keyframes ctaFingerTap {
    0%, 26%  { transform: translateY(0) scale(1); }
    44%      { transform: translateY(-12px) scale(0.88); }
    62%      { transform: translateY(0) scale(1); }
    100%     { transform: translateY(0) scale(1); }
  }

  /* The invitation lives on the play button, not on a label. A "TAP TO PLAY"
     caption in the corner collided with the NEW badge that already sits there,
     and the row does not need words: the pink button is the affordance, so it
     is the thing that should be asking. */
  .theme-row.first-cta .theme-row-play {
    animation: ctaPlay 1.9s ease-in-out infinite;
  }
  @keyframes ctaPlay {
    0%, 100% { transform: scale(1);    box-shadow: 0 0 0 0 rgba(255,107,157,0.45); }
    50%      { transform: scale(1.09); box-shadow: 0 0 0 10px rgba(255,107,157,0); }
  }
  @keyframes ctaPulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(255,107,157,0.00); transform: scale(1); }
    50%      { box-shadow: 0 0 0 5px rgba(255,107,157,0.22); transform: scale(1.015); }
  }

  /* Attract loop on the worlds button until it has been opened once. Slow and
     small on purpose: it should read as "there is something here", not as a
     notification demanding to be cleared. */
  .action-panel.attract { animation: worldsAttract 2.6s ease-in-out infinite; }
  .action-panel.attract .action-icon { animation: worldsIcon 2.6s ease-in-out infinite; }
  /* The ring is always there now, so the attract beat swells it rather than
     fading one in from nothing. */
  @keyframes worldsAttract {
    0%, 72%, 100% { box-shadow: var(--attract-base), 0 0 0 2px rgba(161, 87, 124, 0.13); }
    86%           { box-shadow: var(--hud-shadow), 0 0 0 7px rgba(161, 87, 124, 0.20); }
  }
  /* An unlock is waiting behind this button. Louder than .attract (which only
     says "you have not opened this yet") because this one is time-limited by
     ad inventory, but still ambient — no motion large enough to pull the eye off
     the jar mid-drop. */
  .action-panel.wish-ready {
    border-color: rgba(255,255,255,0.85);
    animation: worldsWish 1.5s ease-in-out infinite;
  }
  /* Still louder than .attract, because this one is time-limited by ad inventory
     — but it earns that by deepening the badge it already has rather than by
     turning it into a different, brighter object. */
  .action-panel.wish-ready .hud-badge {
    background: rgba(161, 87, 124, 0.26);
    color: #7d4160;
    animation: worldsBadge 1.5s ease-in-out infinite;
  }
  @keyframes worldsWish {
    0%, 100% { box-shadow: var(--attract-base), 0 0 0 2px rgba(161, 87, 124, 0.26); }
    50%      { box-shadow: var(--hud-shadow), 0 0 0 8px rgba(161, 87, 124, 0.12); }
  }
  @media (prefers-reduced-motion: reduce) {
    .action-panel.wish-ready,
    .action-panel.wish-ready .hud-badge { animation: none; }
  }

  .action-panel.attract .hud-badge { animation: worldsBadge 2.6s ease-in-out infinite; }
  @keyframes worldsBadge {
    0%, 72%, 100% { transform: scale(1); }
    82%           { transform: scale(1.18); }
    92%           { transform: scale(1.06); }
  }
  @keyframes worldsIcon {
    0%, 72%, 100% { transform: rotate(0deg) scale(1); }
    80%           { transform: rotate(-7deg) scale(1.08); }
    92%           { transform: rotate(5deg)  scale(1.05); }
  }

  /* Every one of these is an attract loop — pure decoration over a message the
     static layout already carries. The hand still points at the button and the
     tile still wears its ring, so nothing is lost by holding them still. */
  @media (prefers-reduced-motion: reduce) {
    .theme-row.first-cta,
    .theme-row.first-cta .theme-row-play,
    .cta-finger,
    .action-panel.attract,
    .action-panel.attract .action-icon,
    .action-panel.attract .hud-badge { animation: none; }
  }

  /* Worlds and Shop sat in a row of four identical glass tiles next to Score,
     Next and Best. Three of those are read-outs and two are controls, and
     nothing told them apart — the eye filed the whole strip as "scoreboard"
     and stopped looking, which is a large part of why only ~20-27% of players
     ever opened the world picker and 6% opened the shop.
     So the two controls now carry a tint, an accent ring and an accent label.
     Same glass language, but they are visibly the only things in the strip
     asking to be pressed. */
  .action-panel {
    cursor: pointer;
    position: relative;
    /* SHARED WITH #shop-btn. Everything specific to the world readout is scoped
       to #home-btn below — putting it here broke Shop on the home screen, which
       has a label and an icon but no badge, so the grid areas left it with an
       empty cell and no card. */
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    gap: 3px;
    padding: 7px 11px 8px;
    min-width: 0;
    font-family: inherit;
    color: var(--ink);
    border-color: rgba(255,255,255,0.58);
    box-shadow: var(--hud-shadow);
    /* The attract/wish rings rebuild box-shadow from scratch, so they need to
       know what they are drawing on top of. */
    --attract-base: var(--hud-shadow);
    transition: transform 0.16s ease, box-shadow 0.2s ease;
  }

  /* ---- WORLDS only ---- */
  #home-btn {
    display: grid;
    grid-template-columns: auto auto;
    /* Label UNDER the pair, not over it: the globe and the figure are the thing
       being read, and the caption belongs beneath what it names. */
    grid-template-areas: 'icon badge' 'label label';
    /* Fixed value row, shared with .next-panel. Aligning only the captions left
       the globe/figure pair and the NEXT piece on centres 13px apart, so the two
       sides read as different heights however level their labels were. One row
       height on both, contents centred in it, puts the VALUES on a line too. */
    grid-template-rows: 38px auto;
    align-items: center; justify-content: center;
    gap: 0;
    padding: 0;
    /* THE SURFACE IS BACK, and the reason it had to come back is worth keeping:
       stripped of its card, this button was rendered in EXACTLY the same visual
       class as the SCORE/BEST readout sitting 42px to its right — a figure over
       a caption, no edge, no ground. Nothing in the game marks read-only text
       and tappable text apart, so players read it as a second scoreboard. The
       only affordance left was a 9px chevron.
       What it gets is not the old 162px slab: a snug glass pill sized to its
       own content, in the HUD's language rather than the powerups' saturated
       one, but carrying the three things a surface needs to say "control" —
       an edge, a lift off the background, and a top highlight. */
    padding: 9px 11px 2px;
    border-radius: 16px;
    background: linear-gradient(180deg, rgba(255,255,255,0.30), rgba(255,255,255,0.17));
    border: 1px solid rgba(255,255,255,0.55);
    backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
    --attract-base:
      inset 0 1px 0 rgba(255,255,255,0.62),
      0 1px 2px rgba(50, 40, 80, 0.10),
      0 5px 14px rgba(50, 40, 80, 0.13);
    box-shadow: var(--attract-base);
    transition: transform 0.16s cubic-bezier(0.34,1.5,0.64,1), box-shadow 0.18s ease, background 0.18s ease;
  }
  /* Nothing says "button" like answering the finger. */
  #home-btn:active {
    transform: scale(0.955);
    background: linear-gradient(180deg, rgba(255,255,255,0.42), rgba(255,255,255,0.26));
    box-shadow: inset 0 2px 4px rgba(50, 40, 80, 0.16), 0 1px 2px rgba(50, 40, 80, 0.10);
    transition-duration: 0.06s;
  }
  #home-btn .label      { grid-area: label; }
  #home-btn .action-icon {
    grid-area: icon;
    /* Smaller and given a real gap. The -3px pull was there to close the space
       to the pill; with the pill gone it dragged the globe into the figures, and
       at 31px it also out-weighed the number it belongs to. */
    width: 25px; height: 25px;
    margin-right: 3px;
  }
  #home-btn .hud-badge   { grid-area: badge; }
  /* The caption style is what made "WORLDS" read as a label about the tile
     rather than an instruction. Same size and tracking as SCORE/BEST so the
     strip stays even, but in the accent at full strength instead of 55% grey. */
  /* NOT var(--accent) — that is redefined per world, so on Aqua the label came
     out teal on a teal background and the button vanished into the scene. The
     picker's play disc already settled this: controls hold one colour across
     all 33 worlds, so they stay findable. Deeper than the tint so it reads
     against it. */
  .action-panel .label {
    /* Still one fixed colour rather than var(--accent) — the original reason
       holds: --accent is redefined per world and on Aqua a teal label on a
       teal-tinted card disappeared. It now sits directly on the world instead of
       on a card, so it carries a soft white lift to hold on the darker ones. */
    color: #a1577c;
    opacity: 1;
    letter-spacing: 0.9px;
    text-shadow: 0 1px 2px rgba(255,255,255,0.75);
  }

  /* Count badge. A number in a pill is the one badge every phone user already
     knows how to read, and "33" is a far stronger reason to tap than any icon:
     it states how much is behind the button. Total worlds, not owned ones —
     the hook is the size of the collection, not the player's progress. */
  .hud-badge {
    position: absolute;
    /* Not the top-right corner: the tile is sized by its own label, so the
       label always fills the content box edge to edge and there is no corner
       to hang in — the badge clipped the "S". The globe is a circle in a
       square box, so its bottom-right corner is empty pixels: the badge sits
       there, touching the icon, covering nothing. */
    /* No longer positioned at all: it sits in the flow beside the globe, so the
       two can never overlap however wide the label makes the card. */
    position: static;
    /* The icon carries a drop-shadow filter, which gives it its own stacking
       context, so it painted over the badge even though the badge is absolute
       and the icon is not. Lift the badge explicitly. */
    z-index: 3;
    /* This is the only container in the component now. */
    min-width: 0; height: auto;
    padding: 3px 15px 4px;
    border-radius: 999px;
    background: linear-gradient(180deg,
      rgba(255,255,255,0.60) 0%, rgba(255,255,255,0.44) 100%);
    border: 1px solid rgba(255,255,255,0.58);
    box-shadow: var(--hud-shadow);
    /* Set into the card, exactly like the counters on the power-up buttons — the
       gradient fill, white ring and coloured drop shadow it had were the same
       three signals that made those read as stickers dropped on top. */
    color: #96506f;
    font-size: 17px; font-weight: 600; line-height: 1.15;
    letter-spacing: -0.2px;
    text-align: center;
    font-variant-numeric: tabular-nums;
    pointer-events: none;
  }
  .action-panel:hover {
    transform: translateY(-2px);
    box-shadow:
      inset 0 -1px 1px rgba(255,255,255,0.85),
      inset 0 1px 0 rgba(255,255,255,0.95),
      0 2px 4px rgba(72, 60, 95, 0.08),
      0 8px 18px rgba(72, 60, 95, 0.13),
      0 0 0 1px rgba(90, 75, 130, 0.07);
  }
  .action-panel:active { transform: translateY(0); }
  .action-panel .action-icon {
    /* Match the 3D Fluent emoji language of the rest of the game (tier
       images, next-fruit preview). A flat stroke SVG sat awkwardly next
       to those vivid 3D heads — this image renders the system actions
       (shop bag, home) in the same dimensional style. */
    display: block;
    width: 26px; height: 26px;
    margin: 0;
    margin: 0;
    filter: drop-shadow(0 2px 4px rgba(60, 50, 90, 0.26));
  }

  /* Diagonal corner ribbon (shop "SALE"). The band is clipped by its OWN
     42px box rather than by overflow:hidden on the button — the button
     holds a 36px emoji that home-active scales to 1.16, and clipping the
     button would shave its edges. The box mirrors the panel's 18px corner
     so the band ends flush with the rounded edge. */
  #shop-btn { position: relative; }
  .corner-ribbon {
    position: absolute; top: 0; left: 0;
    width: 42px; height: 42px;
    overflow: hidden;
    border-top-left-radius: 18px;
    pointer-events: none;
    z-index: 3;
  }
  #shop-btn:not(.has-sale) .corner-ribbon { display: none; }
  /* The label needs no nudging: min-width:70px is only a floor, and on the
     home screen #hud becomes 1fr 1fr 1fr, so this button renders ~130px
     wide. "SHOP" sits centred around x≈100 while the band ends near x≈65 —
     roughly 18px of clearance. An earlier attempt shifted the label down and
     stole the same 6px off the icon's margin to hold the height; that left
     the bag crowding the text, which is the collision it appeared to fix. */
  .corner-ribbon i {
    position: absolute;
    top: 6px; left: -19px;
    width: 62px; padding: 1.5px 0;
    transform: rotate(-45deg);
    background: linear-gradient(135deg, #ff5f88, #ff9163);
    color: #fff; font-style: normal;
    font-size: 7.5px; font-weight: 800; letter-spacing: 0.4px;
    text-align: center;
    box-shadow: 0 1px 4px rgba(175, 40, 80, 0.42);
    /* Nudge, not a loop: two quick beats then a long rest, so the eye is
       caught once in a while instead of being pulled at constantly. This is
       a calm game — a continuously animating tag would fight that. The
       rotate() has to be repeated in every keyframe or the band would
       un-rotate as it animates. */
    animation: sale-nudge 5s ease-in-out infinite;
    will-change: transform;
  }
  @keyframes sale-nudge {
    0%, 86%, 100% { transform: rotate(-45deg) scale(1); }
    90%           { transform: rotate(-45deg) scale(1.09); }
    94%           { transform: rotate(-45deg) scale(0.97); }
    97%           { transform: rotate(-45deg) scale(1.03); }
  }
  @media (prefers-reduced-motion: reduce) {
    .corner-ribbon i { animation: none; }
  }
  #home-btn, #shop-btn { grid-column: 1; grid-row: 1; }

  /* Tier collection strip */
  /* The merge ladder. It was a flat row of eleven small icons, which reads as
     decoration rather than as progress — nothing said how far along you were or
     how far there was to go. It now sits on a rail that FILLS as tiers are
     discovered, and carries a count, so the ladder explains itself at a glance.
     Every size is fluid: the extra height a tall phone has goes into the strip,
     and a short phone gives it back to the jar. */
  #tier-strip {
    /* A little more air above than below: the larger gap separates GAMEPLAY from
       the lower zone, the smaller one holds progression and controls together
       inside it. Even spacing on both sides left the shelf floating between two
       things instead of belonging to one. */
    margin-top: clamp(2px, 0.55vh, 7px);
    display: flex; flex-direction: column; align-items: stretch;
    gap: 0;
    padding: clamp(3px, 0.5vh, 5px) clamp(7px, 2.2vw, 12px);
    /* PROGRESSION SHELF — no container AND no backing of its own. There were two
       wash layers stacked down here, one behind the shelf and one behind the
       controls, which is what read as "background, glow, UI, another glow". The
       icons are colourful 3D objects and the caption carries its own lift, so
       this layer was buying nothing. One grounding for the whole zone now, and it
       lives under the controls. */
    background: none;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }
  /* ── THE LADDER ────────────────────────────────────────────────────────
     Three layers, top to bottom: the emoji, a milestone track, a line of
     text. Each says one thing.

     The caption sits UNDER the emoji, not over them. The next emoji is
     enlarged, and anything drawn above it — a floating pill, a caption — ends
     up underneath it. That collision was the whole problem with the last pass;
     moving the text below removes it rather than negotiating with it.

     There is no NEXT pill any more. The enlarged glowing icon, the open ring
     on the track beneath it, and a caption that names it are already three
     signals for one idea; a fourth was clutter, which is the one thing the
     design was asked not to add. */

  /* Two parts, not one shouted line. "ALMOST THERE" is the state and stays
     quiet; the unlock's NAME is the only thing worth reading, so it takes the
     weight and the colour. As one uppercase 800-weight string at 1.4px tracking
     the whole thing read as a title competing with the panel it sits in. */
  .tier-progress {
    display: flex; align-items: baseline; justify-content: center; gap: 5px;
    /* The one real gap in the strip: everything above it is the collection,
       this is the line talking about it. */
    margin-top: 5px;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    line-height: 1;
    transition: color 0.35s;
  }
  .tier-progress .cap-pre {
    font-size: clamp(8px, 1.05vh, 9.5px);
    font-weight: 500; letter-spacing: 0.9px; text-transform: uppercase;
    color: rgba(70, 58, 92, 0.46);
  }
  .tier-progress .cap-name {
    font-size: clamp(9.5px, 1.25vh, 11.5px);
    font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase;
    color: rgba(70, 58, 92, 0.72);
  }
  /* A lone message (DROP TO BEGIN, ONE MORE MERGE!) has no name to promote, so
     it takes the name's weight itself rather than sitting there whispering. */
  .tier-progress .cap-pre.solo {
    font-size: clamp(8.5px, 1.15vh, 10.5px);
    font-weight: 600; color: rgba(70, 58, 92, 0.58);
  }
  /* Warm only when it is actually true. If it always glows, the glow says
     nothing. */
  .tier-progress.imminent .cap-pre,
  .tier-progress.imminent .cap-name { color: var(--ui-progress-deep); }
  .tier-progress.celebrate .cap-pre,
  .tier-progress.celebrate .cap-name { color: var(--ui-progress-deep); }
  .tier-progress.celebrate { animation: capPop 0.5s ease-out; }
  @keyframes capPop {
    0%   { transform: scale(0.86); opacity: 0; }
    55%  { transform: scale(1.06); opacity: 1; }
    100% { transform: scale(1);    opacity: 1; }
  }

  .tier-row {
    display: flex; align-items: center; justify-content: space-between;
    gap: clamp(1px, 0.5vw, 4px);
    padding-top: 3px;              /* headroom for the enlarged next emoji */
  }

  /* The track. A milestone under every emoji, and the connector between
     them filling as the board works toward the next one — so the row reads as
     a journey with steps rather than one long anonymous bar. */
  .tier-rail {
    position: relative; height: 9px;
    /* Pulled up under the icons it belongs to. */
    margin: -1px 0 0;
  }
  .tier-rail-track, .tier-rail-fill {
    position: absolute; left: 0; top: 50%;
    height: 3px; margin-top: -1.5px; border-radius: 999px;
  }
  .tier-rail-track { right: 0; background: rgba(70, 58, 92, 0.11); }
  /* One warm coral instead of the per-world accent. The old fill ran
     accent2 -> accent, and since --accent is hot pink or red on most worlds the
     track came out as a bright red line cutting across a calm panel. */
  .tier-rail-fill {
    width: 0%;
    overflow: hidden;
    background: linear-gradient(90deg, #e3a893, var(--ui-progress));
    transition: width 0.5s cubic-bezier(0.34,1.3,0.64,1), box-shadow 0.4s;
  }
  .tier-rail.imminent .tier-rail-fill { box-shadow: 0 0 7px rgba(208, 130, 104, 0.55); }

  /* Discovery beat: a light runs along everything earned so far. It fires only
     on the unlock itself, so the track stays still the rest of the time. */
  .tier-rail-fill::after {
    content: ''; position: absolute; inset: 0; border-radius: 999px;
    background: linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent);
    transform: translateX(-100%); opacity: 0;
  }
  .tier-rail.burst .tier-rail-fill::after { animation: railShine 0.75s ease-out; }
  @keyframes railShine {
    0%   { transform: translateX(-100%); opacity: 0.95; }
    100% { transform: translateX(100%);  opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .tier-rail.burst .tier-rail-fill::after { animation: none; }
    .tier-dot.pop { animation: none; }
  }

  .tier-dot {
    position: absolute; top: 50%;
    width: 6px; height: 6px; margin: -3px 0 0 -3px;
    border-radius: 50%;
    background: rgba(70, 58, 92, 0.18);
    transition: background 0.35s, transform 0.35s, box-shadow 0.35s;
  }
  .tier-dot.done {
    background: var(--ui-progress);
    box-shadow: 0 0 0 2px rgba(255,255,255,0.70);
  }
  /* Open ring = the one being worked toward. */
  .tier-dot.next {
    width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px;
    background: #fff;
    box-shadow: 0 0 0 2px var(--ui-progress-deep);
  }
  .tier-dot.pop { animation: dotPop 0.55s cubic-bezier(0.34,1.5,0.64,1); }
  @keyframes dotPop {
    0%   { transform: scale(0.4); }
    55%  { transform: scale(1.9); }
    100% { transform: scale(1); }
  }

  .tier-img {
    position: relative;
    z-index: 1;
    flex: 1 1 0;
    width: auto; height: auto;
    max-width: clamp(25px, 4.1vh, 36px);
    aspect-ratio: 1;
    background-size: contain; background-repeat: no-repeat; background-position: center;
    display: flex; align-items: center; justify-content: center;
    font-size: clamp(16px, 3vh, 27px); line-height: 1;
    /* Softer than the found ones, but still identifiable — these are the prizes
       the player is playing FOR, and a row of unreadable grey blobs is not a
       collection. Contrast between lit and unlit does the work, not erasure. */
    /* Lifted from 0.52/30%: with the shelf's wash removed these sit directly on
       the world, and on the pale ones a row of nine locked icons had faded past
       "not yet earned" into "cannot tell what that is". They are the prizes the
       player is playing for. */
    opacity: 0.62; filter: grayscale(20%);
    transition: opacity 0.4s, filter 0.4s, transform 0.3s;
  }
  .tier-img.unlocked { opacity: 1; filter: none; transform: scale(1.12); }
  .tier-img.just-unlocked { animation: unlockPop 0.5s ease-out; }
  @keyframes unlockPop {
    0%  { transform: scale(1.1); }
    40% { transform: scale(1.5) rotate(8deg); }
    100%{ transform: scale(1.1) rotate(0); }
  }
  /* Expanding accent ring at the moment a tier is discovered — a satisfying
     "unlocked!" pulse on the ladder itself, on top of the scale pop. */
  .tier-img::after {
    content: '';
    position: absolute; inset: -5px;
    border-radius: 50%;
    border: 2px solid rgba(255,255,255,0.95);
    opacity: 0;
    pointer-events: none;
  }
  .tier-img.just-unlocked::after { animation: tierUnlockRing 0.6s ease-out; }
  @keyframes tierUnlockRing {
    0%   { opacity: 0.85; transform: scale(0.5); }
    100% { opacity: 0;    transform: scale(1.5); }
  }
  /* Next-target beacon — the lowest still-locked tier glows + bobs so the
     player always has a visible "merge to unlock THIS next" carrot. Brighter
     than the dim locked siblings so it reads as the current goal. The next
     beacon position is set in JS (updateTierBeacon) on every discovery. */
  .tier-img.next-target {
    opacity: 0.97;
    animation: tierBeacon 1.6s ease-in-out infinite;
    z-index: 2;
    /* Set apart by a gap on its left — the milestone break between what has
       been found and what is still ahead. An arrow would say the same with one
       more object on screen. */
    margin-left: clamp(5px, 1.9vw, 11px);
    margin-right: clamp(3px, 1vw, 6px);
    /* A lift off the glass, not a glow. The bright white one read as a halo
       stuck behind the emoji. */
    filter: drop-shadow(0 2px 5px rgba(60,90,120,0.22));
  }
  /* The size lives in the keyframes: an animated transform beats a declared
     one, so a scale set outside would be discarded the moment the beacon runs. */
  @keyframes tierBeacon {
    0%, 100% { transform: translateY(0)    scale(1.24); }
    50%      { transform: translateY(-3px) scale(1.36); }
  }
  /* The old beacon ring is off. It was drawn at inset:-5px around the icon, and
     once the next emoji started rendering at 1.24-1.40x the ring scaled with it
     into a big white disc. It is also the THIRD marker for one idea — the emoji
     is already enlarged and already has an open dot beneath it. Size and the
     dot carry it; the ring only added a blob. */
  .tier-img.next-target::after {
    animation: none;
    opacity: 0;
  }
  @keyframes beaconHalo {
    0%, 100% { opacity: 0;    transform: scale(0.8); }
    50%      { opacity: 0.55; transform: scale(1.25); }
  }
  @media (prefers-reduced-motion: reduce) {
    .tier-img.next-target,
    .tier-img.next-target::after,
    .tier-img.just-unlocked::after { animation: none; }
    .tier-img.next-target { opacity: 0.8; }
  }

  /* ===== HUD SHAPE LANGUAGE =====================================
     Three shapes were built and compared at phone size: discs for the side
     readouts, a single shelf holding all three, and this — no containers at all
     except the cloud. The shelf was a large rounded rectangle by another name;
     the discs cost width and cropped NEXT. What is left is one designed shape
     carrying the primary reading, and two readouts sitting directly on the
     world. Nothing here is a card.
     ============================================================== */

  /* NEXT loses its card too, which is what finally makes the two side readouts
     one pair: same treatment, same weight, sitting either side of the cloud.
     With WORLD framed and NEXT not, they read as two unrelated things. */
  .next-panel {
    background: none;
    border: 0;
    box-shadow: none;
    padding: 0 6px;
    min-width: 70px;
  }
  .next-panel .label {
    color: #ffffff;
    font-weight: 600;
    /* Matches #home-btn .label exactly. The two captions are meant to read as one
       line across the top of the screen, so any difference in their own box
       shows up as a step. */
    padding: 1px 0 0;
    line-height: 1.2;
    text-shadow: 0 1px 3px rgba(52, 40, 72, 0.42), 0 0 7px rgba(52, 40, 72, 0.20);
  }
  .next-img {
    width: 46px; height: 46px;
    margin: 0 0 2px;
    /* Nothing behind it any more, so the shadow is what lifts it off the world. */
    filter: drop-shadow(0 3px 7px rgba(52, 40, 72, 0.34));
  }

  /* WORLD — the number is the value, not a badge, so it loses the pill too.
     Globe and figure sit as one pair with a soft lift instead of a container. */
  /* White, and bold enough to hold its own without a card behind it. The muted
     rose was tuned for a pill that no longer exists — on the world itself it
     had nowhere near enough separation, on the pale worlds worst of all.
     The lift underneath is a soft DARK halo, not a white one: white type needs
     the ground darkened behind it, and a halo (wide, no offset) does that
     without ever reading as an outline. */
  .hud-badge {
    background: none;
    border: 0;
    box-shadow: none;
    padding: 0 1px;
    color: #ffffff;
    font-weight: 700;
    text-shadow: 0 1px 3px rgba(52, 40, 72, 0.45), 0 0 8px rgba(52, 40, 72, 0.22);
  }
  /* A chevron is the cheapest honest "this opens something" there is — no card,
     no button chrome, one glyph. Without it the world readout looked exactly like
     the NEXT readout, which is not tappable at all. */
  #home-btn .hud-badge::after {
    content: '\203A';
    display: inline-block;      /* so the idle nudge below can transform it */
    margin-left: 5px;
    font-weight: 700;
    /* Was 0.72 and doing the whole job of saying "this opens something". The
       pill carries that now, so this can simply be legible on all 33 worlds. */
    opacity: 0.95;
    text-shadow: 0 1px 2px rgba(60, 45, 90, 0.30);
  }

  /* Idle tell. Flat for the first two thirds of the cycle and then one small
     beat — a control that moves CONSTANTLY reads as an animation, one that
     rests and then nudges reads as something waiting to be pressed. The globe
     breathes and the chevron leans the way it would take you.
     ~4.4s so it is never the thing you notice while aiming a drop. */
  @keyframes worldIdleGlobe {
    0%, 64%, 100% { transform: translateY(0) scale(1); }
    74%           { transform: translateY(-1.5px) scale(1.07); }
    86%           { transform: translateY(0) scale(1); }
  }
  @keyframes worldIdleChevron {
    0%, 64%, 100% { transform: translateX(0);     opacity: 0.72; }
    76%           { transform: translateX(2.5px); opacity: 1; }
    88%           { transform: translateX(0);     opacity: 0.72; }
  }
  #home-btn .action-icon      { animation: worldIdleGlobe   4.4s ease-in-out infinite; }
  #home-btn .hud-badge::after { animation: worldIdleChevron 4.4s ease-in-out infinite; }
  @media (prefers-reduced-motion: reduce) {
    #home-btn .action-icon, #home-btn .hud-badge::after { animation: none; }
  }
  #home-btn .label {
    color: #ffffff;
    font-weight: 600;
    /* Centred under the pair, not under the figures alone. */
    justify-self: center;
    padding: 1px 0 0;
    line-height: 1.2;
    text-shadow: 0 1px 3px rgba(52, 40, 72, 0.42), 0 0 7px rgba(52, 40, 72, 0.20);
  }
  #home-btn .action-icon { filter: drop-shadow(0 2px 4px rgba(60, 50, 90, 0.28)); }

  /* Overlays */
  .overlay {
    position: absolute; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    text-align: center; padding: 24px;
    /* Neutral white-glass so the per-theme bg gradient breathes through.
       Pink-tinted wash used to clash with cool themes (Aqua/Forest/Cosmos). */
    background: rgba(255, 255, 255, 0.55);
    backdrop-filter: blur(12px) saturate(130%);
    -webkit-backdrop-filter: blur(12px) saturate(130%);
    z-index: 10;
    animation: fadeIn 0.45s ease-out;
  }
  @keyframes fadeIn {
    from { opacity: 0; transform: scale(0.96); }
    to   { opacity: 1; transform: scale(1); }
  }

  /* Offline card. No dismiss button on purpose: there is nothing useful for a
     tap to do, and a button the player can press without anything changing
     reads as broken. It clears itself the moment the connection is back. */
  #net-modal { z-index: 30; }
  .net-icon { width: 84px; height: 84px; margin-bottom: 14px; }
  .net-title {
    font-family: 'Fredoka', sans-serif;
    font-size: 26px; font-weight: 700; color: var(--ink, #3a2b4f);
    letter-spacing: -0.3px; margin-bottom: 8px;
  }
  .net-desc {
    font-size: 14px; line-height: 1.55; opacity: 0.7;
    color: var(--ink, #3a2b4f); margin-bottom: 20px;
  }
  .net-spinner { width: 34px; height: 34px; border-width: 4px; }
  .ring {
    width: 56px; height: 56px;
    border: 5px solid rgba(255,107,157,0.18);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.85s linear infinite;
    margin-bottom: 16px;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  #loader-progress { font-size: 13px; opacity: 0.6; font-variant-numeric: tabular-nums; }

  #theme-loader { z-index: 40; }
  #theme-loader-title { font-size: 15px; font-weight: 700; letter-spacing: 0.2px; }
  #theme-loader-progress {
    font-size: 13px; opacity: 0.6; font-variant-numeric: tabular-nums; margin-top: 4px;
  }
  #theme-loader-note { font-size: 12px; opacity: 0.45; margin-top: 10px; }

  /* Title screen — title at the top, scrolling theme list in the middle
     (height-capped so the play button + No Ads pill always stay visible),
     play + shop pinned to the bottom via margin-top:auto. */
  /* No panel behind the list. Each row already carries its own world colour, so
     a frosted sheet under them added a third surface — page, panel, row — and
     the rows read as strips trapped in a box. They sit on the page directly. */
  #title-screen {
    justify-content: flex-start;
    padding: 8px 14px 12px;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  /* CTA sits in its own footer zone — full panel width, clear gap above the
     grid, grounded instead of floating over the cards. */
  #title-screen #start-btn {
    margin-top: 14px;
    width: 100%;
    flex-shrink: 0;
    display: flex;
    justify-content: center;
  }
  .title {
    font-size: 28px; font-weight: 900; letter-spacing: -0.6px;
    background: linear-gradient(95deg, #ff6b9d 0%, #ff9a6b 50%, #6bafff 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    line-height: 1.05; margin-bottom: 10px;
  }
  /* Sub-title progress strip — soft tag under the wordmark. */
  .theme-picker-label {
    font-size: 11px; font-weight: 800;
    letter-spacing: 1.8px; text-transform: uppercase;
    opacity: 0.65;
    margin: 0 0 8px;
    transition: opacity 0.3s ease;
  }
  .theme-picker-label .progress-count { color: var(--accent); opacity: 0.95; }
  .theme-picker-label.bump { animation: labelBump 0.5s ease-out; }
  @keyframes labelBump {
    0%   { transform: scale(1); }
    50%  { transform: scale(1.08); }
    100% { transform: scale(1); }
  }

  /* Vertical theme list — bounded height + internal scroll so the bottom
     CTAs (Play, No Ads) are never clipped. Soft top/bottom mask makes the
     edge scroll feel gradient rather than abrupt. */
  #theme-picker {
    display: grid;
    /* One column. A world now carries a name, a goal and its record — that does
       not fit in half a phone's width, and a list is the right shape for reading
       rows anyway. Five rich rows beat eight tiles you cannot tell apart. */
    grid-template-columns: 1fr;
    gap: 13px;
    align-content: start;
    width: 100%;
    /* flex: 1 1 0 → picker grows to fill available vertical space but
       shrinks below max-height on short devices, keeping Tap-to-Play
       visible on iPhone SE / older Androids. */
    flex: 1 1 auto;
    width: 100%;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    padding: 2px 4px 16px;
    -webkit-overflow-scrolling: touch;
    /* A permanent track down the right edge of a world gallery is chrome
       competing with the artwork. The list moving is the scroll cue, and the
       peek row at the bottom already says there is more. */
    scrollbar-width: none;
    /* Soft top fade + a slim bottom fade — just enough to soften the cut so a
       partial "peek" row reads as scrollable, WITHOUT eating its name label. */
    mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 10px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 10px), transparent 100%);
  }
  #theme-picker::-webkit-scrollbar { width: 0; height: 0; }
  /* At the very bottom of the list there's nothing more to hint — drop the
     bottom fade so the last row stays fully crisp. */
  #theme-picker.at-bottom {
    mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 100%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 100%);
  }
  .picker-wrap {
    position: relative;
    flex: 1 1 0;
    min-height: 0;
    width: 100%;
    display: flex;
  }

  /* Tier-strip is the in-game collection preview — hide it on the home screen
     so the world grid is the clean focus. It returns the moment a game starts. */
  /* The home screen holds a static backdrop instead of the selected world's
     gradient. Thirty-three coloured rows have to sit on it, and a backdrop that
     changed with the selection meant a row was legible against some palettes and
     lost against others — Aqua's blue on a blue page, Bakery's cream on cream.
     A cool near-neutral is the one surface every pastel in the set reads on. */
  /* ── Home screen surface ──────────────────────────────────────────────────
     Three moves, in order of what the eye does:
     1. a fixed candy gradient, so worlds are judged against one constant light
     2. two blurred orbs pinned to complementary hues (the game's own idiom) —
        without them the gradient reads as a flat printed sheet
     3. a soft vignette, which is what actually makes the middle feel lit and
        lets the rows sit IN the screen rather than ON it.
     ------------------------------------------------------------------------ */
  /* Orbs dimmed hard. With colour on every card the backdrop's only job is to
     hold them apart, and two bright blooms behind translucent tiles was one
     layer of colour too many. */
  body.home-active::before { background: #ffb2d0; opacity: 0.22; }
  body.home-active::after  { background: #a9c1ff; opacity: 0.22; }
  /* Rides on <html> for the same reason the base wash does — see the note there.
     The class is mirrored onto documentElement by goHome()/startGame(); every
     other `body.home-active` rule below still keys off <body>. */
  html.home-active {
    background:
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)' opacity='0.05'/%3E%3C/svg%3E") repeat,
      radial-gradient(120% 80% at 50% 30%, rgba(255,255,255,0.42) 0%, rgba(255,255,255,0) 52%, rgba(150,88,96,0.09) 100%),
      linear-gradient(168deg, #f9cfda 0%, #fadcc6 46%, #f7dcd5 100%);
    background-size: 160px 160px, cover, cover;
    background-color: #fadcc6;
  }
  /* The backdrop is mid-tone pastel, not dark: white header text washed out on
     the peach band. Deep plum ink holds against every stop of the gradient. */
  /* White on a mid-tone pastel is the one combination that cannot win — the
     peach band sits at almost the same luminance. Deep berry ink reads on every
     stop of pink → peach → periwinkle, and still feels like this game. */
  body.home-active .title {
    font-size: 28px;
    letter-spacing: -0.7px;
    /* Berry-to-violet, kept a couple of steps brighter than the ink used for
       body text — the wordmark should feel like candy, not like a heading. The
       white halo does the separating, so the letters themselves need not go dark
       to stay readable on the peach band. */
    background: linear-gradient(96deg, #e5487f 0%, #f2735c 46%, #7b6be0 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    filter:
      drop-shadow(0 0 12px rgba(255,255,255,0.95))
      drop-shadow(0 2px 4px rgba(255,255,255,1));
    margin-bottom: 5px;
  }
  body.home-active .theme-picker-label { color: rgba(90, 48, 92, 0.80); }
  body.home-active .theme-picker-label .progress-count { color: #8e2f63; }
  body.home-active .worlds-next { color: rgba(90, 48, 92, 0.72); }
  body.home-active .worlds-next-name { color: #8e2f63; }
  body.home-active .worlds-progress { background: rgba(255,255,255,0.50); }

  /* The outer frame added a border with nothing on the other side of it. */
  body.home-active #wrap { border: none; box-shadow: none; background: none; }

  /* BEST is a readout, not a control — it was sitting at the same weight as the
     two things you can actually press. Shop and Next get their icons back. */
  /* The header tiles were flat while everything below them had become glass —
     two design languages on one screen. Same blur, border, radius and shadow. */
  body.home-active #hud .panel {
    background:
      linear-gradient(to bottom, rgba(255,255,255,0.40) 0%, transparent 44%),
      rgba(255,255,255,0.88);
    /* Deliberately NOT backdrop-filtered. At 0.88 opacity only a tenth of the
       backdrop shows through, so a blur is invisible — but it still costs a full
       GPU pass per surface, and five rows plus three header tiles means eight of
       them on one screen. On the Xiaomi/Realme/Tecno hardware most of these
       players are on, that is a frame budget spent on nothing. */
    /* .panel's own blur is switched off here for the same reason. */
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border: 1.5px solid rgba(255,255,255,0.78);
    border-radius: 20px;
    box-shadow:
      0 10px 22px rgba(88,48,112,0.13),
      0 2px 5px rgba(88,48,112,0.07),
      inset 0 1.5px 2px rgba(255,255,255,0.95);
  }
  body.home-active .score-best { opacity: 0.68; }
  /* Sized, not transformed. scale() does not reserve layout space, so the bag
     grew past the bottom of its own pill. (.next-img was the other half of this
     rule and is hidden on home now.) */
  body.home-active #shop-btn .action-icon { width: 30px; height: 30px; }

  body.home-active #tier-strip { display: none; }

  /* SCORE + NEXT are live-gameplay tiles — meaningless on the idle home screen.
     Hide them there, leaving a clean Shop | Best header; both return the moment
     a game starts (home-active is removed in resetGame). */
  /* Home shows Shop · Next · Best as three even tiles — only the idle "Score 0"
     is hidden (meaningless before a game). Three tiles fill the row cleanly,
     instead of two sparse ones. Next previews the selected world's piece. */
  body.home-active .score-panel { display: none; }
  /* NEXT previews the piece you are about to drop. On the idle home screen
     there is no run to drop it into, so it was a live-gameplay readout sitting
     on a menu. */
  body.home-active .next-panel { display: none; }
  /* #hud stopped being a grid in the HUD rebuild, so this button lost the 1fr
     third that used to give it its width and collapsed onto its own content —
     which put "SHOP" straight under the 42px SALE band and clipped the S.
     A floor wide enough to clear the band restores it under flex. */
  body.home-active #shop-btn { min-width: 112px; }

  /* Home doesn't run the aspect-locked playfield, so let the HUD + panel fill
     the viewport height — no big top/bottom dead-space, and it auto-adapts to
     any device aspect ratio. The grid simply shows more worlds on taller
     screens. (The game screen keeps the strict 420:640 lock.) */
  body.home-active #wrap {
    width: min(440px, calc(100vw - 16px));
    /* Fill most of the height but leave comfortable breathing room top + bottom
       (body centres the wrap, so this slack splits evenly). */
    height: calc(100dvh - 64px);
    max-height: 840px;
  }
  /* #stage is the jar. In game that frosted, heavily-shadowed panel IS the play
     field; on the home screen it was still being drawn, so the world list sat
     inside a giant glass box it had nothing to do with. Strip it back to a bare
     container here — the rows carry their own surfaces. */
  body.home-active #stage {
    aspect-ratio: auto;
    flex: 1 1 auto;
    min-height: 0;
    background: none;
    box-shadow: none;
    border: none;
    border-radius: 0;
    overflow: visible;
  }
  /* ::before is the jar's glass highlight, ::after its vertical shine — both
     only make sense on a jar, and both were still painting over the list. */
  body.home-active #stage::before,
  body.home-active #stage::after { display: none; }
  #theme-picker::-webkit-scrollbar { width: 5px; }
  #theme-picker::-webkit-scrollbar-thumb {
    background: rgba(120,80,140,0.22);
    border-radius: 3px;
  }
  #theme-picker::-webkit-scrollbar-track { background: transparent; }

  /* Row card — taller + more padded for mobile-feel. Both unlocked and
     locked rows render identically (no AD pill, no greyscale). The unlock
     mechanism lives on the Tap-to-Play button's [AD] tag instead, so the
     list itself stays clean and aspirational. */
  .theme-row {
    display: grid;
    grid-template-columns: auto 1fr auto;
    align-items: center;
    gap: 14px;
    /* Symmetric side padding; vertical padding sized so the 90px artwork clears
       it. The row was pinned to 92px while holding 101px of art, and both the
       jar and the stats line were being sliced off by overflow:hidden. */
    padding: 11px 14px;
    min-height: 112px;
    /* The row IS the world's swatch: its own gradient, strong enough to tell
       worlds apart at a glance, still pale enough to read text on. */
    /* Against a fixed near-neutral page the wash can carry more of the world's
       colour than it could over a matching gradient — that contrast is the only
       thing telling one row from another now. */
    background:
      linear-gradient(to bottom, rgba(255,255,255,0.34) 0%, transparent 38%),
      linear-gradient(120deg, var(--card-wash-1, transparent) 0%, var(--card-wash-2, transparent) 100%),
      rgba(255,255,255,0.80);
    border: 2px solid transparent;
    border-radius: 20px;
    cursor: pointer;
    border-width: 1.5px;
    border-color: rgba(255,255,255,0.78);
    /* Three shadow ranges = three distances: a wide soft one for the float, a
       mid one for the body, a tight one for contact. One flat shadow is what
       made everything sit on the same plane. */
    box-shadow:
      0 18px 34px rgba(88,48,112,0.11),
      0 7px 14px rgba(88,48,112,0.09),
      0 2px 4px rgba(88,48,112,0.06),
      inset 0 1.5px 2px rgba(255,255,255,0.95);
    transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease;
    position: relative;
    overflow: hidden;
    text-align: left;
  }
  .theme-row:active { transform: scale(0.985); }

  .theme-row-art {
    width: 90px; height: 90px;
    flex-shrink: 0;
    display: flex; align-items: center; justify-content: center;
    font-size: 38px; line-height: 1;
  }
  .theme-row-art img {
    width: 100%; height: 100%; object-fit: contain; display: block;
    /* The art is the only real colour in the row now, so it earns a nudge of
       contrast and its own shadow — it should sit above the card, not in it. */
    filter:
      saturate(1.12) contrast(1.06)
      drop-shadow(0 4px 7px rgba(80,50,100,0.16));
  }
  .theme-row-art.is-fallback { filter: drop-shadow(0 4px 7px rgba(40,60,90,0.24)); }

  .theme-row-body { min-width: 0; overflow: hidden; display: flex; flex-direction: column; gap: 5px; align-items: flex-start; }

  /* The world's name on its own frosted chip, sized to the word rather than
     stretched across the row — the full-width gradient bar it replaced read as a
     button and shouted down everything beside it. The chip is glass tinted by
     the world it names, with the same accent as its ink, so each label belongs
     to its world without having to be loud about it. */
  .theme-row-name {
    align-self: flex-start;
    max-width: 100%;
    /* Fredoka at 600, not 700 — the heavier cut reads as a children's app, and
       this screen is meant to feel like Alto or Monument Valley: rounded, but
       quiet. Ink lifted off near-black so the word sits IN the card rather than
       being stamped onto it, and the shadow is gone entirely: a shadow made it a
       button, and there is already exactly one button per row. */
    /* Jakarta, not Fredoka. Fredoka's roundness is the game's logo voice — on a
       list of 33 items every row started looking like a wordmark. Jakarta is
       still soft but reads as a label, which is what this is.
       Fixed min-width so Aqua, Critters and Faces all sit on the same footprint:
       three chip widths in a column was the loudest inconsistency left. */
    font-family: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif;
    font-size: 19px; font-weight: 700;
    letter-spacing: -0.35px;
    line-height: 1.05;
    color: #574263;
    /* Sized to its word. A fixed 124px looked tidier in a mockup but on a phone
       it either truncated the long names or pushed the badge out of the row —
       and a world called "Hallowee…" is worse than an uneven column. */
    min-width: 0;
    padding: 6px 14px 7px;
    border-radius: 11px;
    text-align: center;
    background: linear-gradient(to bottom, rgba(255,255,255,0.82) 0%, rgba(255,255,255,0.60) 100%);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.9);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }

  .theme-row-goal {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 14.5px; font-weight: 600;
    padding-left: 4px;
    color: rgba(48, 32, 62, 0.80);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .theme-row-goal.done { color: #2f9e6b; }

  .theme-row-meta {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 12px; font-weight: 500;
    padding-left: 4px;
    color: rgba(48, 32, 62, 0.46);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* Progress variant: a hairline bar with the count riding beside it. Kept to
     4px and the world's own accent so it registers as a fill level, not as a
     third control on a row that already has a button. */
  .theme-row-meta.is-progress {
    display: flex; align-items: center; gap: 8px;
    width: 100%; padding-left: 4px; padding-top: 2px;
    overflow: visible;
  }
  .theme-row-bar {
    flex: 1 1 auto;
    min-width: 0; max-width: 132px;
    height: 4px;
    border-radius: 999px;
    background: rgba(70, 48, 88, 0.13);
    overflow: hidden;
  }
  .theme-row-bar-fill {
    height: 100%;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--chip-c1, #ff85ac), var(--chip-c2, #ffa585));
    box-shadow: 0 0 5px rgba(255,140,175,0.35);
    transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .theme-row-count {
    flex: 0 0 auto;
    font-size: 11.5px; font-weight: 600;
    color: rgba(48, 32, 62, 0.50);
    font-variant-numeric: tabular-nums;
  }
  /* The number you have is the part that matters; the total is context. */
  .theme-row-count b { font-weight: 800; color: rgba(48, 32, 62, 0.78); }


  /* The play affordance. A row was already tappable anywhere, but with no visible
     control the right half of every row read as dead space and nothing said what
     a tap would do. A disc in the world's own accent, and on a locked world the
     same disc wearing the ad price — one object, one message. */
  .theme-row-play {
    position: relative;
    width: 52px; height: 52px;
    flex-shrink: 0;
    border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    /* One colour for every world. A play button that changed hue per row stopped
       reading as "the button" and became more card decoration — the control the
       whole screen exists to serve has to look the same everywhere. */
    background: linear-gradient(150deg, #ff85ac 0%, #ffa585 100%);
    box-shadow:
      0 6px 13px rgba(255,120,160,0.25),
      0 2px 4px rgba(90,40,90,0.12),
      inset 0 1.5px 0 rgba(255,255,255,0.62),
      inset 0 -3px 6px rgba(190,80,120,0.11);
    transition: transform 0.15s ease;
  }
  .theme-row:active .theme-row-play { transform: scale(0.92); }
  .play-tri {
    width: 0; height: 0;
    margin-left: 4px;
    border-left: 15px solid #fff;
    border-top: 10px solid transparent;
    border-bottom: 10px solid transparent;
    filter: drop-shadow(0 1px 1px rgba(0,0,0,0.16));
  }
  /* Ad-gated worlds carry the cost on the button, tucked under it like a price
     tag — visible before the tap, without becoming a second call to action. */
  .theme-row-play.is-ad { margin-bottom: 9px; }
  .play-ad {
    position: absolute;
    bottom: -9px; left: 50%; transform: translateX(-50%);
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 8px; font-weight: 800; letter-spacing: 0.6px;
    color: #fff;
    padding: 2px 7px;
    border-radius: 999px;
    background: rgba(60, 38, 74, 0.82);
    box-shadow: 0 2px 5px rgba(60,38,74,0.30);
  }

  /* ── An unlock we owe the player ──────────────────────────────────────────
     They tapped this world, we had no ad, and we promised to come back to it.
     That promise used to live in a 1.5s toast: 220 refusals produced 31 nudges
     and 1 tap. It now lives on the row they tapped, so it is still there when
     they next open the picker — whenever that is.
     Two states, deliberately different in weight. WAITING is almost silent: it
     is not an offer yet and must not read as a broken button. READY is the
     offer, and gets the accent the picker already uses for "press this". */
  .theme-row.wish-waiting { position: relative; }
  .theme-row.wish-waiting .play-ad {
    background: rgba(60, 38, 74, 0.55);
  }
  /* A slow breath, not a spinner — "we are still holding this for you", without
     claiming anything is loading right now. */
  .theme-row.wish-waiting .theme-row-play {
    animation: wishWaiting 2.8s ease-in-out infinite;
  }
  @keyframes wishWaiting {
    0%, 100% { opacity: 0.72; }
    50%      { opacity: 1; }
  }

  .theme-row.wish-ready {
    box-shadow: 0 0 0 2px rgba(255,107,157,0.55), 0 8px 20px rgba(255,107,157,0.20);
  }
  .theme-row.wish-ready .play-ad {
    background: linear-gradient(150deg, #ff85ac 0%, #ff9f7e 100%);
    box-shadow: 0 2px 6px rgba(220,90,140,0.40);
  }
  .theme-row.wish-ready .theme-row-play {
    animation: wishReady 1.6s ease-in-out infinite;
  }
  @keyframes wishReady {
    0%, 100% { transform: scale(1);    box-shadow: 0 0 0 0 rgba(255,107,157,0.45); }
    50%      { transform: scale(1.07); box-shadow: 0 0 0 9px rgba(255,107,157,0); }
  }
  @media (prefers-reduced-motion: reduce) {
    .theme-row.wish-waiting .theme-row-play,
    .theme-row.wish-ready .theme-row-play { animation: none; }
  }

  .theme-row-head {
    display: flex; align-items: center; gap: 11px;
    min-width: 0; max-width: 100%;
    overflow: hidden;
  }
  /* A small pill beside the name. Not a corner ribbon: that corner belongs to
     the play button, and the two were slicing through each other. */
  .theme-row-tag {
    position: absolute;
    top: 9px; right: 12px;
    z-index: 2;
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 7.5px; font-weight: 800; letter-spacing: 0.35px;
    padding: 2.5px 6.5px;
    border-radius: 7px;
    white-space: nowrap;
    color: #fff;
    box-shadow: none;
  }
  /* One colour per kind, so a badge is readable at a glance instead of needing to
     be spelled out — but all six drawn from the same soft band (mid-light, low
     saturation, flat) so the column still reads as one family and none of them
     out-shouts the play button. Colour never depends on locked state: a badge
     describes the world, not whether it has been paid for. */
  .tag-done     { background: #6fbb9b; }   /* earned      — green   */
  .tag-most     { background: #ec8fae; }   /* standout    — pink    */
  .tag-new      { background: #6cc4c0; }   /* fresh       — teal    */
  .tag-popular  { background: #79a8d8; }   /* crowd       — blue    */
  .tag-premium  { background: #a691cf; }   /* elevated    — purple  */
  .tag-seasonal { background: #dfa96a; }   /* time-bound  — amber   */
  .tag-free     { background: #8fc47a; }   /* no cost     — leaf    */
  .tag-ready    { background: #e2b24e; }   /* earned      — gold    */

  /* The world you last played — a ring, nothing louder. There is no longer a
     "selected then press play" step, so selection is only a reminder. */
  /* No selected state. Selection was a leftover from when you picked a world
     and then pressed Play; a row now opens on tap, so highlighting one of them
     only said "this is where you were", which nobody asked. */

  /* Locked rows keep full colour — a greyed row is not something anyone
     reaches for. The lock chip and the "Free with ad" line carry the state. */
  .theme-row.locked .theme-row-art { opacity: 0.92; }
  /* Corner lock chip — glanceable "locked" cue, subtle frosted disc. */
  .theme-row-lock {
    position: absolute;
    z-index: 2;
    top: 8px; right: 8px;
    width: 22px; height: 22px;
    display: flex; align-items: center; justify-content: center;
    border-radius: 50%;
    /* Soft amber "premium reward" cue — warm, not metallic, so it hints value
       without fighting the pink unlock pill. */
    background: rgba(255, 198, 90, 0.24);
    color: #b57e1e;
    box-shadow: inset 0 0 0 1px rgba(200, 150, 40, 0.28);
    backdrop-filter: blur(3px);
    -webkit-backdrop-filter: blur(3px);
    z-index: 2;
  }
  .theme-row-lock svg { width: 12px; height: 12px; display: block; }
  /* "Watch to unlock" CTA pill — the offer, in a consistent brand gradient
     (learnable: pink pill = free unlock) so it reads as a call-to-action. */
  .theme-row-status {
    font-size: 16px; line-height: 1;
    display: inline-flex; align-items: center; justify-content: center;
    flex-shrink: 0;
    min-width: 22px;
    color: var(--ink-soft);
  }
  .theme-row.selected .theme-row-status {
    color: var(--accent); font-weight: 900; font-size: 18px;
  }

  /* Favorite heart — appears in each row's right slot but ONLY when the
     row is selected (so you can tap it) OR already favorited (so the fav
     state is visible). Otherwise hidden — keeps the unselected list clean. */
  .theme-row-star {
    position: absolute;
    top: 6px; right: 6px;
    width: 26px; height: 26px;
    border: none;
    background: rgba(255,255,255,0.55);
    border-radius: 50%;
    cursor: pointer;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: rgba(120, 80, 140, 0.34);
    flex-shrink: 0;
    visibility: hidden;
    opacity: 0;
    transition: transform 0.15s ease, color 0.2s ease, filter 0.2s ease, opacity 0.2s ease;
  }
  .theme-row.selected .theme-row-star,
  .theme-row-star.on {
    visibility: visible;
    opacity: 1;
  }
  .theme-row-star:hover { transform: scale(1.12); color: rgba(232, 58, 106, 0.7); }
  .theme-row-star:active { transform: scale(0.92); }
  .theme-row-star.on {
    color: #e83a6a;
    filter: drop-shadow(0 1px 4px rgba(232, 58, 106, 0.45));
  }
  .theme-row-star.locked-hide { display: none; }
  .theme-row-star svg { width: 19px; height: 19px; display: block; }
  .theme-row-star.pop { animation: heartPop 0.36s cubic-bezier(0.35, 1.55, 0.5, 1); }
  @keyframes heartPop {
    0%   { transform: scale(1); }
    45%  { transform: scale(1.5); }
    100% { transform: scale(1); }
  }

  /* ── Collection progress bar ──────────────────────────────────────────
     Thin accent-gradient bar under the WORLDS · n/33 label; fills as the
     player unlocks more worlds (completionist nudge). */

  /* Collection progress bar under the WORLDS · n/33 label. */
  /* One named target under the bar. Quiet by design — it should read as a nudge
     the eye picks up on the way to Play, not another thing shouting for a tap. */
  .worlds-next {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 12px; font-weight: 700;
    letter-spacing: 0.2px;
    color: rgba(90, 70, 105, 0.62);
    margin: 0 auto 6px;
    text-align: center;
  }
  .worlds-next-name { color: #8e2f63; font-weight: 800; }
  .worlds-pct { color: #8e2f63; font-weight: 800; }

  .worlds-progress {
    width: 67%; max-width: 230px;
    height: 8px; border-radius: 100px;
    background: rgba(120, 80, 140, 0.14);
    box-shadow: inset 0 1px 2px rgba(120, 80, 140, 0.16);
    margin: 7px auto 9px;
    position: relative;
    /* visible so the glowing head can spill a touch past the fill edge */
    overflow: visible;
    flex-shrink: 0;
  }
  .worlds-progress-fill {
    position: relative;
    height: 100%; width: 0;
    min-width: 9px;
    /* Glossy candy fill: a bright top highlight over the vivid accent gradient,
       with a tight drop + inner base-shade. No soft outer glow — that haze was
       what made the fill read as blurry/washed out. */
    background:
      linear-gradient(180deg, rgba(255,255,255,0.42) 0%, rgba(255,255,255,0) 52%),
      linear-gradient(90deg, #ff9ab8, #ffb59a);
    border-radius: 100px;
    /* Glow on the fill only — the track stays flat, so the coloured part reads
       as lit rather than the whole bar looking hazy. */
    box-shadow:
      0 0 9px rgba(255,140,175,0.55),
      0 1px 2px rgba(120,80,140,0.20),
      inset 0 -1px 2px rgba(0,0,0,0.05);
    transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
  }
  /* Glowing head at the tip of the fill — a little bead of light so the
     collection meter feels alive and rewarding as worlds fill in. */
  .worlds-progress-fill::after {
    content: '';
    position: absolute;
    right: -1px; top: 50%;
    width: 9px; height: 9px;
    transform: translateY(-50%);
    border-radius: 50%;
    background: radial-gradient(circle, #fff 0%, var(--accent-2, #ff9a6b) 58%, transparent 74%);
    box-shadow: 0 0 5px var(--accent-2, #ff9a6b);
  }
  @media (prefers-reduced-motion: reduce) {
    .worlds-progress-fill { transition: none; }
  }

  /* Narrow phones (360-400px) — the row's fixed parts eat the text column, so
     give a little back rather than letting the chip truncate to nothing. */
  @media (max-width: 400px) {
    .theme-row { gap: 11px; padding: 11px 11px; }
    .theme-row-art { width: 82px; height: 82px; }
    .theme-row-play { width: 48px; height: 48px; }
    .play-tri { border-left-width: 14px; border-top-width: 9px; border-bottom-width: 9px; }
    .theme-row-name { font-size: 18px; padding: 5px 12px 6px; }
    .theme-row-tag { top: 8px; right: 10px; }
    /* Disc and padding both shrink here, so the hand has to follow or it drifts
       off-centre: right = row padding + disc/2 - hand/2 = 11 + 24 - 18. */
    .cta-finger { width: 36px; height: 36px; right: 17px; }
  }

  /* Toast — bottom-center floating pill. Fades in/up briefly. */
  /* Anchored in JS to the button it names. Dark like the toast so it reads as
     the same voice, but it points. */
  #coach-tip {
    position: fixed;
    z-index: 210;
    max-width: 250px;
    padding: 8px 14px;
    border-radius: 14px;
    background: rgba(20, 14, 30, 0.92);
    color: #fff;
    font-family: 'Fredoka', -apple-system, "SF Pro Display", system-ui, sans-serif;
    font-size: 12.5px; font-weight: 600; line-height: 1.35;
    letter-spacing: 0.2px; text-align: center;
    box-shadow: 0 8px 22px rgba(20, 14, 30, 0.40);
    pointer-events: none;
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 0.28s ease, transform 0.28s ease;
  }
  #coach-tip.show { opacity: 1; transform: translateY(0); }
  #coach-tip::after {
    content: '';
    position: absolute;
    bottom: -6px; left: var(--arrow-x, 50%); margin-left: -7px;
    border-left: 7px solid transparent;
    border-right: 7px solid transparent;
    border-top: 7px solid rgba(20, 14, 30, 0.92);
  }
  /* The pointed-at button breathes so the tip and the target read as one thing. */
  .powerup-btn.coached {
    animation: coachPulse 1.3s ease-in-out infinite;
  }
  @keyframes coachPulse {
    0%, 100% { transform: scale(1);    filter: brightness(1); }
    50%      { transform: scale(1.09); filter: brightness(1.18); }
  }
  @media (prefers-reduced-motion: reduce) {
    #coach-tip { transition: none; }
    .powerup-btn.coached { animation: none; }
  }

  #toast {
    position: fixed;
    bottom: 90px;
    left: 50%;
    transform: translateX(-50%) translateY(10px);
    background: rgba(20, 14, 30, 0.88);
    color: white;
    padding: 9px 18px;
    border-radius: 100px;
    font-family: 'Fredoka', -apple-system, "SF Pro Display", system-ui, sans-serif;
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.3px;
    z-index: 200;
    pointer-events: none;
    opacity: 0;
    box-shadow: 0 6px 18px rgba(20, 14, 30, 0.35);
    transition: opacity 0.18s ease, transform 0.28s cubic-bezier(0.32, 1.4, 0.5, 1);
  }
  #toast.show {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }

  /* Tap-to-Play button: when the selected theme is locked, show a small
     inline "▶ AD" tag inside the button — tapping the button auto-plays
     the rewarded ad and starts the game in that world. */
  #start-btn { display: inline-flex; align-items: center; gap: 8px; }
  #start-btn .start-ad-tag {
    display: none;
    align-items: center;
    gap: 3px;
    font-size: 10px;
    font-weight: 900;
    letter-spacing: 0.6px;
    padding: 3px 8px 3px 7px;
    border-radius: 100px;
    background: rgba(255,255,255,0.26);
    border: 1px solid rgba(255,255,255,0.5);
    color: white;
    line-height: 1;
  }
  #start-btn .start-ad-tag .ad-icon { font-size: 8px; line-height: 1; }
  #start-btn.locked-theme .start-ad-tag { display: inline-flex; }

  .btn {
    background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
    color: white; border: none;
    padding: 14px 36px;
    border-radius: 100px;
    font-weight: 700; font-size: 16px;
    cursor: pointer;
    box-shadow: 0 10px 24px rgba(255,107,157,0.36), inset 0 1px 0 rgba(255,255,255,0.4);
    transition: transform 0.15s ease, box-shadow 0.2s ease;
    letter-spacing: 0.4px;
  }
  /* Game-over world offer. Deliberately NOT a .btn — Play Again has to stay the
     loudest thing on this screen, or we have traded a returning player for a
     single unlock. This reads as a card that happens to be tappable: quieter
     fill, no big shadow, and it sits above the primary rather than beside it. */
  .go-world {
    display: flex; align-items: center; gap: 11px;
    width: 100%; max-width: 300px;
    margin: 2px auto 14px;
    padding: 9px 11px;
    border-radius: 18px;
    border: 1.5px solid rgba(255,255,255,0.55);
    background: linear-gradient(150deg, rgba(255,255,255,0.90) 0%, rgba(255,244,248,0.86) 100%);
    box-shadow: 0 6px 16px rgba(90,50,90,0.14);
    font-family: inherit; color: var(--ink);
    cursor: pointer; text-align: left;
    animation: goWorldIn 0.42s ease-out both;
  }
  @keyframes goWorldIn {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  .go-world-art {
    width: 42px; height: 42px; flex-shrink: 0;
    border-radius: 11px; object-fit: cover;
    background: rgba(255,255,255,0.65);
  }
  .go-world-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
  .go-world-kicker {
    font-size: 9px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase;
    color: #c62a6c;
  }
  .go-world-name {
    font-size: 15px; font-weight: 800; line-height: 1.15;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .go-world-cta {
    flex-shrink: 0;
    padding: 7px 14px;
    border-radius: 100px;
    background: linear-gradient(150deg, #ff85ac 0%, #ff9f7e 100%);
    color: #fff; font-size: 12px; font-weight: 800; letter-spacing: 0.3px;
    box-shadow: 0 4px 10px rgba(220,90,140,0.30);
  }
  .go-world:active { transform: scale(0.985); }
  @media (prefers-reduced-motion: reduce) { .go-world { animation: none; } }

  .btn:hover { transform: translateY(-2px); box-shadow: 0 14px 28px rgba(255,107,157,0.44), inset 0 1px 0 rgba(255,255,255,0.4); }
  .btn:active { transform: translateY(0); }
  .btn-ghost {
    background: transparent; color: var(--ink-soft);
    border: none; padding: 8px 16px;
    font-weight: 600; font-size: 13px;
    cursor: pointer; margin-top: 6px;
    letter-spacing: 0.4px; opacity: 0.7;
    transition: opacity 0.2s ease;
  }
  .btn-ghost:hover { opacity: 1; }

  /* Close sits at the bottom of a glass panel over the blurred jar, where a
     transparent label had nothing behind it to read against. It is the only way
     out of this panel, so it gets a surface and a real 46px height. */
  #close-settings-btn {
    margin-top: 14px;
    min-height: 46px;
    padding: 12px 34px;
    border-radius: 99px;
    font-size: 15px; font-weight: 600;
    color: var(--ink);
    opacity: 1;
    background: rgba(255, 255, 255, 0.92);
    border: 1px solid rgba(255, 255, 255, 0.9);
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.95),
      0 2px 5px rgba(72, 60, 95, 0.10),
      0 8px 18px rgba(72, 60, 95, 0.13);
    transition: transform 0.18s cubic-bezier(0.34,1.5,0.64,1), box-shadow 0.18s ease;
  }
  #close-settings-btn:active {
    transform: scale(0.965);
    box-shadow: inset 0 2px 4px rgba(72, 60, 95, 0.16);
  }

  #gameover .game-over-title { font-size: 30px; font-weight: 800; margin-bottom: 8px; color: var(--ink); }
  #gameover .label-small { font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; opacity: 0.55; font-weight: 700; }
  #gameover .final-score { font-size: 56px; font-weight: 900; line-height: 1; margin: 4px 0 4px;
    background: linear-gradient(95deg, var(--accent) 0%, var(--accent-2) 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    font-variant-numeric: tabular-nums;
  }
  #gameover .new-best {
    font-size: 12px; font-weight: 800; letter-spacing: 1.5px;
    color: var(--accent); text-transform: uppercase;
    margin-bottom: 4px; opacity: 0;
    transition: opacity 0.4s;
  }
  #gameover .new-best.show { opacity: 1; }
  #gameover .best-line { margin-bottom: 22px; opacity: 0.6; font-size: 13px; }

  .hidden { display: none !important; }

  .footer { font-size: 11px; opacity: 0.4; text-align: center; margin-top: 4px; }

  /* Fade overlay for level/theme transitions. Sits above every overlay
     (z-index 9999). NOT a hard blackout — uses a translucent dark-plum
     tint with backdrop blur, so the transition reads as a soft veil
     instead of a harsh cut to black. Eye finds the swap less jarring. */
  #fade-cover {
    position: fixed; inset: 0;
    background: rgba(20, 16, 32, 0.78);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    opacity: 0;
    pointer-events: none;
    z-index: 9999;
    transition: opacity 0.18s cubic-bezier(0.4, 0, 0.6, 1);
  }
  #fade-cover.active {
    opacity: 1;
    pointer-events: auto;
  }

  /* SPACE power-up — pill button in the bottom row, replacing the static
     "Drag to aim · Tap to drop" hint (moved into the title-screen tagline).
     Sits OUTSIDE the playfield so it can never be obscured by bodies, can't
     be tapped by accident while aiming, and the count badge is always
     readable. Disabled (dim, no pulse) when game isn't running so it reads
     as contextually inactive on the title/gameover screens. */
  #powerup-bar {
    position: relative;
    /* Capped and centred: past ~400px the buttons stop growing and the row stays
       a group rather than four widening slabs. Below it they still share the
       width equally, so nothing can clip.
       width:100% + align-self, NOT auto margins — auto margins on a flex item
       cancel the parent's stretch, which shrank the row to its content and left
       it 219px wide inside a 304px column on a small phone. */
    width: 100%;
    max-width: 400px;
    align-self: center;
    display: flex; justify-content: center; align-items: center;
    /* ONE source of truth for the gutter. It used to be set in three places —
       base 10px, 4px under max-width:430, and 12px under min-height:800 — and on
       a 390x844 phone the last two both matched, so the tall-screen rule won and
       widened the gutter by 24px while the buttons kept their narrow-screen
       size. The row then ran 18px past the column and the outer two buttons were
       clipped, on iPhone 14, Pixel 8 and Galaxy A alike. */
    gap: clamp(4px, 1.6vw, 10px);
    /* Close enough to read as one bottom zone, far enough apart that the shelf
       and the controls keep separate roles. 18px of computed gap had them
       floating as two unrelated bands. The negative pull is gone: with the shelf
       no longer carrying its own wash the two need a real, if small, separation.
       #wrap's gap gives the base; this adds to it purely out of the slack that
       tall screens have and short ones do not — 0 extra at 640px tall, ~10 at
       844, ~11 at 932. The column is bottom-anchored, so the extra pushes the
       board UP into that slack rather than the controls down. */
    margin-top: clamp(0px, 1.15vh, 12px);
    min-height: 30px;
  }
  /* Grounding, not a container. A rounded panel here would be a third slab and
     would out-weigh the controls it is meant to support; a wide soft pool of
     light has edges that never resolve, so it separates the row from the
     playfield without drawing a box around it. */
  /* NO BACKING AT ALL down here now.
     There were two wash layers — one behind the shelf, one behind the controls —
     which is what read as "background, glow, UI, another glow". Merging them into
     one broad field looked right in the abstract and wrong on screen: reaching up
     far enough to hold both, it bleached the collection icons it was supposed to
     be grounding. Measured over 90px of reach at up to 34% white.
     Neither layer was earning its cost. The power-ups are the most saturated
     things on the screen and carry their own hue-tinted shadows; the collectibles
     are 3D objects. Spacing groups them, the world shows through, and the zone is
     one composition because its rhythm says so — not because a panel says so. */

  /* Four buttons instead of three, and the row is inside the 420-wide play
     column rather than the viewport — so on a 390 or 412 phone the bar has only
     ~374-396px to work with. The buttons are content-sized, so shrinking the gap
     alone did nothing: they stayed 100/97/83/92 = 372px and the first and last
     were clipped off both edges.
     Everything scales together here — padding, type, icon and badge — because
     taking it out of one of them alone makes the pill look starved rather than
     compact. Going vertical (icon over label) would be narrower still, but the
     bar sits ~18px above the bottom on a 667-tall phone and the extra height
     would not fit. */
  @media (max-width: 430px) {
    #powerup-bar .powerup-btn {
      height: 36px; padding: 0 5px;
      font-size: 10.5px; letter-spacing: 0.3px;
    }
    #powerup-bar .powerup-icon { margin-right: 4px; }
    #powerup-bar .powerup-count { margin-left: 5px; }
    #powerup-bar .powerup-count {
      min-width: 19px; height: 19px; font-size: 10.5px; padding: 0 2px;
    }
    #powerup-bar .powerup-icon svg { width: 15px; height: 15px; }
    #powerup-bar .twin-icon {
      width: 21px; height: 21px; background-size: 14px 14px;
      font-size: 12px; line-height: 21px;
    }
  }
  @media (max-width: 380px) {
    #powerup-bar .powerup-btn { height: 34px; padding: 0 4px; font-size: 10px; letter-spacing: 0.1px; }
    #powerup-bar .powerup-icon { margin-right: 3.5px; }
    #powerup-bar .powerup-count { margin-left: 4px; }
    #powerup-bar .powerup-count { min-width: 18px; height: 18px; font-size: 10px; padding: 0 2px; }
    #powerup-bar .powerup-icon svg { width: 14px; height: 14px; }
    #powerup-bar .twin-icon {
      width: 20px; height: 20px; background-size: 13px 13px;
      font-size: 11px; line-height: 20px;
    }
  }
  /* Last resort. The label is the least load-bearing part once the icon is a
     picture of the thing and the badge carries the count. */
  @media (max-width: 345px) {
    #powerup-bar .powerup-label { display: none; }
  }
  /* POWER-UP BUTTONS — one component, four accents.
   *
   * They used to be four fully saturated gradient pills, all pulsing, all at the
   * same visual weight. Against 33 deliberately soft worlds that made the busiest
   * thing on screen the UI rather than the emojis, which are supposed to be the
   * hero. The surface is now a milky white that is ALMOST OPAQUE — deliberately
   * not translucent, because a see-through pill inherits whatever world is behind
   * it and therefore cannot look the same on Cosmos and on Studio. Opaque is the
   * only way "consistent across every theme" is actually achievable.
   *
   * Each button carries its accent in three small places only: the icon, the
   * counter badge, and a 10% wash in the surface. Never as a solid fill. */
  .powerup-btn.shuffle {
    /* Warmer and more saturated than the contrast search first landed on: at
       hue 17 the fill came out terracotta-brown rather than the coral the design
       calls for. Hue 10 with more saturation reads as coral and costs only a
       little contrast (3.5:1), which bold rounded type carries fine. */
    --pw-top: #dc7b64; --pw-bot: #d24f33; --pw-solid: #d6644b;
    --pw-shade: rgba(178, 74, 52, 0.33);
  }
  /* Twin — deep violet-magenta, the one colour the bar did not already use
     (violet Shoot, warm-orange Space, green-teal Fill). */
  .powerup-btn.twin {
    --pw-top: #d166ae; --pw-bot: #c5449a; --pw-solid: #c04395;
    --pw-shade: rgba(160, 55, 125, 0.33);
  }
  /* The creature itself, drawn from the tier art the jar already loaded. */
  /* Twin shows the creature it would hand you rather than a glyph, so it cannot
     match the stroked icons directly. A soft accent ring gives it the same weight
     and footprint as the other three. */
  /* Twin shows the creature it would hand you rather than a glyph. The soft well
     behind it gives it the same footprint and the same light as the three stroked
     icons, so it reads as the fourth member rather than as a photo in a row of
     drawings. */
  /* Twin shows the creature it would hand you rather than a glyph. The pale well
     behind it matches the counter on the other side of the label, so the button
     reads as one object with two lit recesses. */
  .twin-icon {
    width: 22px; height: 22px;
    background-size: 15px 15px; background-repeat: no-repeat; background-position: center;
    background-color: rgba(255,255,255,0.90);
    border-radius: 50%;
    box-shadow: inset 0 1px 1.5px rgba(40, 28, 80, 0.16);
    filter: none;
    font-size: 13px; line-height: 22px; text-align: center;
  }

  /* Fill power-up — cool green-teal variant; distinct from violet (Shoot)
     and warm-orange (Space). No pulse (reuses base) so the bar isn't busy. */
  .powerup-btn.fill {
    /* Lifted and pushed green: contrast-matching to the others had driven this
       down to a deep teal when the brief asks for mint. */
    --pw-top: #41ac94; --pw-bot: #2f8375; --pw-solid: #389c88;
    --pw-shade: rgba(28, 110, 96, 0.33);
  }
  /* POWER-UPS — pastel bodies, not white chips and not the old arcade gradients.
   *
   * The milky version made these read as information rather than as controls,
   * which stopped being survivable once the top HUD became milky too: nothing on
   * screen said "tap me". They carry a real colour again, but a MEASURED one.
   *
   * Every fill is tuned so white text lands at 3.8-4.0:1. That is the whole
   * ruleset. It is also why the four are not the same lightness — teal and coral
   * carry far more luminance than violet at equal HSL lightness, so holding one
   * flat lightness across the row would have made FILL unreadable and SHOOT murky.
   * Worth noting what it replaced: the saturated version ran white text at
   * 2.59 (Shoot), 1.90 (Space), 2.11 (Fill) and 3.48 (Twin) — so this is both
   * calmer AND materially more legible than the bright original.
   *
   * Saturation is capped around 52. Past that these stop being pastel and slide
   * straight back to arcade. */
  .powerup-btn {
    /* Shoot is the default; the other three override above. */
    --pw-top: #8a7fda;
    --pw-bot: #6a5dcd;
    --pw-solid: #7a6dd4;
    --pw-shade: rgba(78, 66, 165, 0.33);
    /* Equal shares of the row, so four buttons ALWAYS fit whatever the gutter,
       the font or the label length works out to — a control dock rather than
       four content-sized pills that happen to add up. min-width:0 is what lets
       a flex item actually shrink below its content. */
    flex: 1 1 0; min-width: 0;
    display: inline-flex; align-items: center; justify-content: center;
    overflow: hidden;
    height: 38px;
    padding: 0 6px;
    border-radius: 100px;
    background: linear-gradient(180deg, var(--pw-top) 0%, var(--pw-bot) 100%);
    border: 1px solid rgba(255,255,255,0.40);
    color: #ffffff;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 11px; font-weight: 600;
    letter-spacing: 0.5px; text-transform: uppercase;
    /* Barely there, and doing real work: on a mid-tone pastel the white letter
       edges soften into the fill without it. Not an outline — an outline is what
       makes UI look like a mobile-game sticker. */
    text-shadow: 0 1px 1.5px rgba(48, 34, 78, 0.20);
    cursor: pointer;
    box-shadow:
      /* A brighter top highlight and a deeper bottom shade than anything else on
         screen: this is the layer the finger is meant to go to, and depth is what
         says "press me" without a glow or an outline. */
      inset 0 1.5px 0 rgba(255,255,255,0.50),
      inset 0 -1.5px 0 rgba(30, 20, 60, 0.10),
      0 1px 2px rgba(60, 50, 90, 0.12),
      /* The ambient shadow is tinted with the button's OWN hue rather than grey.
         A grey shadow under a coloured toy reads as dirt; a hue-matched one reads
         as light. */
      0 5px 14px var(--pw-shade);
    transition: transform 0.26s cubic-bezier(0.34, 1.5, 0.64, 1),
                box-shadow 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
  }
  /* Icon and label are one unit; the counter is a separate piece of information.
     A single uniform gap made all three read as equally unrelated. */
  .powerup-btn .powerup-icon { margin-right: 5px; }
  .powerup-btn .powerup-count { margin-left: 7px; }
  .powerup-btn:hover {
    transform: translateY(-1px);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.46),
      inset 0 -1px 0 rgba(30, 20, 60, 0.07),
      0 2px 4px rgba(60, 50, 90, 0.10),
      0 7px 17px var(--pw-shade);
  }
  /* Press is near-instant, release springs back past 1 and settles. Two
     different durations on one property is the whole trick — a single symmetric
     transition reads as a slider moving, not as a soft button letting go. */
  .powerup-btn:active {
    transform: scale(0.94);
    transition-duration: 0.06s;
    box-shadow:
      inset 0 2px 4px rgba(30, 20, 60, 0.20),
      0 1px 2px var(--pw-shade);
  }
  .powerup-btn:disabled {
    opacity: 0.42;
    filter: saturate(0.5);
    cursor: not-allowed;
    animation: none;
    pointer-events: none;
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.28),
      0 1px 2px rgba(60, 50, 90, 0.07);
  }
  /* Shoot-aim active: brighter gradient + stronger glow so player knows
     they're in aiming mode. Tap the button again to cancel. */
  /* Aiming is a MODE the player is stuck in until they cancel, so this is the one
     place a solid accent is right — quiet everywhere else, unmistakable here. */
  /* Aiming is a mode the player stays in until they cancel. The body is already
     coloured, so this deepens and rings it rather than reaching for a new hue. */
  #powerup-btn.aiming {
    --pw-top: #6d5fca; --pw-bot: #5748b4; --pw-solid: #5748b4;
    border-color: rgba(255,255,255,0.62);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.34),
      0 1px 2px rgba(60, 50, 90, 0.12),
      0 4px 13px rgba(72, 58, 160, 0.34),
      0 0 0 3px rgba(122, 106, 214, 0.24);
  }
  /* The icon inherits the accent, which is how the accent reaches the eye first. */
  .powerup-btn .powerup-icon {
    display: inline-flex; line-height: 0;
    color: #ffffff;
    filter: drop-shadow(0 1px 1px rgba(48, 34, 78, 0.22));
  }
  .powerup-btn .powerup-label { line-height: 1; white-space: nowrap; }
  /* The visual pill stays 38px so it does not eat the board, but the finger gets
     the 44px Apple and 48dp Android both ask for. Invisible, costs no layout. */
  .powerup-btn::after {
    content: '';
    position: absolute;
    left: 0; right: 0; top: 50%;
    transform: translateY(-50%);
    height: max(100%, 44px);
  }
  .powerup-btn { position: relative; }
  /* A circle, not a pill: the count was previously the same shape as the button
     containing it, so at a glance the bar read as eight objects instead of four.
     It only stretches once the number needs two digits. */
  /* A well pressed INTO the pill, not a sticker sitting on it. The badge used to
     be a solid accent disc with its own white ring and its own drop shadow — three
     separate signals that it was a different object, which is exactly why it read
     as a coloured circle dropped on top. Now it is a soft tint of the accent with
     the accent as its ink, and a faint inner shadow so the light agrees with the
     rest of the button. It also drops the accent to about a sixth of its former
     area, which is what "moderately soft" actually costs. */
  /* A light disc set INTO the coloured body — it reads as a hole the light comes
     through rather than as a second sticker laid on top, and the accent numeral
     ties it back to the button it belongs to. */
  .powerup-count {
    min-width: 20px; height: 20px;
    border-radius: 999px;
    background: rgba(255,255,255,0.93);
    color: var(--pw-solid);
    font-size: 11px; font-weight: 700;
    display: inline-flex; align-items: center; justify-content: center;
    border: 0;
    padding: 0 2px;
    box-shadow: inset 0 1px 1.5px rgba(40, 28, 80, 0.16);
    text-shadow: none;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0;
  }
  .powerup-count.zero {
    background: rgba(255,255,255,0.62);
    color: rgba(60, 48, 80, 0.50);
  }
  /* Spending presses IN, gaining pops OUT. Two directions for two meanings —
     one shared "number changed" animation would have said nothing about which.
     Both are under 350ms and neither moves anything but the badge itself. */
  @keyframes countSpent {
    0%   { transform: scale(1); }
    32%  { transform: scale(0.76); }
    100% { transform: scale(1); }
  }
  @keyframes countGained {
    0%   { transform: scale(1); }
    38%  { transform: scale(1.30); }
    100% { transform: scale(1); }
  }
  .powerup-count.spent  { animation: countSpent  0.28s cubic-bezier(0.34,1.40,0.64,1); }
  .powerup-count.gained { animation: countGained 0.34s cubic-bezier(0.34,1.50,0.64,1); }
  @media (prefers-reduced-motion: reduce) {
    .powerup-count.spent, .powerup-count.gained { animation: none; }
  }
  /* AVAILABLE vs UNAVAILABLE. A spent power-up keeps its shape, its icon and its
     name — it must still read as TWIN — but drains most of its colour and sits
     flatter, so the eye picks the usable ones out of the row without reading any
     numbers. Skipped when .ad-ready is on the badge: an empty slot with an ad
     behind it is an offer, not a dead end, and dimming it would hide the offer. */
  .powerup-btn:has(.powerup-count.zero:not(.ad-ready)) {
    filter: saturate(0.34);
    opacity: 0.78;
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.30),
      0 1px 2px rgba(60, 50, 90, 0.08);
  }
  .powerup-btn.reward-flash { animation: powerupReward 0.7s ease-out; }
  @keyframes powerupReward {
    0%   { transform: scale(1); }
    30%  { transform: scale(1.18) rotate(-4deg); }
    60%  { transform: scale(1.08) rotate(3deg); }
    100% { transform: scale(1) rotate(0); }
  }
  /* Ads-free badge — replaces the pink count with a gold ∞ once Remove Ads is owned. */
  .powerup-count.infinite {
    background: linear-gradient(135deg, #ffd93f 0%, #ffaa3a 100%);
    color: white;
    letter-spacing: 0;
    font-size: 14px;
    line-height: 1;
    padding: 0 6px;
    box-shadow: 0 1px 6px rgba(255, 170, 58, 0.55);
  }
  /* Rewarded waiting — a zero count with an ad in hand is not a dead end, so the
     grey 0 becomes a play glyph. Ambient by design: it just sits there whenever an
     ad is earnable, rather than interrupting to announce itself.
     Scoped to #powerup-bar because `.powerup-btn.shuffle .powerup-count` (0,3,0)
     out-specifies a bare `.powerup-count.ad-ready` (0,2,0) and would keep its own
     background on the Space button. The 0 is hidden rather than removed so the
     badge keeps its width and nothing reflows. */
  #powerup-bar .powerup-count.ad-ready {
    position: relative;
    background: linear-gradient(180deg, #5fbdaf 0%, #4da8bf 100%);
    color: transparent;
    box-shadow:
      inset 0 1px 1.5px rgba(20, 70, 80, 0.18),
      0 0 0 2.5px rgba(95, 189, 175, 0.18);
  }
  #powerup-bar .powerup-count.ad-ready::after {
    content: '';
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-38%, -50%);
    width: 0; height: 0;
    border-left: 6px solid white;
    border-top: 4px solid transparent;
    border-bottom: 4px solid transparent;
  }
  /* The nudge: two slow breaths, then it settles into the ambient state above and
     never asks again. Only ever applied to a player we actually turned away. */
  #powerup-bar .powerup-count.ad-ready.nudge {
    animation: adReadyBreathe 0.6s ease-in-out 2;
  }
  @keyframes adReadyBreathe {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.2); }
  }
  @media (prefers-reduced-motion: reduce) {
    #powerup-bar .powerup-count.ad-ready.nudge { animation: none; }
  }

  /* Shop modal — full-stage frosted overlay laid out as a flex COLUMN:
     compact header pinned at top, scrollable item list filling the rest,
     and a top-right X to close (no bottom button eating vertical space). */
  #shop-modal {
    justify-content: flex-start;
    padding: 20px 18px 16px;
  }
  /* Corner close — Windows-style X, top-right of the modal. Generic class so
     other modals can reuse it later. */
  .modal-close-x {
    position: absolute;
    top: 14px; right: 14px;
    width: 32px; height: 32px;
    border-radius: 50%;
    border: 1px solid rgba(255,255,255,0.75);
    background: rgba(255,255,255,0.5);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    color: var(--ink-soft);
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    padding: 0;
    z-index: 5;
    box-shadow: 0 2px 8px rgba(40, 70, 110, 0.12);
    transition: transform 0.15s ease, background 0.2s ease, color 0.2s ease;
  }
  .modal-close-x:hover { transform: scale(1.1); background: rgba(255,255,255,0.85); color: var(--ink); }
  .modal-close-x:active { transform: scale(0.92); }
  .modal-close-x svg { width: 16px; height: 16px; display: block; }
  /* Compact header — small bag + wordmark on one row (was a big 62px icon
     stacked over the title, which read oversized and pushed content down). */
  .shop-header {
    display: flex; align-items: center; justify-content: center;
    gap: 9px;
    flex: 0 0 auto;
    margin-bottom: 14px;
  }
  #shop-modal .shop-icon { width: 32px; height: 32px; display: block; filter: drop-shadow(0 3px 8px rgba(255, 170, 60, 0.4)); }
  #shop-modal .shop-title {
    font-size: 24px; font-weight: 800; letter-spacing: -0.4px;
    background: linear-gradient(95deg, #ffaa3a 0%, #ff5b7a 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  /* Item list flex-fills the remaining modal height and scrolls internally —
     using flex (not a vh cap) keeps it correct even when the stage is shorter
     than the viewport, so the hero bundle is always visible at the top and the
     list never clips. Soft mask + thin scrollbar; padding-top clears the
     bundle's "Best Value" badge (top:-8px) from the overflow clip. */
  #shop-items {
    width: 100%; max-width: 320px;
    display: flex; flex-direction: column; gap: 10px;
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    padding: 12px 4px 4px;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
    scrollbar-color: rgba(120,80,140,0.28) transparent;
    mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 12px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 12px), transparent 100%);
  }
  /* When the shop is open, hide the title-screen's bottom chrome (theme
     tier-strip preview + power-up bar) and the floating "i" so the modal
     reads as a focused, self-contained screen. */
  body.shop-active #tier-strip,
  body.shop-active #powerup-bar,
  body.shop-active .floating-icon-btn { display: none !important; }
  #shop-items::-webkit-scrollbar { width: 5px; }
  #shop-items::-webkit-scrollbar-thumb { background: rgba(120,80,140,0.22); border-radius: 3px; }
  #shop-items::-webkit-scrollbar-track { background: transparent; }
  /* Section divider label — quiet small-caps tag that groups consumable
     power-ups apart from the permanent Premium tier. */
  .shop-section-label {
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-size: 10px; font-weight: 800;
    letter-spacing: 1.4px; text-transform: uppercase;
    color: var(--ink-soft); opacity: 0.6;
    text-align: left;
    margin: 4px 2px -2px;
  }
  .shop-section-label:first-child { margin-top: 0; }
  .shop-item {
    display: flex; align-items: center; gap: 10px;
    background: rgba(255,255,255,0.78);
    border: 1px solid var(--glass-border);
    border-radius: 16px;
    padding: 11px 12px;
    box-shadow: var(--shadow-soft);
    text-align: left;
  }
  /* 3D Fluent icon in a soft tinted chip — matches the game's emoji language
     (tier images, next-fruit, HUD action icons) instead of flat unicode, and
     the per-row tint ties each item to its in-game HUD button colour. */
  .shop-item-icon {
    width: 42px; height: 42px;
    flex-shrink: 0;
    display: flex; align-items: center; justify-content: center;
    border-radius: 13px;
    background: rgba(255,255,255,0.55);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
  }
  .shop-item-icon img {
    width: 32px; height: 32px; display: block;
    filter: drop-shadow(0 2px 4px rgba(40,60,90,0.22));
  }
  #shop-item-shoot  .shop-item-icon { background: linear-gradient(135deg, rgba(168,144,255,0.26), rgba(93,200,255,0.20)); }
  #shop-item-space  .shop-item-icon { background: linear-gradient(135deg, rgba(255,170,58,0.26), rgba(255,91,138,0.20)); }
  #shop-item-fill   .shop-item-icon { background: linear-gradient(135deg, rgba(78,200,138,0.26), rgba(42,163,200,0.20)); }
  #shop-item-bundle .shop-item-icon { background: linear-gradient(135deg, rgba(255,170,140,0.34), rgba(255,140,170,0.26)); }
  #shop-item-adfree .shop-item-icon { background: linear-gradient(135deg, rgba(255,217,63,0.36), rgba(255,170,58,0.26)); }
  .shop-item-info { flex: 1; min-width: 0; }
  .shop-item-name { font-size: 14px; font-weight: 800; line-height: 1.2; }
  .shop-item-desc { font-size: 11px; opacity: 0.62; margin-top: 2px; line-height: 1.3; }
  .btn-buy {
    background: linear-gradient(135deg, #ffaa3a 0%, #ff5b7a 100%);
    color: white; border: none;
    padding: 8px 14px;
    border-radius: 100px;
    font-weight: 800; font-size: 12px;
    letter-spacing: 0.4px;
    cursor: pointer;
    box-shadow: 0 6px 14px rgba(255, 100, 100, 0.32), inset 0 1px 0 rgba(255,255,255,0.4);
    transition: transform 0.15s ease, box-shadow 0.2s ease;
    flex-shrink: 0;
    white-space: nowrap;
    min-width: 64px;
  }
  .btn-buy:hover { transform: translateY(-1px); box-shadow: 0 8px 18px rgba(255, 100, 100, 0.4), inset 0 1px 0 rgba(255,255,255,0.4); }
  .btn-buy:active { transform: translateY(0); }
  .btn-buy:disabled { cursor: not-allowed; transform: none; }
  .shop-item.owned .btn-buy {
    background: rgba(120, 80, 200, 0.14);
    color: #5d3a8a;
    box-shadow: none;
    pointer-events: none;
  }
  .shop-item.processing .btn-buy {
    background: rgba(120, 80, 200, 0.18);
    color: #5d3a8a;
    pointer-events: none;
  }
  .shop-item.owned { background: linear-gradient(135deg, rgba(168,144,255,0.14), rgba(93,200,232,0.10)); }
  /* "Best Value" badge — top-right corner of the Power Bundle row,
     a gradient pill that draws the eye to the bundle as the smart pick. */
  .shop-item { position: relative; }
  .shop-item-badge {
    position: absolute;
    top: -8px; right: 12px;
    background: linear-gradient(95deg, #ff7d4f, #ff4d8a);
    color: #fff;
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-size: 9px; font-weight: 800;
    letter-spacing: 0.6px;
    padding: 3px 8px;
    border-radius: 100px;
    text-transform: uppercase;
    box-shadow: 0 4px 10px rgba(255, 90, 130, 0.35);
  }
  /* Bundle item gets a subtle warm tint so it pops from siblings. */
  #shop-item-bundle {
    background: linear-gradient(135deg, rgba(255, 220, 200, 0.6), rgba(255, 195, 220, 0.55));
    border-color: rgba(255, 170, 140, 0.5);
  }
  /* Confirmation toast for power-up purchases. */
  .shop-toast {
    position: absolute;
    top: 20px;
    left: 50%;
    transform: translate(-50%, -8px);
    background: linear-gradient(95deg, var(--accent), var(--accent-2));
    color: #fff;
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-weight: 700; font-size: 12px;
    letter-spacing: 0.3px;
    padding: 8px 16px;
    border-radius: 100px;
    box-shadow: 0 8px 18px rgba(0,0,0,0.18);
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    z-index: 30;
  }
  .shop-toast.show {
    animation: shopToastInOut 2.2s ease forwards;
  }
  @keyframes shopToastInOut {
    0%   { opacity: 0; transform: translate(-50%, -8px); }
    15%  { opacity: 1; transform: translate(-50%, 0); }
    80%  { opacity: 1; transform: translate(-50%, 0); }
    100% { opacity: 0; transform: translate(-50%, -8px); }
  }

  /* Ad modal — same overlay system as title/gameover. Reuses .btn for the watch
     CTA, with a violet→cyan gradient skin (.btn-ad) to match the power-up theme. */
  #ad-modal .ad-icon { font-size: 56px; line-height: 1; margin-bottom: 10px; filter: drop-shadow(0 4px 12px rgba(168, 144, 255, 0.5)); }
  #ad-modal .ad-title {
    font-size: 22px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.4px;
    background: linear-gradient(95deg, #7a4dc0 0%, #3aaad8 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  #ad-modal .ad-desc { font-size: 14px; opacity: 0.7; max-width: 280px; line-height: 1.45; margin-bottom: 22px; }
  .btn-ad {
    background: linear-gradient(135deg, #a890ff 0%, #5dc8ff 100%);
    box-shadow: 0 10px 24px rgba(120, 80, 200, 0.4), inset 0 1px 0 rgba(255,255,255,0.4);
  }
  .btn-ad:hover { box-shadow: 0 14px 28px rgba(120, 80, 200, 0.5), inset 0 1px 0 rgba(255,255,255,0.4); }
  .ad-progress {
    width: 240px;
    background: rgba(255,255,255,0.55);
    border: 1px solid var(--glass-border);
    border-radius: 14px;
    padding: 14px 16px;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  .ad-progress-bar-track {
    height: 8px; background: rgba(168, 144, 255, 0.18);
    border-radius: 4px; overflow: hidden;
    margin-bottom: 8px;
  }
  .ad-progress-bar {
    height: 100%; width: 0;
    background: linear-gradient(90deg, #a890ff 0%, #5dc8ff 100%);
    border-radius: 4px;
    transition: width 0.08s linear;
  }
  .ad-progress-text { font-size: 12px; opacity: 0.7; text-align: center; font-weight: 600; }

  /* Continue modal — see the CONTINUE MODAL block below for the card itself. */
  #continue-modal .continue-desc { font-size: 14px; opacity: 0.72; line-height: 1.45; margin-bottom: 16px; text-align: center; }
  .continue-timer {
    position: relative;
    width: 90px; height: 90px;
    margin-bottom: 18px;
  }
  .timer-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
  .timer-bg, .timer-fill {
    fill: none;
    stroke-width: 6;
  }
  .timer-bg { stroke: rgba(120, 80, 140, 0.15); }
  .timer-fill {
    stroke: url(#timer-grad);
    stroke-linecap: round;
    stroke-dasharray: 289;        /* 2π × 46 ≈ 289 */
    stroke-dashoffset: 0;
    transition: stroke-dashoffset 0.1s linear;
  }
  /* Gradient stroke for the timer ring via inline SVG defs (added by JS) */
  .timer-fill { stroke: #ff6b3a; }
  .timer-count {
    position: absolute;
    inset: 0;
    display: flex; align-items: center; justify-content: center;
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-size: 30px; font-weight: 700;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
  }
  #continue-modal .ad-progress { margin-top: 10px; }

  /* ===== CONTINUE MODAL =========================================
     Cut from the game's own material. Everything here was previously a
     hand-picked hex — a white slab, a #c8623f coral and a #5f6fce CTA — and
     blue does not exist anywhere else in this game, so the loudest thing on the
     screen was also the only foreign one. These are the same --ui-* tokens the
     HUD panels and the power-up dock use, and --ui-progress, the single warm
     coral that already means "progress" throughout. */

  #continue-modal {
    padding: 18px;
    background: rgba(255,255,255,0.40);
    backdrop-filter: blur(20px) saturate(78%);
    -webkit-backdrop-filter: blur(20px) saturate(78%);
  }
  .cont-card {
    width: min(304px, 100%);
    display: flex; flex-direction: column; align-items: center;
    padding: clamp(17px, 3.2vh, 23px) 20px clamp(12px, 2.4vh, 17px);
    border-radius: 26px;
    background: linear-gradient(180deg, var(--ui-top), var(--ui-bot));
    border: 1px solid var(--ui-edge);
    box-shadow: var(--ui-hair), var(--ui-lift), 0 16px 36px rgba(72,60,95,0.16);
    animation: contCardIn 0.34s cubic-bezier(0.22,1,0.36,1) both;
  }
  @keyframes contCardIn {
    from { opacity: 0; transform: translateY(10px) scale(0.97); }
    to   { opacity: 1; transform: none; }
  }

  #continue-modal .continue-title {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(21px, 3vh, 25px); font-weight: 600;
    letter-spacing: -0.2px; margin-bottom: 3px;
    background: none; -webkit-background-clip: border-box; background-clip: border-box;
    color: var(--ui-ink);
  }
  #continue-modal .continue-desc {
    font-size: 13px; font-weight: 500; line-height: 1.4;
    color: var(--ui-ink-soft); opacity: 0.9;
    margin-bottom: clamp(11px, 2vh, 15px); text-align: center;
  }
  /* The full jar is the REASON, so it sits under the headline at reason size. */
  #continue-modal.is-offer .continue-desc { display: block; }

  /* ---- the reward, shown as the jar it is about ---- */
  #continue-modal .cont-reward { display: none; }
  #continue-modal.is-offer .cont-reward {
    display: flex; align-items: center; justify-content: center; gap: 14px;
    width: 100%;
    background: color-mix(in srgb, var(--ui-progress) 9%, transparent);
    border-radius: 20px;
    padding: 12px 16px;
    margin-bottom: clamp(13px, 2.4vh, 18px);
  }
  @supports not (color: color-mix(in srgb, red 50%, blue)) {
    #continue-modal.is-offer .cont-reward { background: rgba(208,130,104,0.09); }
  }
  .cont-jar { position: relative; flex: 0 0 auto; }
  .cont-jar-svg { display: block; width: 44px; height: 56px; }
  .jar-stays  { fill: var(--ui-progress); opacity: 0.34; }
  /* The part that goes: same colour, brighter, and it lifts and fades on a loop
     so the drawing says "this clears" without a sentence. */
  .jar-clears { fill: var(--ui-progress-deep); animation: jarClear 2.6s ease-in-out infinite; }
  .jar-edge   { fill: none; stroke: var(--ui-progress-deep); stroke-width: 2; opacity: 0.55; }
  .jar-line   { stroke: var(--ui-base); stroke-width: 2; opacity: 0.9; }
  @keyframes jarClear {
    0%, 62%, 100% { transform: translateY(0);    opacity: 0.92; }
    78%           { transform: translateY(-4px); opacity: 0.30; }
    90%           { transform: translateY(0);    opacity: 0.92; }
  }
  .cont-spark {
    position: absolute; top: -7px; right: -11px;
    width: 24px; height: 24px;
    filter: drop-shadow(0 2px 4px rgba(150, 90, 60, 0.30));
    animation: contSpark 2.6s ease-in-out infinite;
  }
  @keyframes contSpark {
    0%, 62%, 100% { transform: scale(0.88); opacity: 0.75; }
    78%           { transform: scale(1.10); opacity: 1; }
  }
  .cont-reward-copy { text-align: left; }
  .cont-reward-line { display: flex; align-items: baseline; }
  .cont-pct {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(38px, 6vh, 46px); font-weight: 600; line-height: 0.92;
    letter-spacing: -1.4px;
    color: var(--ui-progress-deep);
  }
  .cont-of {
    font-size: 12.5px; font-weight: 600; line-height: 1.25;
    color: var(--ui-ink-soft); margin-top: 3px;
  }

  /* ---- CTA. The strongest thing on the card, and now in the game's colour. ---- */
  #continue-modal .btn-ad {
    width: 100%;
    display: flex; align-items: center; justify-content: center; gap: 9px;
    height: 52px; border-radius: 99px;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 16px; font-weight: 600; letter-spacing: 0.2px;
    color: #fff; border: 1px solid rgba(255,255,255,0.42);
    background: linear-gradient(180deg, #e09070 0%, var(--ui-progress-deep) 100%);
    box-shadow:
      inset 0 1.5px 0 rgba(255,255,255,0.42),
      inset 0 -1.5px 0 rgba(90, 40, 20, 0.12),
      0 2px 5px rgba(120, 70, 50, 0.16),
      0 8px 20px rgba(180, 100, 70, 0.30);
    transition: transform 0.24s cubic-bezier(0.34,1.5,0.64,1), box-shadow 0.2s ease;
    animation: contCtaBreathe 3.6s ease-in-out infinite;
  }
  #continue-modal .btn-ad:active {
    transform: scale(0.965);
    transition-duration: 0.06s;
    box-shadow: inset 0 2px 5px rgba(90, 40, 20, 0.24), 0 1px 3px rgba(120, 70, 50, 0.16);
  }
  /* Rests, then one small lift — a button that pulses without pause reads as an
     advert, which is the one thing this must not look like. */
  @keyframes contCtaBreathe {
    0%, 72%, 100% { transform: translateY(0); }
    82%           { transform: translateY(-2px); }
    92%           { transform: translateY(0); }
  }
  .cta-play {
    width: 0; height: 0;
    border-left: 10px solid #fff;
    border-top: 6.5px solid transparent;
    border-bottom: 6.5px solid transparent;
    margin-left: -2px;
  }

  /* Countdown + decline share one quiet row. */
  .cont-foot {
    display: flex; align-items: center; justify-content: center; gap: 12px;
    margin-top: clamp(9px, 1.8vh, 14px);
  }
  #continue-modal .continue-timer { width: 26px; height: 26px; margin: 0; }
  #continue-modal .timer-count { font-size: 12px; font-weight: 600; color: var(--ui-ink-quiet); }
  /* In the offer state the countdown is a footnote beside the decline. In the
     other states it is the only thing telling you what happens next. */
  #continue-modal:not(.is-offer) .continue-timer { width: 54px; height: 54px; margin-top: 4px; }
  #continue-modal:not(.is-offer) .timer-count { font-size: 20px; }
  #continue-modal:has(#continue-ad-progress:not(.hidden)) .continue-timer { display: none; }
  #continue-modal .timer-bg { stroke: rgba(90, 75, 130, 0.14); }
  #continue-modal .timer-fill { stroke: color-mix(in srgb, var(--ui-progress) 70%, transparent); }
  @supports not (color: color-mix(in srgb, red 50%, blue)) {
    #continue-modal .timer-fill { stroke: rgba(208,130,104,0.7); }
  }
  #continue-modal .btn-ghost {
    font-size: 13.5px; font-weight: 600; color: var(--ui-ink-soft); opacity: 0.72;
    /* Generous target even though the label is small — a decline that is hard to
       hit is a dark pattern whatever it says. And it says what actually happens:
       "No thanks" hid the consequence, "End run" states it. */
    padding: 12px 16px; margin: 0;
  }
  #continue-modal .btn-ghost:active { opacity: 1; }
  #continue-modal .ad-progress {
    width: 100%; background: none; border: 0; box-shadow: none; padding: 0; margin-top: 14px;
  }

  @media (prefers-reduced-motion: reduce) {
    .cont-card, #continue-modal .btn-ad, .jar-clears, .cont-spark { animation: none; }
  }

  /* ---- coins ---------------------------------------------------------------
     Drawn, not an emoji: the asset set has no coin, and borrowing Candy or
     Spiral shell would collide with tier art. A disc in the game's own progress
     coral keeps it out of casino-gold territory, which the audit ruled out
     explicitly, and costs nothing to load. */
  /* A drawn disc was the only non-emoji object in a game made entirely of 3D
     Fluent art, and at 12px it read as a flat brown dot. The glowing star is a
     real asset from the same set, so it sits with the tier art and the globe —
     and it is not used as a tier in any of the 33 worlds, so it cannot be
     mistaken for something droppable. Warm rather than gold: still not casino. */
  .coin-icon {
    width: 1.15em; height: 1.15em; flex: none; display: block;
    filter: drop-shadow(0 1px 2px rgba(150, 110, 40, 0.30));
  }
  /* ---- compact dialog ------------------------------------------------------
     A decision this small does not need the whole screen. The scrim only has
     to separate the card from the list; the list staying visible is what keeps
     this feeling like part of the game rather than a system dialog. */
  .overlay.overlay-light {
    background: rgba(255,255,255,0.28);
    backdrop-filter: blur(5px) saturate(96%);
    -webkit-backdrop-filter: blur(5px) saturate(96%);
    padding: 20px;
  }
  .ad-card {
    width: min(300px, 100%);
    display: flex; flex-direction: column; align-items: center;
    padding: clamp(16px, 3vh, 22px) 22px clamp(12px, 2.2vh, 16px);
    border-radius: 28px;
    background: linear-gradient(180deg, var(--ui-top), var(--ui-bot));
    border: 1px solid var(--ui-edge);
    box-shadow: var(--ui-hair), var(--ui-lift), 0 18px 40px rgba(72,60,95,0.18);
    animation: contCardIn 0.32s cubic-bezier(0.22,1,0.36,1) both;
  }
  /* The world's own art, not a padlock. A player deciding whether to unlock
     Xmas should be looking at Xmas. */
  .unlock-art {
    width: clamp(64px, 17vw, 82px); height: clamp(64px, 17vw, 82px);
    border-radius: 20px; display: block; margin-bottom: 9px;
    box-shadow: 0 6px 16px rgba(80,55,110,0.24), 0 0 0 3px rgba(255,255,255,0.75);
  }
  #ad-modal .ad-icon { font-size: 34px; line-height: 1; margin-bottom: 4px; }
  #ad-modal.is-unlock .ad-icon { display: none; }
  #ad-modal .ad-title {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(20px, 5.4vw, 24px); font-weight: 600;
    letter-spacing: -0.3px; color: var(--ui-ink); margin-bottom: 3px;
  }
  #ad-modal .ad-desc {
    font-size: 13px; line-height: 1.5; color: var(--ui-ink-soft);
    text-align: center; margin-bottom: clamp(11px, 2.2vh, 16px);
  }
  #ad-modal .btn-ad {
    width: 100%; height: 48px; border-radius: 99px;
    white-space: nowrap;
    display: flex; align-items: center; justify-content: center; gap: 8px;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 15.5px; font-weight: 600; color: #fff;
    border: 1px solid rgba(255,255,255,0.42);
  }
  #ad-modal .btn-ghost {
    font-size: 13.5px; font-weight: 600; color: var(--ui-ink-soft);
    opacity: 0.72; padding: 11px 16px; margin-top: 2px;
  }
  #ad-modal .ad-progress { width: 100%; background: none; border: 0; box-shadow: none; padding: 0; }

  /* The unlock offer's numbers. Cost leads because it is the decision; what
     they hold is the context under it. Same coral the coins use everywhere. */
  .unlock-cost {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 1.5em; font-weight: 600; color: var(--ui-progress-deep);
    vertical-align: -1px;
  }
  .unlock-have { font-size: 0.92em; opacity: 0.72; }

  /* The unlock CTA in the game's own colour. The shared .btn-ad is periwinkle,
     which appears nowhere else in Squishy Merge — the continue modal was moved
     off it for the same reason. Same action, same colour. */
  #ad-modal .btn-ad {
    background: linear-gradient(180deg, #e09070 0%, var(--ui-progress-deep) 100%);
    box-shadow:
      inset 0 1.5px 0 rgba(255,255,255,0.42),
      inset 0 -1.5px 0 rgba(90, 40, 20, 0.12),
      0 2px 5px rgba(120, 70, 50, 0.16),
      0 8px 20px rgba(180, 100, 70, 0.30);
  }
  #ad-modal .btn-ad:active {
    box-shadow: inset 0 2px 5px rgba(90, 40, 20, 0.24), 0 1px 3px rgba(120, 70, 50, 0.16);
  }

  /* Top-right, opposite the shop, above the list. Present without competing:
     it answers "how close am I" at a glance and is never the loudest thing. */
  .coin-chip-home {
    position: fixed;
    top: 10px; right: 46px;
    z-index: 100;
    font-size: 16px;
    padding: 7px 14px 7px 9px;
    gap: 6px;
    border-radius: 99px;
    background: linear-gradient(180deg, rgba(255,255,255,0.62), rgba(255,255,255,0.42));
    border: 1px solid rgba(255,255,255,0.72);
    backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.85), 0 3px 10px rgba(80,55,110,0.12);
    color: var(--ui-ink);
  }
  body:not(.home-active) .coin-chip-home { display: none; }

  /* The unlock dialog's numbers. */
  .ad-desc .u-line, .ad-desc .u-have { display: block; }
  .u-cost {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 1.7em; font-weight: 600; color: var(--ui-progress-deep);
    vertical-align: -2px;
  }
  .u-have { font-size: 0.94em; opacity: 0.75; margin-top: 3px; }
  #watch-ad-btn.is-coin-buy {
    background: linear-gradient(180deg, #e09070 0%, var(--ui-progress-deep) 100%);
  }
  .coin-chip {
    grid-area: coin;
    display: inline-flex; align-items: center; gap: 4px;
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 12px; font-weight: 800;
    color: var(--ui-ink); justify-self: center;
    margin-top: 2px;
    text-shadow: 0 1px 2px rgba(255,255,255,0.7);
  }


  .run-reward {
    display: flex; flex-direction: column; align-items: center; gap: 8px;
    margin: 2px 0 10px;
  }
  .run-coin-row {
    display: inline-flex; align-items: center; gap: 6px;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(19px, 5.2vw, 24px); font-weight: 600;
    color: var(--ui-progress-deep);
  }
  .run-double {
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    min-height: 42px; padding: 0 20px; border-radius: 99px;
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 14.5px; font-weight: 600; color: #fff;
    border: 1px solid rgba(255,255,255,0.42);
    background: linear-gradient(180deg, #e09070 0%, var(--ui-progress-deep) 100%);
    box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.40), 0 5px 14px rgba(180,100,70,0.28);
    transition: transform 0.18s cubic-bezier(0.34,1.5,0.64,1);
  }
  .run-double:active { transform: scale(0.95); }

  /* ---- rewarded unavailable ------------------------------------------------
     The honest third state. Not greyed out to the point of looking broken —
     just clearly not an offer right now, so the player stops tapping it. */
  .powerup-btn.is-ad-unavailable { opacity: 0.55; }
  .powerup-btn.is-ad-unavailable .pcount { background: rgba(90,75,120,0.30); color: rgba(255,255,255,0.75); }
  .theme-row.is-ad-unavailable .theme-row-play { opacity: 0.5; filter: saturate(0.6); }
  .theme-row.is-ad-unavailable .play-ad { background: rgba(90, 75, 120, 0.62); }
  /* Affordable with coins: the price chip carries the coin colour so it reads as
     a price rather than as another AD marker. */
  /* Locked rows carry a coin, not a price. */
  .theme-row.locked .play-ad {
    background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.78));
    border: 1px solid rgba(255,255,255,0.9);
    padding: 3px; line-height: 0;
    box-shadow: 0 2px 5px rgba(80,50,100,0.20);
  }
  .theme-row.locked .play-ad img { width: 14px; height: 14px; display: block; }
  .theme-row.is-ad-unavailable .play-ad { opacity: 0.8; }

  /* ---- goal banner ------------------------------------------------------
     Same glass material as the HUD panels, sized in viewport-relative units so
     it holds from a 360-wide phone to a tall 430. Anchored to the stage, not to
     the viewport, so it cannot collide with a notch or the system bars. */
  .goal-banner {
    position: absolute;
    left: 50%; top: 14%;
    transform: translateX(-50%);
    z-index: 6;
    display: flex; flex-direction: column; align-items: center; gap: 3px;
    max-width: min(86%, 320px);
    padding: clamp(9px, 2.2vh, 14px) clamp(14px, 4.5vw, 22px);
    border-radius: 20px;
    background: linear-gradient(180deg, var(--ui-top), var(--ui-bot));
    border: 1px solid var(--ui-edge);
    box-shadow: var(--ui-hair), var(--ui-lift);
    pointer-events: none;
    animation: goalIn 0.42s cubic-bezier(0.22,1,0.36,1) both;
  }
  .goal-banner.is-leaving { animation: goalOut 0.4s ease-in both; }
  @keyframes goalIn  { from { opacity:0; transform:translateX(-50%) translateY(-8px) scale(0.96) } }
  @keyframes goalOut { to   { opacity:0; transform:translateX(-50%) translateY(-6px) scale(0.98) } }
  .goal-lead {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 9.5px; font-weight: 800; letter-spacing: 1.4px;
    text-transform: uppercase; color: var(--ui-ink-quiet);
  }
  .goal-main { display: flex; align-items: center; gap: 8px; }
  .goal-emoji { width: clamp(26px, 7vw, 34px); height: clamp(26px, 7vw, 34px); display: block;
                filter: drop-shadow(0 3px 6px rgba(60,40,90,0.24)); }
  .goal-main b {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(16px, 4.6vw, 20px); font-weight: 600; letter-spacing: -0.2px;
    color: var(--ui-ink); white-space: nowrap;
  }
  /* The destination, marked on the strip the game already has rather than in a
     second progress bar. A ring, not a fill — it is a place, not a level. */
  .tier-dot.is-goal-tier {
    box-shadow: 0 0 0 2.5px var(--ui-progress), 0 0 0 4.5px rgba(255,255,255,0.75);
  }
  .tier-img.is-goal-tier { filter: drop-shadow(0 2px 5px rgba(200,110,70,0.5)); }
  @media (prefers-reduced-motion: reduce) {
    .goal-banner, .goal-banner.is-leaving { animation: none; }
  }

  /* Mid-game ad-break card — lighter frost than the full overlay so the jar
     stays visible behind it (reads as a brief pause, not a screen takeover). */
  .break-overlay {
    background: rgba(255, 255, 255, 0.42);
    backdrop-filter: blur(10px) saturate(125%);
    -webkit-backdrop-filter: blur(10px) saturate(125%);
    z-index: 12;
  }
  .break-card {
    display: flex; flex-direction: column; align-items: center;
    max-width: 300px;
    padding: 26px 30px 22px;
    background: rgba(255, 255, 255, 0.72);
    border: 1px solid rgba(255, 255, 255, 0.8);
    border-radius: 26px;
    box-shadow: 0 18px 50px rgba(80, 50, 110, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.7);
    animation: breakPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
  }
  @keyframes breakPop {
    from { opacity: 0; transform: translateY(10px) scale(0.9); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes buoyBob {
    0%, 100% { transform: translateY(0) rotate(-4deg); }
    50%      { transform: translateY(-5px) rotate(4deg); }
  }
  .break-icon {
    width: 62px; height: 62px; margin-bottom: 10px;
    filter: drop-shadow(0 6px 14px rgba(150, 110, 60, 0.35));
    animation: buoyBob 2.4s ease-in-out infinite;
  }
  .break-title { font-family: 'Fredoka', sans-serif; font-size: 22px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
  .break-desc { font-size: 13.5px; opacity: 0.72; line-height: 1.45; margin-bottom: 16px; text-align: center; }
  .break-timer { width: 62px; height: 62px; margin-bottom: 0; }
  .break-timer .timer-count { font-size: 22px; }
  @media (prefers-reduced-motion: reduce) {
    .break-icon { animation: none; }
    .break-card { animation: none; }
  }

  /* When the share modal is open, hide everything that competes with the
     jar card — the HUD up top and the tier-strip / power-up bar below. */
  body.share-active #hud,
  body.share-active #tier-strip,
  body.share-active #powerup-bar,
  body.share-active .floating-icon-btn { display: none !important; }

  /* Share modal — full viewport, transparent. Breaks out of the .wrap
     so brand/score/buttons sit OUTSIDE the (flattened) game container,
     not cramped inside it. */
  #share-modal {
    position: fixed;
    inset: 0;
    background: transparent;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    justify-content: space-between;
    padding: 44px 24px 34px;
    gap: 16px;
  }
  /* Small home button, top-left corner of the share screen — replaces the
     bottom "Play again" link. Tapping returns to the title screen. */
  .share-home-btn, .share-replay-btn {
    position: absolute;
    top: 16px;
    width: 38px; height: 38px;
    border-radius: 50%;
    border: 1px solid rgba(255,255,255,0.85);
    background: rgba(255,255,255,0.38);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    color: #ffffff;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer; padding: 0; z-index: 5;
    box-shadow: 0 2px 10px rgba(40,70,110,0.14);
    transition: transform 0.15s ease, background 0.2s ease;
  }
  .share-home-btn { left: 16px; }
  .share-replay-btn { right: 16px; }
  .share-home-btn:hover, .share-replay-btn:hover { transform: scale(1.1); background: rgba(255,255,255,0.55); }
  .share-home-btn:active, .share-replay-btn:active { transform: scale(0.92); }
  .share-home-btn svg, .share-replay-btn svg { width: 18px; height: 18px; display: block; filter: drop-shadow(0 1px 2px rgba(40,70,110,0.45)); }
  /* Onboarding tutorial — first-play coaching layer. Sits above the
     canvas but doesn't block input (pointer-events: none on container,
     only Skip catches clicks). Hidden after the player completes it. */
  #tutorial {
    position: absolute;
    inset: 0;
    z-index: 8;
    pointer-events: none;
  }
  #tutorial.hidden { display: none; }
  .tutorial-finger {
    position: absolute;
    top: 18%;
    left: 50%;
    font-size: 44px;
    line-height: 1;
    filter: drop-shadow(0 6px 14px rgba(0,0,0,0.28));
    transform: translate(-50%, 0);
    will-change: transform;
    animation: tutFingerSwipe 1.6s ease-in-out infinite;
  }
  /* Always the 3D Fluent hand (native 👆 renders flat/2D on some Android devices) */
  .tutorial-finger img { width: 48px; height: 48px; display: block; }
  @keyframes tutFingerSwipe {
    0%, 100% { transform: translate(calc(-50% - 60px), 0) rotate(-8deg); }
    50%      { transform: translate(calc(-50% + 60px), 0) rotate(8deg); }
  }
  .tutorial-caption {
    /* Below the animated finger (at top: 18%) but well above the
       body pile that builds up near the floor. */
    position: absolute;
    left: 50%;
    top: 32%;
    transform: translateX(-50%);
    background: linear-gradient(95deg, var(--accent), var(--accent-2));
    color: #fff;
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-weight: 700; font-size: 13px;
    letter-spacing: 0.2px;
    padding: 9px 18px;
    border-radius: 100px;
    box-shadow: 0 8px 18px rgba(0,0,0,0.16);
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
  }
  .tutorial-caption.show {
    animation: tutCaptionIn 0.4s ease forwards;
  }
  @keyframes tutCaptionIn {
    from { opacity: 0; transform: translate(-50%, 8px); }
    to   { opacity: 1; transform: translate(-50%, 0); }
  }
  /* When share modal is active: flatten the entire game container so
     the inner jar card is the ONLY visible chrome on screen. */
  body.share-active #game-canvas { visibility: hidden; }
  body.share-active #stage {
    background: transparent !important;
    box-shadow: none !important;
    border: none !important;
  }
  body.share-active #stage::before,
  body.share-active #stage::after { display: none !important; }
  #share-modal .share-top {
    display: flex; flex-direction: column; align-items: center;
    width: 100%;
  }
  #share-modal .share-bottom {
    display: flex; flex-direction: column; align-items: center;
    width: 100%;
  }
  #share-modal .share-brand {
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-size: 12px; font-weight: 800;
    letter-spacing: 3.2px;
    color: #ffffff;
    text-shadow: 0 1px 3px rgba(50, 30, 55, 0.4);
    margin-bottom: 6px;
    opacity: 0;
    animation: shareItemIn 0.5s ease 0.05s forwards;
  }
  #share-modal .share-score-big {
    font-size: 28px; font-weight: 900;
    letter-spacing: -0.4px;
    line-height: 1;
    /* White with a soft dark shadow so it stays readable + pops on every
       theme's light pastel bg, warm or cool. */
    color: #ffffff;
    text-shadow: 0 1px 3px rgba(50, 30, 55, 0.45), 0 2px 9px rgba(50, 30, 55, 0.3);
    margin-bottom: 4px;
    opacity: 0;
    animation: shareScoreIn 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) 0.15s forwards;
  }
  #share-modal .share-points-label {
    font-family: 'Fredoka', -apple-system, sans-serif;
    font-size: 10px; font-weight: 800;
    letter-spacing: 2.5px;
    color: rgba(255, 255, 255, 0.88);
    text-shadow: 0 1px 3px rgba(50, 30, 55, 0.4);
    margin-bottom: 0;
    opacity: 0;
    animation: shareItemIn 0.5s ease 0.25s forwards;
  }
  @keyframes shareItemIn {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  @keyframes shareScoreIn {
    0%   { opacity: 0; transform: scale(0.6); }
    60%  { opacity: 1; transform: scale(1.08); }
    100% { opacity: 1; transform: scale(1); }
  }

  /* The captured jar is a transparent body PNG; the card reproduces the
     in-game #stage glass EXACTLY so the share card reads as the same glossy
     glass jar: frosted gradient + diagonal sun-shine sit BEHIND the PNG (so
     they glow through the gaps between bodies, like in the live game), inset
     highlights form the bright glass rim/walls, and ::after is the vertical
     reflection stripe as front-glass gloss. */
  .share-jar-card {
    flex: 0 1 auto;
    position: relative;
    width: auto;
    max-width: 92vw;
    max-height: 62vh;
    border-radius: 30px 30px 24px 24px;
    overflow: hidden;
    background:
      radial-gradient(ellipse 55% 30% at 24% 12%,
        rgba(255,255,255,0.50) 0%, rgba(255,255,255,0.12) 46%, transparent 76%),
      linear-gradient(180deg,
        rgba(255,255,255,0.55) 0%,
        rgba(255,255,255,0.30) 16%,
        rgba(255,255,255,0.26) 50%,
        rgba(255,255,255,0.26) 100%);
    border: 1.5px solid rgba(255, 255, 255, 0.82);
    box-shadow:
      0 22px 48px rgba(40, 70, 110, 0.26),
      0 10px 18px rgba(40, 70, 110, 0.12),
      0 3px 8px rgba(40, 70, 110, 0.08),
      inset 0 3px 0 rgba(255,255,255,1.0),
      inset 0 8px 14px rgba(255,255,255,0.42),
      inset -4px 0 16px rgba(255,255,255,0.30),
      inset 4px 0 16px rgba(255,255,255,0.26);
    margin: 0;
    opacity: 0;
    animation: shareJarPop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) 0.32s forwards;
  }
  /* Vertical reflection stripe — front-glass gloss, same as #stage::after. */
  .share-jar-card::after {
    content: '';
    position: absolute;
    top: 12%; right: 6%;
    width: 7px; height: 48%;
    background: linear-gradient(180deg,
      transparent 0%,
      rgba(255,255,255,0.62) 28%,
      rgba(255,255,255,0.22) 75%,
      transparent 100%);
    border-radius: 8px;
    filter: blur(2.5px);
    pointer-events: none;
    z-index: 3;
  }
  #share-modal .share-jar-img {
    display: block;
    width: auto;
    height: auto;
    max-width: 92vw;
    max-height: 62vh;
  }
  @keyframes shareJarPop {
    0%   { transform: scale(0.7) rotate(-4deg); opacity: 0; }
    60%  { transform: scale(1.03) rotate(0.6deg);  opacity: 1; }
    100% { transform: scale(1) rotate(0); opacity: 1; }
  }
  /* Deliberately NOT the pink .btn treatment Share wears — this screen's own
     job is sharing, and two solid pills fighting each other would just make the
     screen louder without making either one clearer. Glass over the world's
     backdrop, with the art carrying the colour. */
  /* The world offer is the loudest thing on this screen — Share gave up its
     pulse and some of its width for it. A run just ended: sharing is optional,
     but a world they have never seen is what decides whether they open the game
     again tomorrow, and 25 ad-gated worlds between them get fewer plays than the
     one free one.
     Content-sized rather than pinned to Share's width: at a fixed width the
     longest world ("Play Halloween World") ellipsised on a 360px phone, cutting
     the one word the button exists to say. */
  #share-modal .share-world {
    display: inline-flex; align-items: center; gap: 9px;
    width: auto;
    max-width: min(96%, 344px);
    box-sizing: border-box;
    position: relative;
    margin-bottom: 12px;
    padding: 7px 11px 7px 7px;
    border-radius: 100px;
    border: 1.5px solid rgba(255,255,255,0.80);
    background: rgba(255,255,255,0.94);
    font-family: inherit; color: var(--ink);
    cursor: pointer;
    opacity: 0;
    animation:
      shareItemIn 0.45s ease 0.6s forwards,
      shareWorldPulse 1.9s ease-in-out 1.2s infinite;
  }
  @keyframes shareWorldPulse {
    0%, 100% { transform: scale(1);     box-shadow: 0 8px 20px rgba(60,40,70,0.20), 0 0 0 0 rgba(255,107,157,0.42); }
    50%      { transform: scale(1.035); box-shadow: 0 10px 24px rgba(60,40,70,0.24), 0 0 0 12px rgba(255,107,157,0); }
  }
  /* An explicit target to press. The pill was tappable everywhere but looked
     like a label, and a row with nothing button-shaped in it reads as
     information rather than as an offer. */
  #share-modal .share-world-go {
    flex-shrink: 0;
    width: 26px; height: 26px;
    border-radius: 50%;
    background: linear-gradient(150deg, #ff85ac 0%, #ff9f7e 100%);
    box-shadow: 0 3px 8px rgba(220,90,140,0.36);
    position: relative;
  }
  /* Play triangle drawn in borders, the same shape .play-tri uses in the picker,
     so the two controls speak the same language. */
  #share-modal .share-world-go::after {
    content: '';
    position: absolute; top: 50%; left: 50%;
    transform: translate(-42%, -50%);
    width: 0; height: 0;
    border-left: 8px solid #fff;
    border-top: 5.5px solid transparent;
    border-bottom: 5.5px solid transparent;
  }
  /* No thumbnail crop — it is one emoji on a transparent background, so let it
     be the emoji at full size rather than a 34px collage of nine creatures
     rendered at four pixels each, which said "a world" but never said WHICH. */
  #share-modal .share-world-art {
    width: 46px; height: 46px; flex-shrink: 0;
    object-fit: contain;
    filter: drop-shadow(0 3px 6px rgba(50,30,60,0.24));
  }
  #share-modal .share-world-label {
    flex: 0 1 auto; min-width: 0;
    font-size: 15px; font-weight: 800; letter-spacing: 0.1px;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    text-align: left;
  }
  @media (max-width: 400px) {
    #share-modal .share-world { gap: 7px; padding: 6px 9px 6px 6px; }
    #share-modal .share-world-art { width: 40px; height: 40px; }
    #share-modal .share-world-label { font-size: 14px; }
    #share-modal .share-world-go { width: 24px; height: 24px; }
  }
  /* The longest world makes the longest string — "Merge Halloween Squishies" —
     and it ellipsised on a 360px screen. Everything gives up a little so the
     world's name, the one word this button exists to say, always survives. */
  @media (max-width: 375px) {
    #share-modal .share-world { gap: 6px; padding: 5px 8px 5px 5px; }
    #share-modal .share-world-art { width: 36px; height: 36px; }
    #share-modal .share-world-label { font-size: 13px; letter-spacing: 0; }
    #share-modal .share-world-go { width: 22px; height: 22px; }
    #share-modal .share-world-go::after { border-left-width: 7px; border-top-width: 4.5px; border-bottom-width: 4.5px; }
  }
  #share-modal .share-world-label b { font-weight: 800; color: #c62a6c; }
  /* The tap spends a rewarded ad, so the row says so. Quiet, but never absent —
     the picker already prices every locked world this way and a button that hid
     the cost here would be the one place in the game that lied about it. */
  /* The cost sits ON the pill rather than in a column of its own: inline it ate
     ~35px and truncated "Play Mystic World" to an ellipsis on 360-390px phones,
     which is the one word the button exists to say. */
  /* The cost still has to be visible — the picker prices every locked world the
     same way and this must not be the one place that hides it. But a hard dark
     capsule was the single strongest "this is an advert" cue on the button, so
     it is now a quiet pastel chip that belongs to the game's palette. */
  #share-modal .share-world-ad {
    position: absolute;
    top: -4px; right: -2px;
    font-size: 7.5px; font-weight: 800; letter-spacing: 0.5px;
    color: #a2517d; padding: 2px 6px; border-radius: 999px;
    background: rgba(255, 228, 238, 0.96);
    box-shadow: 0 0 0 1.5px rgba(255,255,255,0.9);
    pointer-events: none;
  }
  /* The coin route shows the currency itself rather than a word — the same
     symbol the picker rows carry, so a player learns one mark and not two. No
     number on it: the price only means something next to the balance, and that
     pairing lives in the unlock card. */
  #share-modal .share-world-ad.is-coin {
    top: -5px; right: -4px;
    padding: 3px;
    display: grid; place-items: center;
    background: linear-gradient(160deg, #fff7dc 0%, #ffe6a8 100%);
    box-shadow: 0 0 0 1.5px rgba(255,255,255,0.95), 0 2px 5px rgba(150,110,20,0.26);
  }
  #share-modal .share-world-ad.is-coin img { width: 15px; height: 15px; display: block; }
  #share-modal .share-world:active { transform: scale(0.98); }
  @media (prefers-reduced-motion: reduce) {
    #share-modal .share-world { animation: shareItemIn 0.45s ease 0.6s forwards; }
  }

  #share-modal .share-cta {
    width: 54%;
    max-width: 196px;
    padding: 11px 24px;
    font-size: 14px; font-weight: 800;
    letter-spacing: 0.3px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    color: #fff;
    box-shadow:
      0 12px 26px rgba(255, 127, 156, 0.48),
      0 2px 6px rgba(255, 127, 156, 0.25);
    opacity: 0;
    /* Pulse removed and moved to the world button. Two pulsing things on one
       screen cancel each other out, and this screen's job has shifted: sharing
       is what a player does after a good run, unlocking a world is what brings
       them back tomorrow. */
    animation: shareItemIn 0.45s ease 0.5s forwards;
  }
  #share-modal .share-cta svg {
    stroke: #fff;
    margin-top: -1px;
  }
  @keyframes shareCtaPulse {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.04); }
  }
  #share-modal .btn-ghost {
    margin-top: 10px;
    opacity: 0;
    animation: shareGhostIn 0.4s ease 0.65s forwards;
  }
  @keyframes shareGhostIn {
    from { opacity: 0; }
    to   { opacity: 0.55; }
  }

  /* Info ("i") on home + Settings (cog) in gameplay. Pinned to the
     viewport's top-right corner via position: fixed — outside the wrap
     entirely, always at the absolute screen corner. */
  .floating-icon-btn {
    position: fixed;
    top: 12px;
    right: 12px;
    width: 22px; height: 22px;
    border-radius: 50%;
    /* Level 6: almost invisible until looked for. It was a white ring at 85%
       over a 38% white fill, which on the pale worlds was brighter than the HUD
       card beside it. */
    border: 1px solid rgba(255, 255, 255, 0.55);
    background: rgba(255, 255, 255, 0.26);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    color: #ffffff;
    opacity: 0.72;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    box-shadow: 0 1px 3px rgba(40, 70, 110, 0.10);
    transition: transform 0.15s ease, background 0.2s ease;
    padding: 0;
    z-index: 100;
  }
  /* Only the GEAR moves. .floating-icon-btn is shared with #info-btn, which
     lives on the home screen — moving the class sent both to the same corner. */
  /* Only the gear grows. .floating-icon-btn is shared with #info-btn on the
     home screen, and 22px was under half the 44px minimum touch target — on the
     game screen, where it sits alone in the corner over live gameplay, that is
     a button you have to aim at. */
  #settings-btn {
    left: 10px; right: auto; top: 10px;
    width: 38px; height: 38px;
    opacity: 0.82;
    border-color: rgba(255, 255, 255, 0.62);
    background: rgba(255, 255, 255, 0.30);
    box-shadow: 0 2px 6px rgba(40, 70, 110, 0.13);
  }
  #settings-btn svg { width: 18px; height: 18px; }
  /* Reach beyond the drawn circle so the thumb does not have to be precise. */
  #settings-btn::after {
    content: ''; position: absolute;
    top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: 46px; height: 46px;
  }
  .floating-icon-btn svg { width: 11px; height: 11px; fill: #ffffff; }
  .floating-icon-btn:hover { opacity: 1; }
  .floating-icon-btn:hover {
    transform: scale(1.15);
    background: rgba(255, 255, 255, 0.65);
  }
  .floating-icon-btn:active { transform: scale(0.92); }

  /* Info modal — single short attribution line + close. Same overlay
     glass language as the rest. */
  #info-modal .info-icon { font-size: 44px; line-height: 1; margin-bottom: 6px; filter: drop-shadow(0 4px 10px rgba(60, 140, 200, 0.3)); }
  #info-modal .info-title {
    font-size: 24px; font-weight: 800; margin-bottom: 14px; letter-spacing: -0.4px;
    background: linear-gradient(95deg, var(--accent), var(--accent-2));
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  #info-modal .info-text {
    font-family: 'Fredoka', -apple-system, "SF Pro Display", system-ui, sans-serif;
    font-size: 13px;
    line-height: 1.55;
    color: var(--ink-soft);
    max-width: 270px;
    text-align: center;
    margin-bottom: 20px;
  }
  #info-modal .info-text strong { color: var(--ink); font-weight: 700; }

  /* Settings modal — same overlay glass language as shop/ad. */
  #settings-modal .settings-icon { font-size: 44px; line-height: 1; margin-bottom: 6px; filter: drop-shadow(0 4px 10px rgba(120, 80, 200, 0.3)); }
  #settings-modal .settings-title {
    font-size: 24px; font-weight: 800; margin-bottom: 18px; letter-spacing: -0.4px;
    background: linear-gradient(95deg, var(--accent), var(--accent-2));
    -webkit-background-clip: text; background-clip: text; color: transparent;
  }
  .settings-rows {
    width: 100%; max-width: 280px;
    display: flex; flex-direction: column; gap: 10px;
    margin-bottom: 18px;
  }
  .settings-row {
    display: flex; align-items: center; justify-content: space-between;
    gap: 10px;
    background: rgba(255,255,255,0.78);
    border: 1px solid var(--glass-border);
    border-radius: 16px;
    padding: 10px 14px;
    box-shadow: var(--shadow-soft);
  }
  .settings-row-label {
    font-family: 'Fredoka', -apple-system, "SF Pro Display", system-ui, sans-serif;
    font-size: 15px; font-weight: 600;
    color: var(--ink);
    display: flex; align-items: center; gap: 8px;
  }
  .settings-row-label .icon { font-size: 18px; line-height: 1; }
  .settings-toggle {
    background: linear-gradient(135deg, #6fc850 0%, #4ea436 100%);
    color: white;
    border: none;
    padding: 6px 16px;
    border-radius: 100px;
    font-weight: 800; font-size: 11px;
    letter-spacing: 0.7px;
    cursor: pointer;
    min-width: 58px;
    transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
    box-shadow: 0 3px 8px rgba(80, 180, 60, 0.32), inset 0 1px 0 rgba(255,255,255,0.4);
    text-transform: uppercase;
  }
  .settings-toggle:active { transform: scale(0.95); }
  .settings-toggle.off {
    background: rgba(120, 80, 140, 0.18);
    color: var(--ink-soft);
    box-shadow: none;
  }
  /* Tiny attribution line at the bottom of the settings modal — replaces
     the separate Info modal so home + gameplay stay uncluttered. */
  .settings-attribution {
    font-family: 'Fredoka', -apple-system, "SF Pro Display", system-ui, sans-serif;
    font-size: 11px;
    color: var(--ink-soft);
    opacity: 0.75;
    margin-bottom: 14px;
    line-height: 1.4;
  }
  .settings-attribution strong { color: var(--ink); font-weight: 600; }

  /* =====================================================================
   *  HOME-SCREEN JUICE — entrance staggers + idle micro-anims.
   *  All animations use transform/opacity only (GPU-composited).
   *  Reduced-motion guard at the bottom of this block disables everything.
   * ===================================================================== */

  /* HUD panels fade + slide-down on first boot. Stagger by ~60ms across
     the four visible cells. Home-btn shares cell 1 with shop-btn and is
     display:none on the title screen, so the stagger order is
     shop → score → next → best. */
  .boot-revealing #hud > * {
    animation: hudPanelReveal 0.45s ease-out both;
  }
  .boot-revealing #hud > #shop-btn { animation-delay: 0ms; }
  .boot-revealing #hud > .score-panel { animation-delay: 60ms; }
  .boot-revealing #hud > .next-panel { animation-delay: 120ms; }
  .boot-revealing #tier-strip {
    animation: stripReveal 0.45s 600ms ease-out both;
  }
  @keyframes hudPanelReveal {
    from { opacity: 0; transform: translateY(-8px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  @keyframes stripReveal {
    from { opacity: 0; }
    to   { opacity: 1; }
  }

  /* Title-screen children — replays every time the screen mounts (boot
     and goHome). JS adds .is-revealing to #title-screen, removes ~1s later. */
  .is-revealing .title {
    animation: titleReveal 0.45s 100ms cubic-bezier(0.25, 1.45, 0.5, 1) both;
  }
  .is-revealing #theme-picker-label {
    animation: labelReveal 0.35s 320ms ease-out both;
  }
  .is-revealing #theme-picker .theme-row:nth-child(1) { animation: rowReveal 0.32s 240ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(2) { animation: rowReveal 0.32s 280ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(3) { animation: rowReveal 0.32s 320ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(4) { animation: rowReveal 0.32s 360ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(5) { animation: rowReveal 0.32s 400ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(6) { animation: rowReveal 0.32s 440ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(7) { animation: rowReveal 0.32s 480ms ease-out both; }
  .is-revealing #theme-picker .theme-row:nth-child(8) { animation: rowReveal 0.32s 520ms ease-out both; }
  .is-revealing #start-btn {
    animation: playReveal 0.5s 580ms cubic-bezier(0.25, 1.5, 0.5, 1) both;
  }
  @keyframes titleReveal {
    0%   { opacity: 0; transform: scale(0.92); }
    100% { opacity: 1; transform: scale(1); }
  }
  @keyframes labelReveal {
    from { opacity: 0; }
    to   { opacity: 0.65; }
  }
  @keyframes rowReveal {
    from { opacity: 0; transform: translateX(-6px); }
    to   { opacity: 1; transform: translateX(0); }
  }
  @keyframes playReveal {
    0%   { opacity: 0; transform: scale(0.86); }
    100% { opacity: 1; transform: scale(1); }
  }

  /* Idle micro-anims — continuous, gentle, GPU-friendly. The entrance
     animations above use the same transform property, so they cleanly
     hand off to these once the entrance class is removed. */
  .title {
    animation: titleBob 3s ease-in-out infinite;
  }
  #start-btn {
    animation: ctaBreathe 2.4s ease-in-out infinite;
    position: relative;
    overflow: hidden;
  }
  /* Gel-sheen sweep — a soft diagonal glint slides across the CTA every few
     seconds (long pause between), so the button reads as glossy candy. Pure
     transform on a pseudo (GPU-cheap, mobile-safe); off under reduced motion. */
  #start-btn::after {
    content: '';
    position: absolute;
    top: -20%; bottom: -20%;
    left: -40%;
    width: 34%;
    background: linear-gradient(100deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%);
    transform: translateX(0) skewX(-20deg);
    pointer-events: none;
    animation: ctaSheen 4.5s ease-out infinite;
  }
  @keyframes ctaSheen {
    0%   { transform: translateX(0) skewX(-20deg); }
    8%   { transform: translateX(430%) skewX(-20deg); }
    100% { transform: translateX(430%) skewX(-20deg); }
  }
  @media (prefers-reduced-motion: reduce) {
    #start-btn::after { animation: none; opacity: 0; }
  }
  /* Gentle float — visible at a glance but doesn't draw attention away
     from the theme list. */
  @keyframes titleBob {
    0%, 100% { transform: translateY(0)   scale(1); }
    50%      { transform: translateY(-3px) scale(1.02); }
  }
  @keyframes ctaBreathe {
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.025); }
  }

  /* Stronger tap squish on theme rows — overrides the earlier subtle
     0.985 with a more tactile 0.96 for mobile press feedback. */
  .theme-row:active { transform: scale(0.96); }

  /* Every world's boss-emoji gently floats — staggered into a soft wave down
     the list (via per-row delays) so the whole picker feels alive at a glance.
     Transform-only = GPU-composited, cheap even across all rows. */
  .theme-row-emoji {
    animation: themeEmojiFloat 3.2s ease-in-out infinite;
    will-change: transform;
  }
  .theme-row:nth-child(2n) .theme-row-emoji { animation-delay: -0.7s; }
  .theme-row:nth-child(3n) .theme-row-emoji { animation-delay: -1.5s; }
  .theme-row:nth-child(4n) .theme-row-emoji { animation-delay: -2.3s; }
  @keyframes themeEmojiFloat {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-3.5px); }
  }
  /* Selected world pops more — bigger bob + a playful tilt to draw the eye
     to the current pick. Overrides the gentle float above. */
  .theme-row.selected .theme-row-emoji {
    animation: selectedEmojiBob 2.4s ease-in-out infinite;
  }
  @keyframes selectedEmojiBob {
    0%, 100% { transform: translateY(0) scale(1) rotate(0deg); }
    30%      { transform: translateY(-6px) scale(1.09) rotate(-6deg); }
    65%      { transform: translateY(-2px) scale(1.05) rotate(5deg); }
  }

  /* Accessibility: respect reduced-motion. Kill all home-screen anims —
     elements still appear in their final state thanks to `both` fill mode. */
  @media (prefers-reduced-motion: reduce) {
    .boot-revealing #hud > *,
    .boot-revealing #tier-strip,
    .is-revealing .title,
    .is-revealing #theme-picker-label,
    .is-revealing #theme-picker .theme-row,
    .is-revealing #start-btn,
    .title,
    #start-btn,
    .theme-row-emoji,
    .theme-row.selected .theme-row-emoji {
      animation: none !important;
    }
    .theme-row:active { transform: none; }
  }
  body.ad-freeze, body.ad-freeze * { animation-play-state: paused !important; }

  /* Tall phones have height the playfield cannot use: the jar's width is capped
     at the viewport, and its height follows from that width, so it simply
     cannot grow. Filling the leftover WITH the jar would mean pushing the world
     back to roughly its old size and undoing the whole reason it was shortened.
     So the chrome takes the room instead — the HUD, the ladder and the buttons
     all breathe, and the playfield is left exactly as tuned. Below 800px tall
     none of this applies and everything stays compact. */

  @media (min-height: 800px) {
    :root { --vert-reserved: 240px; }

    .panel { padding: 5px 14px; border-radius: 18px; }   /* HUD stays compact so
       the ladder below gets the room instead */
    #powerup-bar { min-height: 40px; }
    #tier-strip { padding: clamp(4px, 0.7vh, 7px) clamp(10px, 2.6vw, 16px); }
    .tier-img { max-width: clamp(30px, 4.9vh, 44px); }
    .tier-progress { font-size: clamp(9.5px, 1.25vh, 11.5px); }
  }
  @media (min-height: 880px) {
    :root { --vert-reserved: 258px; }

  }


  /* Short screens: tighten the ladder rather than drop the caption — the
     caption is the part that gives a reason to take another turn. Placed at the
     END of the stylesheet on purpose: a media query adds no specificity, so
     anywhere above the base rules it would simply be overridden. */
  @media (max-height: 700px) {
    #tier-strip { gap: 2px; padding: 5px clamp(9px, 2.6vw, 14px); }
    .tier-row   { padding-top: 5px; }
    .tier-rail  { height: 9px; margin-top: 0; }
    .tier-progress { font-size: 8.2px; letter-spacing: 1.1px; }
    .tier-dot { width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px; }
    .tier-dot.next { width: 8px; height: 8px; margin: -4px 0 0 -4px; }
  }
  /* Below this there is genuinely no room for both. */
  @media (max-height: 600px) { .tier-progress { display: none; } }
  /* Short screens: the cloud keeps its shape but stops rising, which hands back
     the ~12px the HUD borrowed for it. */
  @media (max-height: 660px) {
    /* The lower zone compresses FIRST — gaps and insets before anything anyone
       has to read. This is what buys back the ~15px that was overflowing. */
    #wrap { gap: 5px; }
    #tier-strip { margin-top: 1px; }
    /* The extra shelf-to-controls breathing is a tall-screen luxury; here the
       thumb needs the bottom inset more than the eye needs the gap. */
    #powerup-bar { margin-top: 0; }
    body { padding-top: 4px;
           padding-bottom: max(13px, calc(env(safe-area-inset-bottom) + 7px)); }
    #hud { padding-top: 8px; }
    #hud > .score-panel { margin-top: -3px; }
    #hud > .action-panel { margin-top: 9px; }
  }

  /* ==================================================================
     HOME SCREEN REDESIGN
     ------------------------------------------------------------------
     Measured before touching anything, at 390x844 with all 33 worlds:
       - only 5 rows visible, and the 5th was half cut
       - 133px of dead space above the title
       - 53px between the completion line and the first card
       - each row 103px tall, of which the art was 90px and the rest padding
       - the most saturated pixels on the whole screen were the FREE / NEW
         badges: the least important information, shouting loudest
       - four stacked white layers per row: page, card, name chip, art tile
     The washed-out feeling is that last one. Colour was being spent on the
     wrong things and then diluted everywhere else.
     ================================================================== */

  /* ---- background: depth, not decoration ---------------------------- */
  body.home-active::before {
    content: '';
    position: fixed; inset: -10%;
    pointer-events: none; z-index: 0;
    /* Three soft colour clouds, wide and low-opacity. No particles, no stars,
       no texture — just enough variation that the page stops reading as one
       flat sheet behind the cards. */
    background:
      radial-gradient(60% 42% at 18% 12%, rgba(255,168,200,0.55) 0%, transparent 70%),
      radial-gradient(55% 38% at 88% 30%, rgba(255,214,166,0.50) 0%, transparent 72%),
      radial-gradient(70% 45% at 50% 92%, rgba(178,196,255,0.48) 0%, transparent 74%);
    filter: blur(6px);
  }
  body.home-active #wrap { position: relative; }
  body.home-active #wrap, body.home-active #title-screen { z-index: 1; }

  /* ---- header: tighter, and the completion state actually lands ------ */
  body.home-active .title {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: clamp(27px, 7.4vw, 33px); font-weight: 600;
    letter-spacing: -0.8px;
    /* The old ramp ended on #6bafff, and a pink-to-blue sweep is the single
       most generic treatment a mobile game title can have. Warm to rose, in
       the game's own family, with one soft lift underneath for depth. */
    background: linear-gradient(96deg, #ff8f6b 0%, #ff6b9d 55%, #d178cf 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    filter: drop-shadow(0 2px 4px rgba(180, 90, 140, 0.18));
    margin-bottom: 6px;
  }
  body.home-active .theme-picker-label {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 10.5px; letter-spacing: 1.6px; opacity: 0.55; margin-bottom: 6px;
  }
  body.home-active .theme-picker-label .progress-count {
    font-family: 'Fredoka', -apple-system, system-ui, sans-serif;
    font-size: 16px; font-weight: 600; opacity: 1;
    color: #d8608f; letter-spacing: 0.2px;
    vertical-align: -1px;
  }
  body.home-active .worlds-progress { margin: 5px auto 7px; height: 7px; width: 58%; max-width: 200px; }
  /* Completion is a state worth marking, and a bar that is simply full does not
     mark it. One slow sheen crossing a filled bar does — restrained enough to
     miss, which is the point. */
  body.home-active.worlds-complete .worlds-progress-fill::before {
    content: '';
    position: absolute; inset: 0;
    border-radius: 100px;
    background: linear-gradient(100deg, transparent 30%, rgba(255,255,255,0.85) 50%, transparent 70%);
    background-size: 260% 100%;
    animation: worldsSheen 4.6s ease-in-out infinite;
  }
  @keyframes worldsSheen {
    0%, 55%  { background-position: 180% 0; }
    85%, 100%{ background-position: -80% 0; }
  }
  #worlds-next {
    font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
    font-size: 13px; font-weight: 700; color: #7d6a86;
  }
  /* 53px of nothing sat between the completion line and the first card. */
  body.home-active .picker-wrap { margin-top: 2px; }
  body.home-active #stage { margin-top: 0; }
  body.home-active #title-screen {
    /* 14px here plus the HUD's own trailing space left 33px of nothing between
       the header row and the wordmark, so the top of the screen read as two
       unrelated blocks instead of one masthead. */
    padding: 2px 18px 10px;
    /* .overlay lays a 55% white wash over everything so gameplay reads through
       it as a blur. On the home screen there is no jar behind it — the wash was
       only draining the colour out of the page and is most of why the screen
       read as "white glass UI over a pastel background". Thinned so the ambient
       colour underneath actually arrives. The home backdrop is static, so this
       does not vary per world and the text contrast below is fixed. */
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* ---- rows: one surface per row, not four ------------------------- */
  .theme-row {
    gap: 12px;
    padding: 9px 12px;
    min-height: 88px;
    border-radius: 22px;
  }
  /* The name was sitting on its own frosted pill inside the card — a white
     shape on a white shape, which is most of why the screen read as pale. The
     word can simply BE the word. */
  /* The name gets a surface of its own again, tinted by the world it names.
     Bare ink on the card's soft radial had nothing to sit on, so thirty-three
     names read as thirty-three identical grey words and the only thing telling
     the rows apart was the picture. The chip takes its fill AND its ink from
     the same world accent the artwork already carries, so each label belongs to
     its world without any of them having to shout.
     Tint, never the raw accent: a saturated fill repeated down a column of 33
     is the loudest thing on the screen carrying the least urgent information. */
  .theme-row-name {
    background: linear-gradient(165deg, rgba(255,255,255,0.92), rgba(255,255,255,0.74));
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(80,50,100,0.07);
    padding: 3px 11px 4px; border-radius: 12px;
    font-size: clamp(19px, 5.4vw, 22px); letter-spacing: -0.4px;
    color: #4c3a5c;
  }
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-name {
      background: linear-gradient(165deg,
        color-mix(in srgb, var(--chip-c1) 30%, #fff) 0%,
        color-mix(in srgb, var(--chip-c2) 38%, #fff) 100%);
      /* A ring so a pale world's chip still has an edge — several accents are
         light enough that a 30% tint lands within a shade of the card itself. */
      box-shadow:
        inset 0 1px 0 rgba(255,255,255,0.85),
        0 0 0 1px color-mix(in srgb, var(--chip-c1) 26%, #fff),
        0 1px 3px rgba(80,50,100,0.09);
      color: color-mix(in srgb, var(--chip-c1) 42%, #3b2a4c);
    }
  }
  /* The chip's padding is new width the name did not have to pay for before,
     and on a 360px screen "Halloween" — the longest world in the catalogue —
     landed 2px over. The padding gives way, never the word: the name is the one
     thing this row exists to say. */
  @media (max-width: 380px) {
    .theme-row-name { padding: 3px 8px 4px; letter-spacing: -0.5px; }
  }
  .theme-row-body { gap: 3px; }
  .theme-row-goal { font-size: 13.5px; padding-left: 1px; color: rgba(48,32,62,0.62); }
  .theme-row-meta.is-progress { padding-left: 1px; padding-top: 3px; }
  .theme-row-bar { height: 5px; max-width: 118px; }
  .theme-row-art { width: 78px; height: 78px; }

  /* ---- badges: status, not focal points ---------------------------- */
  /* These were six solid saturated fills — the loudest colour on the screen,
     carrying the least important line of information on it. Same six hues,
     now as tinted labels: the hue still identifies the kind, the weight no
     longer competes with the play button. */
  .theme-row-tag {
    top: 10px; right: 12px;
    font-size: 8.5px; letter-spacing: 0.6px;
    padding: 4px 9px; border-radius: 99px;
    /* Its own glass surface. The card's ground is a soft radial with no edge,
       so a flat chip read as floating in mid-air above the row. */
    background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(255,255,255,0.70));
    border: 1px solid rgba(255,255,255,0.85);
    backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
    box-shadow: 0 2px 6px rgba(80,50,100,0.12);
  }
  .tag-done     { background: rgba(111,187,155,0.20); color: #3f8a6c; }
  .tag-most     { background: rgba(236,143,174,0.22); color: #c25e85; }
  .tag-new      { background: rgba(108,196,192,0.22); color: #3d9490; }
  .tag-popular  { background: rgba(121,168,216,0.22); color: #4d7cad; }
  .tag-premium  { background: rgba(166,145,207,0.22); color: #7a63ac; }
  .tag-seasonal { background: rgba(223,169,106,0.24); color: #b1793a; }
  .tag-free     { background: rgba(143,196,122,0.22); color: #55913c; }
  .tag-ready    { background: rgba(240,185,60,0.26); color: #96660f; }

  /* ---- play: the one control, made tactile ------------------------- */
  .theme-row-play {
    width: 50px; height: 50px;
    background:
      radial-gradient(120% 100% at 32% 20%, rgba(255,255,255,0.55) 0%, transparent 55%),
      linear-gradient(150deg, #ff8fb4 0%, #ff9d86 100%);
    box-shadow:
      0 7px 15px rgba(255,120,160,0.30),
      0 2px 4px rgba(90,40,90,0.14),
      inset 0 2px 0 rgba(255,255,255,0.70),
      inset 0 -3px 7px rgba(190,80,120,0.16);
    transition: transform 0.18s cubic-bezier(0.34,1.6,0.64,1), box-shadow 0.18s ease;
  }
  .theme-row:active .theme-row-play {
    transform: scale(0.90);
    box-shadow: 0 2px 6px rgba(255,120,160,0.26), inset 0 2px 6px rgba(160,60,100,0.22);
  }
  .play-tri { border-left-width: 14px; border-top-width: 9.5px; border-bottom-width: 9.5px; }

  /* ---- the direction that won, promoted ----------------------------
     Three were built and rendered at 390x844 side by side. A (soft premium)
     stayed too pale — the cards read as one white slab repeated 33 times.
     C (toy shelf) had lovely material but its asymmetric corners and under-glow
     do not survive at 88px row height. B wins because it is the only one where
     you can tell the worlds apart at a glance, and it does that by CONCENTRATING
     the colour on the artwork plate rather than smearing a tint across the whole
     card — which is the brief's own rule: vibrancy from world art, not from a
     saturated UI. C's pillow highlight and coloured under-glow were worth
     keeping and are folded in below; they add depth without adding colour area.
     -------------------------------------------------------------------- */
  .theme-row {
    background:
      linear-gradient(to bottom, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.08) 30%, transparent 52%),
      linear-gradient(96deg, var(--card-wash-1, transparent) 0%, transparent 58%),
      rgba(255,255,255,0.86);
    padding-left: 9px;
    box-shadow:
      0 14px 26px var(--card-glow, rgba(88,48,112,0.16)),
      0 6px 12px rgba(88,48,112,0.08),
      0 2px 3px rgba(88,48,112,0.05),
      inset 0 2px 2px rgba(255,255,255,0.98),
      inset 0 -2px 4px rgba(150,110,170,0.07);
  }

  /* The artwork plate. Deliberately a FRAME, not a panel: the preview images
     already carry their own light rounded ground, so a padded plate around one
     produced a coloured box holding a white box holding the art — the same
     nested-surface problem the name chip had. 3px of colour reads as an edge
     the art sits in. border-box because the img is sized at 100%: with the
     default content-box the padding pushed the artwork out of its own frame,
     which is what was clipping the bottom row of emoji. */
  .theme-row-art {
    box-sizing: border-box;
    /* One value, two consumers: the art box and the row height that has to
       contain it. They drifted apart once already and the art spilled over the
       row below, so neither is allowed its own number any more. */
    width: var(--row-art); height: var(--row-art);
    border-radius: 19px;
    padding: 3px;
    background:
      radial-gradient(85% 70% at 50% 16%, rgba(255,255,255,0.55) 0%, transparent 60%),
      linear-gradient(155deg, var(--chip-c1) 0%, var(--chip-c2) 100%);
  }
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-art {
      background:
        radial-gradient(85% 70% at 50% 16%, rgba(255,255,255,0.50) 0%, transparent 60%),
        linear-gradient(155deg,
          color-mix(in srgb, var(--chip-c1) 52%, #fff) 0%,
          color-mix(in srgb, var(--chip-c2) 52%, #fff) 100%);
    }
    box-shadow:
      inset 0 1.5px 0 rgba(255,255,255,0.50),
      0 4px 10px var(--card-glow, rgba(120,80,140,0.22));
  }
  .theme-row-art img {
    border-radius: 16px;
    filter: saturate(1.14) contrast(1.06) drop-shadow(0 2px 5px rgba(40,25,60,0.22));
  }
  /* Worlds whose preview never rendered fall back to a bare boss emoji, which
     has no ground of its own — it needs the padding the framed version does not. */
  .theme-row-art.is-fallback { padding: 12px; }

  /* A card that answers the finger. The row was already tappable but only the
     disc moved, so the object you pressed and the object that responded were
     different things. */
  .theme-row:active {
    transform: scale(0.975);
    box-shadow:
      0 6px 14px var(--card-glow, rgba(88,48,112,0.14)),
      0 2px 4px rgba(88,48,112,0.07),
      inset 0 2px 2px rgba(255,255,255,0.98);
  }


  /* ---- SHOP: part of the same game ---------------------------------
     It was a full-size white card with a diagonal red SALE band across the
     corner — the loudest, hardest-edged object on a screen made of soft pastel
     pills, and the only one shouting. It becomes the same glass pill the
     in-game WORLDS control uses, at a size that says "utility, not the point",
     and the sale becomes a small coral dot instead of a banner. */
  body.home-active #shop-btn {
    min-width: 0;
    flex-direction: row;
    gap: 7px;
    padding: 8px 14px 8px 11px;
    border-radius: 16px;
    background: linear-gradient(180deg, rgba(255,255,255,0.62), rgba(255,255,255,0.42));
    border: 1px solid rgba(255,255,255,0.72);
    backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.85),
      0 1px 2px rgba(80, 55, 110, 0.07),
      0 5px 13px rgba(80, 55, 110, 0.11);
    overflow: visible;
  }
  body.home-active #shop-btn .action-icon { width: 24px; height: 24px; order: 0; }
  body.home-active #shop-btn .label {
    order: 1; color: #7a5a78; opacity: 0.95;
    font-size: 11px; letter-spacing: 0.9px;
    text-shadow: none;
  }
  /* The 42px diagonal band becomes a dot. It still says "there is something on
     offer" — which is all a badge has to do — without cutting a hard red edge
     across a screen with no other hard edges on it. */
  body.home-active #shop-btn .corner-ribbon {
    top: -4px; left: auto; right: -4px;
    width: 12px; height: 12px;
    border-radius: 50%;
    overflow: visible;
    background: linear-gradient(180deg, #ff8f7a, #f2695f);
    box-shadow: 0 1px 3px rgba(190, 70, 60, 0.45), inset 0 1px 0 rgba(255,255,255,0.5);
    animation: shopDot 4.4s ease-in-out infinite;
  }
  body.home-active #shop-btn .corner-ribbon i { display: none; }
  @keyframes shopDot {
    0%, 78%, 100% { transform: scale(1); }
    88%           { transform: scale(1.25); }
  }
  @media (prefers-reduced-motion: reduce) {
    body.home-active #shop-btn .corner-ribbon { animation: none; }
  }

  /* Worlds were separating weakly once the plate was lifted 52% toward white —
     Garden and Kitty came out nearly the same pale grey-pink. 40% keeps them in
     the pastel register while giving the hue back enough presence to identify. */
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-art {
      background:
        radial-gradient(85% 70% at 50% 16%, rgba(255,255,255,0.48) 0%, transparent 60%),
        linear-gradient(155deg,
          color-mix(in srgb, var(--chip-c1) 62%, #fff) 0%,
          color-mix(in srgb, var(--chip-c2) 62%, #fff) 100%);
    }
  }
  /* 13px between cards on a list of 33 spends a whole row every four. */
  body.home-active #theme-picker { gap: 10px; }

  /* The world's picture is the only thing on this screen that says what a world
     actually IS — the name is a word and the goal line is a promise, but the
     art is the product. It gets the space.
     Declared on :root, not on body.home-active: a custom property that only
     exists under one class resolves to nothing everywhere else, and an invalid
     width on the art box is a collapsed row rather than a smaller one. */
  :root { --row-art: clamp(92px, 27vw, 112px); }


  /* ==================================================================
     WORLD GALLERY — composition pass
     ------------------------------------------------------------------
     The previous pass fixed the colour but left the shape: every world was a
     white rounded rectangle holding a coloured plate holding a white-grounded
     preview holding the art — three nested surfaces to show one picture — plus
     a badge, a bar and a 50px disc. Thirty-three of those in a column is a
     settings list, whatever colour it is.
     The correction is to stop drawing containers. Separation between worlds
     comes from spacing; emphasis comes from scale and weight. Below: the
     surface work is stripped back to one frame around the artwork, and three
     compositions are laid over that to be compared.
     ================================================================== */

  /* ---- shared: one frame, not three ------------------------------- */
  /* The preview image already carries its own light rounded ground — it IS a
     tile. So the coloured plate stops being a panel behind it and becomes a
     glow around it: the world's colour with no extra edge of its own. */
  .theme-row-art {
    padding: 0;
    background: none;
    box-shadow: none;
  }
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-art { background: none; }
  }
  .theme-row-art img {
    border-radius: 18px;
    box-shadow:
      0 0 0 2px color-mix(in srgb, var(--chip-c1) 40%, #fff),
      0 6px 14px var(--card-glow, rgba(120,80,140,0.28)),
      0 2px 4px rgba(60,40,80,0.10);
    filter: saturate(1.16) contrast(1.07);
  }
  @supports not (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-art img { box-shadow: 0 6px 14px var(--card-glow, rgba(120,80,140,0.28)); }
  }

  /* ---- shared: the play control loses its mass -------------------- */
  /* 50px of saturated pink repeated 33 times was the heaviest thing in the
     column and it out-weighed the artwork it was meant to serve. 42px, and the
     depth comes from an inner highlight rather than a large outer glow. */
  .theme-row-play {
    width: 42px; height: 42px;
    background:
      radial-gradient(120% 100% at 34% 18%, rgba(255,255,255,0.60) 0%, transparent 58%),
      linear-gradient(155deg, #ff90b2 0%, #ff9f87 100%);
    box-shadow:
      0 4px 10px rgba(255,120,160,0.26),
      0 1px 2px rgba(90,40,90,0.12),
      inset 0 1.5px 0 rgba(255,255,255,0.72),
      inset 0 -2px 5px rgba(190,80,120,0.16);
  }
  .play-tri {
    margin-left: 3px;
    border-left-width: 12px; border-top-width: 8px; border-bottom-width: 8px;
  }
  .theme-row:active .theme-row-play { transform: scale(0.88); }

  /* ---- shared: header, tightened --------------------------------- */
  body.home-active .worlds-progress { margin: 4px auto 5px; }
  /* "6% collected · next Fastfood" repeated what "WORLDS · 2/33" and the bar
     directly above it already said, and it was the most prominent line in the
     header on a screen whose job is to get the player to the worlds. */
  body.home-active #worlds-next { display: none; }
  body.home-active .picker-wrap { margin-top: 6px; }
  /* The peek fade was thick enough to grey out the name of the row underneath.
     4px only softens the cut. */
  #theme-picker {
    mask-image: linear-gradient(to bottom, transparent 0, #000 8px, #000 calc(100% - 4px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 8px, #000 calc(100% - 4px), transparent 100%);
  }

  /* ---- the composition that won -----------------------------------
     Three were built and compared at 390x844. A (no container at all, hairline
     separators) is the cleanest but the entries float — nothing holds a world
     together as one object. B (soft cushion) is still a card, just a quieter
     one. C wins: it gives each world an ATMOSPHERE instead of a container. The
     world's own colour pools around its artwork and is gone before it reaches
     the text, so a row is identified by the light it sits in rather than by a
     box drawn around it — which is the brief's "atmospheric accent", and it is
     the only one of the three that is both container-less and vibrant.
     -------------------------------------------------------------------- */
  .theme-row {
    border: 0;
    border-radius: 24px;
    box-shadow: none;
    padding: 10px 10px;
    min-height: calc(var(--row-art) + 20px);
    /* Anchored on the artwork and radial, so it has no edge anywhere — the
       first attempt was a linear gradient starting at full strength on the
       row's left, which drew exactly the vertical edge a container would. */
    overflow: visible;
    background: radial-gradient(62% 150% at 13% 50%,
      color-mix(in srgb, var(--chip-c1) 30%, transparent) 0%,
      color-mix(in srgb, var(--chip-c1) 10%, transparent) 40%,
      transparent 68%);
  }
  @supports not (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row {
      background: radial-gradient(58% 120% at 12% 50%,
        var(--card-wash-1) 0%, transparent 74%);
    }
  }
  /* The lift belongs to the artwork, not to a card. */
  .theme-row:active { transform: scale(0.978); box-shadow: none; }
  body.home-active #theme-picker { gap: 6px; }

  /* One frame. The 2px colour ring plus the preview's own light ground was two
     frames around one picture — the nesting the whole pass exists to remove.
     The world's colour is already in the glow behind it. */
  .theme-row-art img {
    /* The preview art already has its own rounded panel baked in, with
       transparent corners. A box-shadow is drawn on the element BOX, so it
       traced a second rectangle a few pixels outside that panel — the double
       frame. drop-shadow follows the alpha instead, so there is one shape. */
    box-shadow: none;
    filter:
      saturate(1.2) contrast(1.08)
      drop-shadow(0 8px 16px var(--card-glow, rgba(120,80,140,0.40)))
      drop-shadow(0 2px 4px rgba(60,40,80,0.20));
  }

  /* ==================================================================
     CALM-DOWN PASS — three things that had each grown on their own and
     stopped agreeing with one another.
     ================================================================== */

  /* ── 1. THEMES tile: same information, less furniture ──────────────
     A navigation control had ended up with more presence than the SCORE
     it sits beside: 9px of padding, a 25px globe and a 17px figure.
     Everything shrinks EXCEPT grid-template-rows, which is shared with
     .next-panel and is the only reason the two captions sit on one line
     — they have come apart once already over exactly this. */
  #home-btn { padding: 4px 8px 1px; border-radius: 12px; }
  #home-btn .action-icon { width: 18px; height: 18px; margin-right: 2px; }
  #home-btn .hud-badge { padding: 2px 8px 3px; font-size: 13.5px; }
  #home-btn .hud-badge::after { margin-left: 4px; font-size: 0.92em; }
  /* Both captions or neither: THEMES and NEXT are meant to read as one line
     across the top, so shrinking one of them is how the pair comes apart. */
  #home-btn .label, .next-panel .label { font-size: 9px; letter-spacing: 0.8px; }

  /* ── 2a. The ladder said everything twice ──────────────────────────
     Eleven dots on the rail, directly under eleven emoji, carrying the
     same eleven facts — the emoji are already lit, dimmed or enlarged.
     Only the one being worked toward survives, because "you are here" is
     the single thing the row of emoji does not say on its own.
     Hidden with opacity, not display: layoutTierDots() measures these to
     place the rail, and a display:none dot measures zero. */
  #tier-strip .tier-dot { opacity: 0; }
  #tier-strip .tier-dot.next,
  #tier-strip .tier-dot.is-goal-tier { opacity: 1; }

  /* ── 2b. One dock, not four loose stickers ─────────────────────────
     Four buttons in four hues at four different lightnesses read as four
     things from four different apps, floating on the world with nothing
     holding them together. A single tray gives them one edge and one
     ground, so the row becomes an instrument panel — and the tray is the
     HUD's glass, so the bottom of the screen now speaks the same language
     as the top. */
  #powerup-bar {
    box-sizing: border-box;
    padding: 6px;
    border-radius: 23px;
    background: linear-gradient(180deg, rgba(255,255,255,0.26), rgba(255,255,255,0.12));
    /* A hairline, not a rim. At full white the tray's own edge was the
       brightest line on the screen and it read as a box drawn around the
       buttons rather than as the surface they sit on. */
    border: 1px solid rgba(255,255,255,0.30);
    backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.38),
      0 4px 16px rgba(50, 40, 80, 0.10);
  }
  /* Same four hues, but the chroma is cut 30% and every fill is solved to
     land white text at 4.20:1 — one number, all four. The spread is what
     made them clash: they used to run 3.34 (Fill) to 4.69 (Twin), which is
     four different lightnesses as much as four different colours. Holding
     the contrast fixed holds the lightness fixed, and four hues at one
     lightness read as a set. Legibility improves at the same time.
     Recomputed, not eyeballed — see the ratios above. */
  .powerup-btn            { --pw-top: #968dcc; --pw-bot: #6054b3; --pw-solid: #7c72c0; --pw-shade: rgba(65, 55, 135, 0.26); }
  .powerup-btn.shuffle    { --pw-top: #c57968; --pw-bot: #a55340; --pw-solid: #bb634f; --pw-shade: rgba(123, 57, 42, 0.26); }
  .powerup-btn.fill       { --pw-top: #4d998a; --pw-bot: #3a7469; --pw-solid: #44877a; --pw-shade: rgba(40, 86, 77, 0.26); }
  .powerup-btn.twin       { --pw-top: #be78a6; --pw-bot: #9f4d83; --pw-solid: #b26096; --pw-shade: rgba(117, 52, 95, 0.26); }
  /* Inside a tray the buttons no longer need to lift themselves off the
     world — the tray does that — so the big ambient glow under each one
     comes down. Four glows inside one box was the other half of the mess. */
  .powerup-btn {
    box-shadow:
      inset 0 1.5px 0 rgba(255,255,255,0.46),
      inset 0 -1.5px 0 rgba(30, 20, 60, 0.10),
      0 2px 6px var(--pw-shade);
  }

  /* ── 3. Badges that sit ON the card instead of dissolving into it ──
     Every tag was a 20-26% wash of its hue, so what you actually saw was
     the row's own radial ground showing through a tint — and that ground
     is a different colour on all 33 worlds. The same badge therefore had
     33 appearances, none of them the one it was designed as.
     Opaque now: one lightness across all eight, a hairline edge of the
     same hue, and ink solved to 4.6:1 on its OWN fill rather than on
     whatever happens to be behind it. */
  .theme-row-tag {
    background: #fff;
    border: 0;
    backdrop-filter: none; -webkit-backdrop-filter: none;
  }
  .tag-done     { background: #deede7; box-shadow: inset 0 0 0 1px #bcdcce, 0 1px 3px rgba(80,50,100,0.13); color: #39735a; }
  .tag-most     { background: #f4d7e1; box-shadow: inset 0 0 0 1px #ebadc2, 0 1px 3px rgba(80,50,100,0.13); color: #b82455; }
  .tag-new      { background: #ddeeed; box-shadow: inset 0 0 0 1px #b9dfdd, 0 1px 3px rgba(80,50,100,0.13); color: #317370; }
  .tag-popular  { background: #dbe5f0; box-shadow: inset 0 0 0 1px #b4cce4, 0 1px 3px rgba(80,50,100,0.13); color: #32679e; }
  .tag-premium  { background: #e3deed; box-shadow: inset 0 0 0 1px #c7bbdd, 0 1px 3px rgba(80,50,100,0.13); color: #7050af; }
  .tag-seasonal { background: #f2e6d9; box-shadow: inset 0 0 0 1px #e8ceb0, 0 1px 3px rgba(80,50,100,0.13); color: #8e5c22; }
  .tag-free     { background: #e2edde; box-shadow: inset 0 0 0 1px #c5ddbb, 0 1px 3px rgba(80,50,100,0.13); color: #477336; }
  .tag-ready    { background: #f4ebd7; box-shadow: inset 0 0 0 1px #ebd7ad, 0 1px 3px rgba(80,50,100,0.13); color: #876319; }

  /* ── 4. The home header stops shouting its own name ────────────────
     The wordmark was 33px of gradient at the top of a screen the player
     only ever reaches from inside the game — they know what they are
     playing, and the thing they came here to read is the list. It keeps
     its identity and gives up its size.
     The line under it gains the word the number was missing: "11/33" is
     a fraction of nothing until something names it. And it gains a
     surface, because a bare uppercase line floating on the peach wash
     read as a caption for the wordmark rather than as a status of its
     own. */
  body.home-active .title {
    font-size: clamp(19px, 5.2vw, 23px);
    letter-spacing: -0.4px;
    margin-bottom: 7px;
  }
  body.home-active .theme-picker-label {
    display: inline-block;
    padding: 4px 13px 5px;
    border-radius: 999px;
    opacity: 1;
    color: rgba(92, 52, 94, 0.78);
    background: linear-gradient(180deg, rgba(255,255,255,0.80), rgba(255,255,255,0.58));
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.92),
      0 0 0 1px rgba(255,255,255,0.62),
      0 2px 6px rgba(140, 90, 130, 0.12);
    backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
    margin-bottom: 7px;
  }
  body.home-active .theme-picker-label .progress-count {
    font-size: 14.5px;
    color: #d0537f;
  }

  /* ── 5. The row becomes a card again, and the goal joins its world ──
     The atmosphere version — a radial pool of the world's colour under the
     artwork, gone before it reached the text — was deliberately
     container-less, on the argument that a box around each row reads as
     furniture. It does avoid that. What it also does is leave two thirds
     of every row sitting directly on the page wash, so the right-hand half
     (name, goal, progress, play) has no surface under it at all and the
     whole list reads as faded.
     So: a real card, but one still made of its own world rather than of
     grey. The colour pools where the artwork is and thins across to the
     right — the same gradient idea — except it now lands on a glass floor
     instead of on nothing, and the card has an edge and a lift so it is an
     object you could pick up. */
  .theme-row {
    background:
      radial-gradient(64% 140% at 14% 46%,
        color-mix(in srgb, var(--chip-c1) 30%, transparent) 0%,
        color-mix(in srgb, var(--chip-c1) 11%, transparent) 42%,
        transparent 70%),
      linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.46));
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.92),
      0 0 0 1px rgba(255,255,255,0.58),
      /* Tinted with the world's own colour, not grey: a grey shadow under a
         coloured object reads as dirt, a hue-matched one reads as light. */
      0 4px 14px var(--card-glow, rgba(120,80,140,0.16));
    backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
  }
  @supports not (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row {
      background:
        radial-gradient(60% 130% at 13% 46%, var(--card-wash-1) 0%, transparent 72%),
        linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0.46));
    }
  }
  /* The card answers the finger now that there is a card to press. */
  .theme-row:active {
    transform: scale(0.978);
    box-shadow:
      inset 0 2px 3px rgba(90, 55, 110, 0.10),
      0 0 0 1px rgba(255,255,255,0.5),
      0 1px 4px var(--card-glow, rgba(120,80,140,0.14));
  }
  /* The goal is the one line that differs between all 33 worlds — "Make a
     Whale", "Make a Lion" — and it was the same grey on every one of them.
     It takes the world's colour too, but a third less of it than the name:
     the name is what you pick, the goal is what you get. */
  .theme-row-goal { color: rgba(48, 32, 62, 0.62); }
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-goal { color: color-mix(in srgb, var(--chip-c1) 30%, #4a3560); }
  }

  /* ══════════════════════════════════════════════════════════════════
     ART DIRECTION — one ground, one action colour, thirty-three accents.
     ------------------------------------------------------------------
     What was wrong was not any single colour, it was that everything was
     an accent. The page ran warm pink at the top to PERIWINKLE at the
     bottom, so the lower rows sat on a cool ground while their artwork and
     tints stayed warm — that hue collision is what read as "dingy", not
     low saturation. On top of that, thirty-three cards each contributed
     their own hue at roughly the same strength as the page itself, so
     nothing was foreground.
     The fix is the oldest one there is: quiet the ground, and spend the
     colour where it means something.
       GROUND   one warm blush-to-cream family, no cool note anywhere
       ACTION   pink for play, amber for shop — the only saturated things
       ACCENT   each world's hue, pooled under its own artwork
     ══════════════════════════════════════════════════════════════════ */

  /* The bottom cloud was periwinkle (178,196,255). It is now a pale sand,
     so the ground drifts in VALUE down the page instead of in hue, and a
     world's colour is the only cool thing that can ever appear on it. */
  body.home-active::before {
    background:
      radial-gradient(60% 42% at 18% 12%, rgba(255,176,203,0.50) 0%, transparent 70%),
      radial-gradient(55% 38% at 88% 30%, rgba(255,219,178,0.46) 0%, transparent 72%),
      radial-gradient(74% 48% at 50% 94%, rgba(255,232,214,0.62) 0%, transparent 76%);
  }
  body.home-active::after { background: #ffd9c2; opacity: 0.16; }

  /* The one control on every row, and on a blush ground the old fill was
     within a few steps of the page itself. Deeper, warmer, and the only
     saturated pink left on the screen now that the ground has stepped back. */
  .theme-row-play {
    background:
      radial-gradient(120% 100% at 34% 18%, rgba(255,255,255,0.58) 0%, transparent 58%),
      linear-gradient(155deg, #fb7fa4 0%, #fb8f6d 100%);
    box-shadow:
      0 4px 11px rgba(233, 96, 126, 0.32),
      0 1px 2px rgba(90, 40, 90, 0.14),
      inset 0 2px 0 rgba(255,255,255,0.62),
      inset 0 -3px 7px rgba(190, 80, 120, 0.18);
  }

  /* Ink comes forward with the ground going back — the names and goals were
     tuned against a busier page and now have room to be read. */
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .theme-row-name { color: color-mix(in srgb, var(--chip-c1) 40%, #33243f); }
    .theme-row-goal { color: color-mix(in srgb, var(--chip-c1) 26%, #3f2c52); }
  }

  /* ── SHOP: the second thing you can press, so it looks like one ─────
     It was white glass with a grey-violet label — the same material as the
     THEMES tile, which is navigation, and quieter than every row below it.
     Shop is money, and the only other money on this screen is the coin
     chip beside it, so it takes the coin's amber. Two warm controls at the
     top, pink ones down the list, and nothing else saturated.
     Ink solved to 5.0:1 on its own fill rather than picked. */
  /* #hud #shop-btn, not #shop-btn: `body.home-active #hud .panel` carries two
     classes and an id and was quietly winning the background off this rule. */
  body.home-active #hud #shop-btn {
    padding: 9px 16px 9px 12px;
    border-radius: 18px;
    background: linear-gradient(180deg, #fff1d8 0%, #ffdfb4 100%);
    border: 1px solid #f3ce9b;
    box-shadow:
      inset 0 1.5px 0 rgba(255,255,255,0.95),
      0 2px 4px rgba(150, 100, 30, 0.10),
      0 7px 18px rgba(214, 150, 60, 0.26);
  }
  body.home-active #hud #shop-btn .action-icon { width: 27px; height: 27px; }
  body.home-active #hud #shop-btn .label {
    color: #8c5811;
    font-size: 11.5px; font-weight: 700; letter-spacing: 1px;
    opacity: 1;
  }
  /* ── The header was three things on three different lines ──────────
     SHOP's centre sat at y=45, the coin chip's at y=27 and the info dot's
     at y=23. Nothing was aligned to anything, which is what reads as
     "position thoda off" — the pill growing into an amber button only made
     the mismatch easier to see. All three now share one centre line, and
     each is placed from ITS OWN height so the line survives a size change.
     SHOP: top 22 + 47/2 = 45.5. */
  body.home-active .coin-chip-home { top: 28px; }       /* 45.5 - 35/2 */
  body.home-active .floating-icon-btn { top: 34px; }    /* 45.5 - 22/2 */

  /* ── Coin collection ───────────────────────────────────────────────
     One sprite per flight, positioned in px and animated with a transform
     only — no layout is touched while fourteen of these are in the air.
     They sit above the card (z 260) because they travel ACROSS it. */
  .coin-fly {
    position: fixed;
    z-index: 260;
    width: 22px; height: 22px;
    margin: -11px 0 0 -11px;       /* the px position is its centre */
    pointer-events: none;
    will-change: transform, opacity;
    filter: drop-shadow(0 2px 4px rgba(150, 105, 25, 0.45));
    animation: coinFly 460ms cubic-bezier(0.45, 0.02, 0.35, 1) both;
  }
  @keyframes coinFly {
    /* A small pop away from the jar first, so the coin reads as being SHED by
       it rather than as sliding along a wire. */
    0%   { transform: translate(0, 0) scale(0.45); opacity: 0; }
    14%  { transform: translate(calc(var(--dx) * 0.04), calc(var(--dy) * 0.04 - 16px)) scale(1.12); opacity: 1; }
    82%  { opacity: 1; }
    100% { transform: translate(var(--dx), var(--dy)) scale(0.60); opacity: 0; }
  }
  /* The figure takes the hit when the last coin lands. */
  .coin-land { animation: coinLand 380ms cubic-bezier(0.34, 1.6, 0.64, 1); }
  @keyframes coinLand {
    0%   { transform: scale(1); }
    34%  { transform: scale(1.16); }
    100% { transform: scale(1); }
  }
  #run-coins { display: inline-block; }
  @media (prefers-reduced-motion: reduce) {
    .coin-fly { display: none; }
    .coin-land { animation: none; }
  }

  body.home-active #hud #shop-btn:active {
    transform: scale(0.96);
    background: linear-gradient(180deg, #ffe7c4 0%, #f8d2a2 100%);
    box-shadow: inset 0 2px 4px rgba(150, 100, 30, 0.18), 0 1px 3px rgba(150, 100, 30, 0.12);
  }
