  :root {
    --bg: #FAF9F6;
    --surface: #F4EEDD;
    --surface-2: #EAE2CB;
    --ink: #23261E;
    --ink-soft: #6B6A5B;
    --purple: #7A5CA8;
    --purple-dark: #5B4180;
    /* Achievements/Stats tile-pairing shade (v289, reassigned v290) —
       sits close to --purple (not as far as --purple-dark) so the pair
       reads as subtly related rather than starkly two-tone. Used only
       by .tile-achievements; --purple-dark remains available elsewhere. */
    --purple-mid: #72559E;
    --orange: #D9822B;
    --orange-dark: #B0631A;
    --green: #3F8B5C;
    --green-dark: #2C6941;
    --line: rgba(35,38,30,0.14);
    --gold: #D9B85C;
    --gold-dark: #D6A427;
    --purple-tint: #DBD3E3;
    --orange-tint: #F2DCC5;
    --green-tint: #CDDFD1;
    --purple-bright: #9B6DDE;
    --orange-bright: #F5943A;
    --green-bright: #4CAF6E;
    --heart-red: #E5484D;
    --bg-practice: #ECEAE4;
    /* Red track — deliberately distinct from --heart-red (the multiplier
       badge's fixed red, unrelated to this track) so the two don't read
       as the same color when they're near each other on screen —
       confirmed as a real, separate design decision, not an oversight,
       when these were first chosen in the standalone playground. */
    --red: #C13B3B;
    --red-dark: #8F2A2A;
    --red-tint: #EFD5D3;
    --red-bright: #DB5252;
    /* Help tile (v312) — not one of the three track colors, so its own
       var rather than reusing one of theirs; same saturation/lightness
       family as --purple/--green so it reads as part of the same
       palette rather than a jarring addition. */
    --blue: #3E7CB8;
    /* Blue TRACK's own tint/dark shades — added when Blue became a real
       track (as opposed to just the help-tile accent above, which is
       this same --blue value). Derived the same way Purple/Orange/
       Green/Red's own -tint tokens were (a light desaturated version of
       the base color) rather than picked freshly; flag this as a
       provisional match to the existing palette family, not a
       confirmed final choice, the same way the other four's -tint
       tokens weren't questioned once set. --blue-dark is the empty-slot
       outline color, mirroring how --purple/--orange/--green/--red
       double as their own outline color already. */
    --blue-tint: #D3E0EA;
    --blue-dark: #2C5C8C;
    /* --blue-bright — the 4th standard per-track token, missed when
       -tint/-dark were added above; no current consumer (nothing yet
       needs a "bright" Blue the way Purple/Orange/Green's own -bright
       tokens get used elsewhere), but added now for parity with the
       other four tracks' complete token sets. Derived the same
       proportional lightness/saturation shift as the other four's
       base→bright deltas, same "provisional, not confirmed final"
       flag as -tint/-dark above. */
    --blue-bright: #4F97DC;
    /* v433 — Yellow's tokens (claude/yellow-design-doc.md §4), matched to
       the iOS 🟨 emoji. Added ahead of the Yellow track itself: the Horde
       already knows the color (horde.js). */
    --yellow: #EFC644;
    --yellow-bright: #F8DC69;
    --yellow-dark: #9E7A12;
    --yellow-tint: #FBF1D0;
    /* Tap-for-info badge popup backgrounds (v191) — faded/desaturated
       tints of Unlock's gray and the multiplier heart's red, following
       the existing -tint naming convention above. Purple/Orange/Green's
       own -tint tokens are reused directly for Bonus-6 popups. */
    --unlock-tint: #F1EFE9;
    --heart-tint: #F7DEDF;
    /* Nudge's own token (new power-up, same pool as Wild/Unlock) —
       settled on a charcoal after trying the steel-blue original and
       flat black in the playground; distinct from --ink (used for text/
       borders generally, not meant as a badge fill). */
    --nudge: #3A3A3A;
    /* Tap-for-info badge-row bubble tint (§21c) — same faded/desaturated
       role as --unlock-tint/--heart-tint above, following the same
       -tint naming convention. */
    --nudge-tint: #E2E2E0;
    /* Home page/modal corner radius (v270) — scoped to the home page's
       tiles, its two new modals (challenge-link, practice-confirmation),
       and the header's home button specifically (all four sharing one
       value by explicit request), not applied retroactively anywhere
       else in the existing game UI. */
    --home-radius: 14px;
  }

  /* Dark mode v1 — deliberately minimal: only --bg changes, nothing else
     in the palette is touched yet. This is a first pass specifically to
     see how the page reads with just the background swapped before
     deciding what else (if anything) needs its own dark treatment.
     html.dark-mode (not body) so the override reaches html's own
     background-color rule too, not just its descendants — custom
     properties cascade downward from wherever they're set. */
  html.dark-mode {
    --bg: #2B2B2B;
  }

  /* Practice-mode background tint — same technique and same reasoning as
     dark mode above (html, not body, so html's own background-color
     rule is covered too, not just its descendants). Deliberately doesn't
     account for dark mode + practice mode both being active at once —
     dark mode's own palette is still only a first pass (see the comment
     above), so that interaction isn't worth designing around yet. If
     both classes are ever applied together, whichever rule is later in
     this stylesheet wins for --bg, since both have equal specificity. */
  /* v415 — the html.practice-mode background tint was removed (Allen):
     Free Play now shares Daily's --bg. The class is still toggled by
     updateHeaderChrome() (board-render.js) but nothing styles it;
     --bg-practice is now unused. */

  * { box-sizing: border-box; }

  html {
    color-scheme: light;
    background-color: var(--bg);
    min-height: 100%;
    overflow-x: hidden;
  }

  #bg-fill {
    position: fixed;
    inset: 0;
    background: var(--bg);
    z-index: -1;
  }

  body {
    margin: 0;
    min-height: 100vh;
    min-height: 100dvh;
    background-color: var(--bg);
    font-family: 'Inter', sans-serif;
    color: var(--ink);
    padding: 28px 10px 60px;
    /* v278 — supersedes the v276 attempt: same corrected reasoning as
       .home-page's own bottom padding above (env(safe-area-inset-
       bottom) doesn't reflect Safari's toolbar height at all, only
       hardware safe areas) — a generous flat 120px fallback instead of
       trying to derive the right number, with the real safe-area inset
       still added on top for the home-indicator case specifically. */
    padding-bottom: calc(120px + env(safe-area-inset-bottom));
    display: flex;
    justify-content: center;
  }

  .wrap {
    width: 100%;
    max-width: 620px;
  }

  header {
    /* v232 — full-bleed to the page edge and flush to the very top:
       same 100vw breakout technique .board uses, plus a negative top
       margin to cancel out body's own 28px top padding (see body rule
       above). Ported from the header playground. */
    background: rgba(0,0,0,0.06);
    width: 100vw;
    margin-left: calc(50% - 50vw);
    margin-right: calc(50% - 50vw);
    margin-top: -28px;
    margin-bottom: 20px;
  }

  /* v232 — actual header content, centered/max-width same as the rest
     of .wrap, same pattern as .track-row-inner inside the full-bleed
     .board, so the title/buttons align with everything else instead
     of stretching edge to edge themselves. Holds the flex layout the
     old bare `header` rule used to have directly. */
  .header-inner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    max-width: 620px;
    margin: 0 auto;
    padding: 20px 10px 12px;
  }

  .header-title-group {
    display: flex;
    flex-direction: column;
    align-items: flex-end; /* v415 — logo sits on the right */
    gap: 2px;
  }

  h1 {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 26px;
    margin: 0;
    letter-spacing: 0.2px;
    color: var(--gold-dark);
  }

  /* v333 — TriRoll wordmark logo, replacing plain text in the game
     header and on the home page. Source asset is 958×274 (~3.5:1);
     each spot sets an explicit height with width:auto rather than
     relying on the image's own dimensions, so it scales cleanly at
     any display density. Flat #5F5F5F wordmark color, not tied to any
     --ink/--gold-dark variable — doesn't respond to dark mode's
     background swap the way the text it replaced did (dark mode's
     palette is still only a first pass, see §12 of design-doc.md; the
     home-page spot in particular was already low-contrast in dark mode
     before this change, so it's not a new regression there — the
     header spot, which previously used --gold-dark and read fine in
     dark mode, is the one place this is an actual step down). */
  .logo-header {
    height: 35px;
    width: auto;
    display: block;
  }

  .logo-home {
    height: 50px;
    width: auto;
    display: inline-block;
  }

  /* Today's date in daily mode, "Practice Game" in practice mode — set
     in updateHeaderChrome() (board-render.js), called every render() so
     it never drifts out of sync with whichever mode is actually live.
     v232 — moved out of .header-title-group to sit outside the header
     entirely (see index.html), restyled, and now built via .innerHTML
     with the date wrapped in .subtitle-date for daily mode specifically
     (see updateHeaderChrome() itself) so it renders on its own line. */
  .header-subtitle {
    font-family: 'Inter', sans-serif;
    font-size: 15px;
    font-weight: 700;
    color: rgba(35,38,30,0.85);
    max-width: 412px; /* matches .sb-zone's own box exactly, so the
                          left inset lines up with the star badge's own
                          inset on the opposite side */
    margin: 0 auto 10px;
    padding: 0;
    line-height: 1.3;
    /* v233 — practice mode's single-line "Practice Game" left less
       total header-to-board gap than daily's two-line "Daily
       Challenge:"/date, since this element was free to shrink to
       whatever content it actually held. min-height reserves the same
       two-line height (15px + 17px, both at line-height:1.3) either
       way, so the total gap matches. v235 — min-height alone wasn't
       enough: a single line still sat at the TOP of that reserved
       space, not where the date sits (the bottom line) in daily mode —
       confirmed via screenshot. flex + justify-content:flex-end
       bottom-aligns the content instead, so practice's one line lands
       exactly where daily's second line does; daily's own two lines
       already fill the box, so this has no visible effect there. */
    min-height: 42px;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
  .header-subtitle .subtitle-date {
    font-size: 17px;
    display: block;
  }

  /* Opens the home page (homePageOverlay) — always visible in both
     modes as of v270 (previously hidden until today's daily was
     complete). Same square-with-rounded-corners chrome as .help-btn for
     visual consistency with the rest of the header's icon buttons.
     v232 — enlarged (32px→42px circle → 42px rounded square).
     v270 — border-radius bumped from a flat 10px to var(--home-radius),
     matching the new home page/modal system (explicitly requested to
     share that value); glyph switched from a text character (☰) to an
     inline SVG house icon (see .home-glyph below), sized directly rather
     than via font-size/line-height. */
  .hamburger-btn {
    width: 42px;
    height: 42px;
    flex: none;
    border-radius: var(--home-radius);
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: rgba(35,38,30,0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .hamburger-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }
  .home-glyph {
    width: 22px;
    height: 22px;
    display: block;
  }

  .help-btn {
    width: 42px;
    height: 42px;
    flex: none;
    border-radius: 50%;
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: rgba(35,38,30,0.6);
    font-family: 'Inter', sans-serif;
    font-weight: 700;
    font-size: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .help-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }

  .theme-btn {
    width: 32px;
    height: 32px;
    flex: none;
    border-radius: 50%;
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: rgba(35,38,30,0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .theme-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }
  .theme-btn svg { width: 17px; height: 17px; display: block; }

  .top-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }

  .board {
    position: relative;
    background: transparent;
    padding: 0;
    border: none;
    box-shadow: none;
    width: 100vw;
    margin-left: calc(50% - 50vw);
    margin-right: calc(50% - 50vw);
  }

  .track-row {
    padding: 18px 0;
    overflow-x: clip; /* v439 (§192) — the score badge's star points can pass the screen edge on narrow phones; clip them here, not at the page */
    cursor: pointer; /* v191 — tap-for-info; tracks aren't otherwise clickable as a whole row */
  }
  .track-row[data-track="purple"] { background: var(--purple-tint); }
  .track-row[data-track="orange"] { background: var(--orange-tint); }
  .track-row[data-track="green"]  { background: var(--green-tint); }
  .track-row[data-track="red"]    { background: var(--red-tint); }
  /* Blue's own column badges hang below the grid itself (row badges
     sit to the side, no vertical concern there) — the base 18px bottom
     padding every other track's row uses is enough for a badge sitting
     right at a slot's own edge, but not enough once BLUE_GRID_EXTENSION
     pushes the badge further down first. Worst case is a heart badge
     (.mult-badge-wrap, 24px) at the current 14px extension: reaches
     ~26px below the slot's bottom edge, so 28px of clearance below the
     grid's true bottom edge clears it.
     v379 (§122) — this used to live entirely in this rule's own
     padding-bottom (10px top / 30px bottom, asymmetric on purpose).
     Now split: the row's own padding is symmetric (20px/20px, same
     40px total as before — no new height added), and the remaining
     10px of clearance moves into .blue-grid-wrap's own margin-bottom
     below, so the grid's wrapper ends up exactly as tall as the points
     table (§121) — letting both sit flush/centered with zero leftover
     offset from align-items:center, rather than needing the grid to be
     "centered" within a taller sibling. Total clearance below the
     grid's true bottom edge is unchanged: 10px (wrapper margin) + 20px
     (row padding) = 30px, same as before. */
  .track-row[data-track="blue"]   { background: var(--blue-tint); padding-top: 10px; padding-bottom: 20px; }

  .track-row-inner {
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: 620px;
    margin: 0 auto;
    padding: 0 18px;
  }

  /* Blue's own inner gap, wider than every other track's — the only
     track with 2 children in .track-row-inner (the grid + the points
     table, §121), so this doesn't touch anyone else's single-child row. */
  /* v383 (§126) — justify-content: center added back, per Allen's
     direct call. This was unsafe before §122: #slots-blue itself had
     flex: 1 (grow-to-fill), so on a wide screen it could claim
     hundreds of extra px of allocated width, and centering (or
     left-aligning) THAT oversized box just relocated the same
     ballooning-gap problem rather than fixing it. Since §122, neither
     child here grows — .blue-grid-wrap is flex: 0 1 auto and
     #slots-blue is width: fit-content — so the grid+table pair is
     always its own fixed, tightly-packed width (this gap included),
     and justify-content: center just slides that whole fixed-width
     pair to the middle of the row, the same way every other track's
     single (also non-growing-past-its-content, in practice)
     .slots ends up looking centered on the page. */
  .track-row[data-track="blue"] .track-row-inner {
    gap: 22px;
    justify-content: center;
  }

  /* v435 — Yellow's row (claude/yellow-design-doc.md §4, settled in
     claude/yellow-track-playground.html). Two row bands with the column-
     badge strip between them, an 8-entry points table on the right.
     Compact spacing: 8px row padding, 6px band padding, 8px strip — the
     row comes out 152px tall at phone width, the same as Blue's. The
     playground's color-mix() fills are precomputed here as flat colors
     (band: 24% --yellow into --yellow-tint = #F8E7AE; legal slot: 50%
     white over --yellow-tint = #FDF8E8; points box: --yellow at 16%). */
  .track-row[data-track="yellow"] { background: var(--yellow-tint); padding: 8px 0; }
  .track-row[data-track="yellow"] .track-row-inner {
    gap: 18px;
    justify-content: center;
  }
  .yellow-grid-wrap { flex: 0 1 auto; min-width: 0; }
  .yellow-grid { display: flex; flex-direction: column; }
  .yellow-band {
    position: relative;
    border-radius: 12px;
    padding: 6px;
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 52px));
    column-gap: 8px;
    background: #F8E7AE;
  }
  /* Badges overhang the bands slightly; the strip (z-index 1) paints
     over both. */
  .yellow-strip {
    position: relative;
    z-index: 1;
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 52px));
    column-gap: 8px;
    padding: 0 6px;
    height: 8px;
  }
  .yellow-strip-cell { position: relative; height: 8px; display: flex; align-items: center; justify-content: center; }
  .yellow-badge-wrap { position: relative; z-index: 1; flex: none; width: 18px; height: 18px; transform: rotate(10deg); }
  .yellow-badge-wrap.heart { width: 24px; height: 24px; }
  .points-table-box.yellow-points { margin-left: 0; background: rgba(239,198,68,0.16); }
  .yellow-points .points-table-wrap { grid-template-rows: repeat(4, 1fr); row-gap: 0; }
  .yellow-points .points-cell { height: 28px; }
  .yellow-points .points-star-face { background: var(--yellow-dark); }
  /* Empty-slot hint: angled "ODD"/"EVEN" (decided Oct 12). */
  .slot .hint.yellow-hint {
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: 0.5px;
    color: rgba(35,38,30,0.42);
    transform: rotate(-30deg);
  }

  .slots {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    flex: 1;
  }

  .connector-overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }

  .connector-float {
    position: absolute;
    width: 26px;
    height: 26px;
    display: flex;
    align-items: center;
    justify-content: center;
    transform: translate(-50%, -50%);
  }
  .connector-float svg {
    width: 100%;
    height: 100%;
    display: block;
    filter:
      drop-shadow(1px 0 0 var(--purple))
      drop-shadow(-1px 0 0 var(--purple))
      drop-shadow(0 1px 0 var(--purple))
      drop-shadow(0 -1px 0 var(--purple))
      drop-shadow(0.7px 0.7px 0 var(--purple))
      drop-shadow(0.7px -0.7px 0 var(--purple))
      drop-shadow(-0.7px 0.7px 0 var(--purple))
      drop-shadow(-0.7px -0.7px 0 var(--purple));
  }

  /* Red's minus-sign connectors — same technique as .connector-float
     above (white-stroke SVG bordered via an 8-direction drop-shadow
     filter) but a straight-line glyph instead of a chevron, and --red
     instead of --purple in the filter. Only between each RED_PAIRS
     pair's two slots (3 connectors across 6 slots), not between every
     adjacent slot the way Purple's chevrons are — see
     renderRedConnectors() in game.js. Box is 19px wide × 26px tall as of
     v209 (was 26×26, matching .connector-float exactly) — narrowed
     specifically, height left alone; the SVG's square 24×24 viewBox
     scales non-uniformly to fit, which is what gives the line's rounded
     end-caps their slightly elongated look at this width. */
  .red-connector {
    position: absolute;
    width: 19px;
    height: 26px;
    display: flex;
    align-items: center;
    justify-content: center;
    transform: translate(-50%, -50%);
    pointer-events: none;
  }
  .red-connector svg {
    width: 100%;
    height: 100%;
    display: block;
    filter:
      drop-shadow(1px 0 0 var(--red))
      drop-shadow(-1px 0 0 var(--red))
      drop-shadow(0 1px 0 var(--red))
      drop-shadow(0 -1px 0 var(--red))
      drop-shadow(0.7px 0.7px 0 var(--red))
      drop-shadow(0.7px -0.7px 0 var(--red))
      drop-shadow(-0.7px 0.7px 0 var(--red))
      drop-shadow(-0.7px -0.7px 0 var(--red));
  }

  /* v447 — chain Red's connector (RED_CHAIN, dormant): an up chevron
     over a down chevron between every pair of adjacent slots
     (RED_UPDOWN_SVG, board-render.js). Square box, the same red outline
     as .red-connector above. Settled in claude/red-track-playground.html. */
  .red-connector.red-chain-connector { width: 26.25px; height: 26.25px; }
  .red-connector.red-chain-connector svg { overflow: visible; }

  /* v447 — chain Red's "×2" pill on a filled ×2 slot (renderTrack(),
     board-render.js): white, red text, no border, straight, 19×14px,
     centered 3px in from the slot's top-right corner. Playground pick. */
  .red-x2-badge {
    position: absolute;
    top: -4px;
    right: -6.5px;
    width: 19px;
    height: 14px;
    border-radius: 999px;
    background: #fff;
    color: var(--red);
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 800;
    font-size: 11px;
    line-height: 1;
    letter-spacing: -0.5px;
    z-index: 3;
    pointer-events: none;
  }
  .red-x2-badge .x { position: relative; top: -0.04em; margin-right: 0.02em; }

  /* Blue's grid lines — a straight line between/beyond slots, same
     weight as Red's minus-sign connectors per spec ("similar line width
     as the red minus signs"), but drawn as a plain positioned div
     rather than an SVG icon, since these need to span a real measured
     distance (slot-to-slot, and out to each badge) rather than sit at
     one fixed-size icon spot. Positioned/sized entirely via inline
     style from renderBlueConnectors() (board-render.js) — real slot
     rects, same measurement technique every other connector already
     uses. */
  .blue-line {
    position: absolute;
    background: var(--blue);
    opacity: .7;
  }

  /* Blue's own badge-wrap — reuses .bonus-badge/.bonus-badge-<color>
     for the badge itself (renderBlueBadges(), board-render.js), just a
     differently-named wrap div so its positioning math (endpoint
     coordinates, not a slot's own bottom-center) stays independent of
     .bonus-badge-wrap's. */
  .blue-badge-wrap {
    position: absolute;
    width: 18px;
    height: 18px;
    transform: translate(-50%, -50%) rotate(10deg);
  }

  .slot {
    position: relative;
    flex: 1;
    aspect-ratio: 1;
    max-width: 52px;
    border-radius: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 19px;
    background: transparent;
    border: none;
    box-shadow: inset 0 0 0 3px rgba(35,38,30,0.16);
    color: var(--ink);
    transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
  }

  /* v433 — row-start direction cue: a tall double chevron floating in the
     row's side margin, just left of the first slot (renderTrack(),
     board-render.js). Anchored to that slot (.slot is position:relative)
     and takes no layout width, so no slot shrinks for it. On every
     left-to-right track; not Blue (any-order fill). Settled in
     claude/yellow-track-playground.html. */
  .row-chevron {
    position: absolute;
    top: 50%;
    right: calc(100% + 4px);
    transform: translateY(-50%);
    width: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: .75;
    pointer-events: none;
  }
  .row-chevron svg { width: 16px; height: 26px; display: block; }
  .row-chevron-purple { color: var(--purple-dark); }
  .row-chevron-orange { color: var(--orange-dark); }
  .row-chevron-green  { color: var(--green-dark); }
  .row-chevron-red    { color: var(--red-dark); }
  .row-chevron-yellow { color: var(--yellow-dark); }
  @media (max-width: 395px) {
    .row-chevron { width: 12px; right: calc(100% + 3px); }
    .row-chevron svg { width: 12px; }
  }

  /* v439 (§192) — track score badge: the track's raw score in a
     track-color star, floating 1px right of the last slot
     (renderTrack(), board-render.js). Anchored to that slot like
     .row-chevron, so it takes no layout width. Purple, Orange, Green,
     Red. Settled in claude/track-score-badge-playground.html. At 395px
     and under it shrinks to 20px with no gap; about 2px of the star's
     points clips at 375px (accepted). */
  .track-score {
    position: absolute;
    top: 50%;
    left: calc(100% + 1px);
    transform: translateY(-50%);
    width: 24px;
    height: 24px;
    /* v444 (§197) — no z-index: it stacks like .row-chevron, so the
       opened score table (.sb-tab-table, z-index 3) covers it. */
    pointer-events: none;
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 700;
    line-height: 1;
    font-size: 13.2px;
  }
  .track-score.len-2 { font-size: 11px; }
  .track-score.len-3 { font-size: 8.6px; }
  .track-score-face {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #fff;
    background: var(--tc);
    transform: rotate(5deg);
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }
  .track-score-purple { --tc: var(--purple); }
  .track-score-orange { --tc: var(--orange); }
  .track-score-green  { --tc: var(--green); }
  .track-score-red    { --tc: var(--red); }
  @media (max-width: 395px) {
    .track-score { width: 20px; height: 20px; left: 100%; font-size: 11px; }
    .track-score.len-2 { font-size: 9.2px; }
    .track-score.len-3 { font-size: 7.2px; }
  }
  /* Score pop (scorePopElapsed()/applyScorePop(), badges-connectors.js):
     one-shot, with a negative animation-delay so it survives rebuilds. */
  @keyframes scoreStarPop {
    0%   { transform: rotate(5deg) scale(1); }
    25%  { transform: rotate(-6deg) scale(1.35); }
    60%  { transform: rotate(9deg) scale(0.92); }
    100% { transform: rotate(5deg) scale(1); }
  }
  .score-pop { animation: scoreStarPop 0.4s ease-out both; }

  .slot.empty-purple { box-shadow: inset 0 0 0 3px var(--purple); background: var(--purple-tint); }
  .slot.empty-orange { box-shadow: inset 0 0 0 3px var(--orange); background: var(--orange-tint); }
  .slot.empty-green  { box-shadow: inset 0 0 0 3px var(--green); background: var(--green-tint); }
  .slot.empty-red    { box-shadow: inset 0 0 0 3px var(--red); background: var(--red-tint); }
  .slot.empty-yellow { box-shadow: inset 0 0 0 3px var(--yellow); background: var(--yellow-tint); } /* v435 */
  .slot.empty-blue   { box-shadow: inset 0 0 0 3px var(--blue); background: var(--blue-tint); }

  /* Blue's grid — the only track whose slots aren't a single flex row.
     .slot's own flex:1 has no effect once the parent is display:grid
     (flex properties are simply inert on a grid item), so the existing
     class is reused as-is rather than needing its own variant; only the
     container's layout mode changes. 2 rows x 4 cols, same gap as every
     other track's .slots for visual consistency.
     Columns use minmax(0, 52px) rather than a bare 1fr: with 1fr, each
     column track stretches to fill this flex:1 container's full
     available width (only 4 columns dividing up the whole row), while
     .slot's own max-width:52px caps the actual visible box well below
     that — leaving large, uneven per-column leftover space that never
     affected the ROWS (nothing forces the grid's own height to stretch
     the same way), so horizontal gaps ended up visibly wider than
     vertical ones. minmax(0, 52px) caps columns the same way rows
     already behave, still shrinking together on a narrow viewport.
     v379 (§122) — no longer a flex item itself, and no longer sets
     flex/justify-content (both removed; see .blue-grid-wrap below,
     which now owns that job). v378's own justify-content:start fix
     turned out to only paper over a worse problem: #slots-blue's
     flex:1 meant its ALLOCATED width (everything left over in the row
     after the points table) and its actual CONTENT width (~232px,
     capped by minmax above) only stayed close together on a narrow
     phone, where there's little leftover to begin with. On a wide
     viewport, flex:1 could hand it 400px+ of allocated space for 232px
     of real content — justify-content:start pinned the grid to the
     LEFT edge of that oversized box, but the points table (the next
     flex sibling) still started wherever the oversized box actually
     ENDED, not where the grid visibly did — a large, growing gap
     between the two on any wide screen. Moving flex-grow to 0 on the
     wrapper (not this element) means the pair's combined width is
     always exactly their real content width, gap included, regardless
     of viewport — nothing left over to create that gap in the first
     place. */
  #slots-blue {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 52px));
    grid-template-rows: repeat(2, 1fr);
    gap: 8px;
    width: fit-content;
  }

  /* The actual flex item in track-row-inner, replacing #slots-blue
     itself in that role (§122) — #slots-blue now sits inside this,
     an ordinary block child, not a flex participant. flex: 0 1 auto
     is the fix for the wide-screen gap bug above: grow:0 means this
     never claims more than its own content's width, so the grid and
     the points table always sit exactly gap-apart, at any viewport
     width; shrink:1 preserves the old narrow-phone behavior (the
     grid's own minmax(0, 52px) columns can still compress on a very
     tight screen). margin-bottom:10px exists purely so this wrapper's
     total height (112px grid + 10px = 122px) matches the points
     table's own ~122px (§121) almost exactly — with both
     track-row-inner children the same height, align-items:center
     needs no offset for either one; they're already flush. That 10px
     is also exactly why the row's own padding could become symmetric
     (20px/20px, see .track-row[data-track="blue"] above) without
     losing any badge clearance: the same 10px that used to live in
     the row's own asymmetric padding-bottom now lives here instead —
     total clearance below the grid's true bottom edge is unchanged. */
  .blue-grid-wrap {
    flex: 0 1 auto;
    margin-bottom: 10px;
  }

  /* Blue's own lines (.blue-line, #blueConnectors) deliberately run
     through every slot's center in a row/column, not just the gaps
     between slots the way every other track's connector icons do (those
     sit squarely in the gap, never overlapping a slot's box at all, so
     this never came up before). #blueConnectors is a later sibling of
     .track-row under .board, and neither it nor .slot sets an explicit
     z-index, so ties go to tree order — the connector layer, appearing
     later, was painting over the slots it passes through instead of
     staying hidden behind them. Scoped to Blue's own slots only; no
     effect on any other track's stacking. */
  #slots-blue .slot {
    z-index: 1;
  }

  /* Blue's points table — sits in the horizontal space next to Blue's
     2x4 grid (track-row-inner's own flex gap separates them, same 12px
     as every other spacing there). Deliberately NOT the overlay/
     getBoundingClientRect() pattern the other score badges use (Green,
     Red, Multiplier) — those measure real slot positions because their
     badges anchor to a specific slot; this table is a fixed 2x3 grid
     with no relationship to any one slot's position, so plain CSS grid
     is enough. Settled in blue-points-table-playground.html: all 6
     numerals live in fixed cells and never reflow — only which cell
     shows the star (the current score) changes, via renderBluePointsTable()
     (badges-connectors.js). Box background is --blue at 5% opacity;
     written as a literal rgb triple (62,124,184) since a custom
     property can't be dropped into rgba()'s component list directly. */
  .points-table-box {
    background: rgba(62,124,184,0.10);
    border-radius: 12px;
    padding: 4px;
    margin-left: 28px;
  }
  .points-table-label {
    text-align: center;
    font-family: 'Inter', sans-serif;
    font-weight: 700;
    font-size: 10px;
    letter-spacing: 0.4px;
    text-transform: uppercase;
    color: rgba(35,38,30,0.38);
    margin: 0 0 2px;
  }
  .points-table-wrap {
    display: grid;
    grid-template-columns: repeat(2, 32px);
    grid-template-rows: repeat(3, 1fr);
    row-gap: 2px;
    column-gap: 0px;
  }
  .points-cell {
    position: relative;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .points-cell .cell-number {
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 700;
    font-size: 15px;
    color: rgba(35,38,30,0.38);
    transition: opacity 0.15s ease;
  }
  .points-cell.current .cell-number { opacity: 0; }

  /* Same star/burst shape as .corner-score/.pair-score, --blue instead
     of --green/--red, same 5deg tilt as Green's .corner-score-wrap —
     "I like it tilted", confirmed directly in the playground. */
  .points-star {
    position: absolute;
    inset: 0;
    margin: auto;
    width: 32px;
    height: 32px;
    opacity: 0;
    transform: rotate(5deg);
    pointer-events: none;
  }
  /* v439 (§192) — the old scale-in transition is gone: the table is
     rebuilt every render(), so it never played. The current star pops
     with .score-pop (scoreStarPop below) when the score changes. */
  .points-cell.current .points-star { opacity: 1; }
  .points-star-face {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 700;
    font-size: 16px;
    background: var(--blue);
    color: #fff;
    line-height: 1;
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }

  .pip-grid {
    position: relative;
    z-index: 1;
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(3, 1fr);
    width: 62%;
    height: 62%;
    gap: 10%;
  }
  .pip-cell {
    border-radius: 50%;
    align-self: center;
    justify-self: center;
    width: 100%;
    height: auto;
    aspect-ratio: 1;
  }
  .pip-cell.active {
    background: #fff;
  }

  .slot .hint {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 17px;
    font-weight: 500;
    color: rgba(35,38,30,0.4);
    font-family: 'Inter', sans-serif;
  }

  .corner-score {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 700;
    font-size: 13px;
    background: var(--green);
    color: #fff;
    line-height: 1;
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }

  .corner-score-wrap {
    position: absolute;
    width: 23px;
    height: 23px;
    transform: translate(-50%, -50%) rotate(5deg);
  }

  /* Red's pair-score badge — same star/burst shape and font as
     .corner-score above (just --red instead of --green), sized up to
     30x30 (vs. corner-score's 23x23) so two-character negative scores
     ("-10") have room inside the points without overflowing — found
     necessary in the standalone playground, not a guess. Bridges over a
     RED_PAIRS pair at the top rather than anchored to one slot's corner
     — see renderRedPairBadges() in game.js. Font-size varies by how many
     characters the score needs (set inline per-badge in JS, not here) —
     single digits render larger than the 3-character "-10" case. Always
     present at every pair, empty until that pair is filled, rather than
     fading in — a direct request during the playground pass, not the
     default corner-score behavior. */
  .pair-score-wrap {
    position: absolute;
    width: 30px;
    height: 30px;
    transform: translate(-50%, -50%); /* straightened at v208 — no rotate(), unlike Green's tilted .corner-score-wrap this was copied from */
  }
  .pair-score {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace;
    font-weight: 700;
    background: var(--red);
    color: #fff;
    line-height: 1;
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }

  .badge-overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }

  .bonus-badge-wrap {
    position: absolute;
    width: 18px;
    height: 18px;
    transform: translate(-50%, -50%) rotate(10deg);
  }

  /* Slightly larger than .bonus-badge-wrap (24px vs 18px) — the heart
     shape doesn't fill its bounding box edge-to-edge the way a solid
     square badge does, so at matching box sizes it read visibly smaller
     than Wild/Unlock/Bonus-6 next to it. Same position/rotation formula
     as the other badges either way (see renderMultiplierBadges()). */
  .mult-badge-wrap {
    position: absolute;
    width: 24px;
    height: 24px;
    transform: translate(-50%, -50%) rotate(10deg);
  }

  .bonus-badge {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 4px;
    color: #fff;
    font-family: 'JetBrains Mono', 'Inter', sans-serif;
    font-weight: 700;
    font-size: 10px;
    line-height: 1;
    transition: filter 0.15s ease;
  }
  .bonus-badge.used {
    filter: grayscale(0.6) brightness(0.7);
  }
  /* Activation wiggle — same puBadgeWiggle keyframe used for the power-up
     tray's landing feedback, reused here so both animations read as the
     same visual language. See renderBonusBadges() for playback timing. */
  .bonus-badge.bonus-badge-wiggle {
    animation: puBadgeWiggle 0.4s ease-in-out;
  }
  .bonus-badge-purple { background: var(--purple); }
  .bonus-badge-orange { background: var(--orange); }
  .bonus-badge-green  { background: var(--green); }
  .bonus-badge-red    { background: var(--red); }
  .bonus-badge-blue   { background: var(--blue); }
  /* v435 — dark glyph: white on this yellow is too low-contrast. */
  .bonus-badge-yellow { background: var(--yellow); color: var(--ink); }

  .bonus-glyph {
    display: inline-block;
    transform: scale(1.5);
  }
  .bonus-badge-wild {
    /* Same SVG-wedge fix as .die.color-wild — see comment there. */
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNNTAsNTAgTDUwLDAgTDEwMCwwIEwxMDAsNTAgWiIgZmlsbD0iIzlCNkRERSIvPjxwYXRoIGQ9Ik01MCw1MCBMMTAwLDUwIEwxMDAsMTAwIEw1MCwxMDAgWiIgZmlsbD0iI0Y1OTQzQSIvPjxwYXRoIGQ9Ik01MCw1MCBMNTAsMTAwIEwwLDEwMCBMMCw1MCBaIiBmaWxsPSIjNENBRjZFIi8+PHBhdGggZD0iTTUwLDUwIEwwLDUwIEwwLDAgTDUwLDAgWiIgZmlsbD0iI0Y0RUVERCIvPjwvc3ZnPg==");
    background-size: cover;
  }
  .bonus-badge-wild .pip-grid { width: 66%; height: 66%; }
  .bonus-badge-unlock { background: #E8E8E8; color: var(--ink); }
  .bonus-badge-unlock svg { width: 11px; height: 11px; display: block; }
  /* Nudge's badge — real .bonus-badge/.bonus-glyph markup (NUDGE_GLYPH_HTML
     in board-render.js), matching Bonus-6's own construction rather than
     Wild/Unlock's icon technique. Self-contains font-family/size/weight
     rather than relying on inheriting .bonus-badge's own (10px) — this
     class is reused on .bank-flight-badge (the flying bank-charge clone,
     badges-connectors.js) too, which doesn't have the .bonus-badge class
     itself, only .bonus-badge-nudge.

     Character-level styling (font-family/vertical offset only, no
     size) lives in .nudge-pm-char/.nudge-one-char below, shared across
     every context. Actual SIZE is set per-context via .bonus-glyph's own
     scale — deliberately NOT inline on the glyph spans (an earlier
     version set font-size:1.3em inline, which compounded unpredictably
     with each context's own .bonus-glyph scale rule; worst case, the
     tap-for-info popup's generic scale(2.4) — sized for Bonus-6's single
     digit — won a same-specificity/later-source-order tie against the
     intended smaller nudge override, rendering "±1" wider than its own
     32px badge box). Target size in each context is tuned against what
     Bonus-6's own glyph looks like there, not just scaled up from this
     18px version — see .pu-glyph-nudge and .badge-bubble-icon-wrap
     .bonus-badge-nudge below for the other two. */
  .nudge-pm-char {
    font-family: 'Anton', sans-serif;
    position: relative;
    top: -2px;
  }
  .nudge-one-char {
    position: relative;
    top: 0px;
  }
  .bonus-badge-nudge {
    background: var(--nudge);
    color: #fff;
    font-family: 'JetBrains Mono', 'Inter', sans-serif;
    font-weight: 700;
    font-size: 10px;
    line-height: 1;
  }
  /* scale(1.5) — same nominal height Bonus-6's own default .bonus-glyph
     rule gives a single digit at this 10px base (10×1.5=15px); the
     letter-spacing is what actually handles "±1" being two characters,
     not a smaller overall scale. Playground-validated. */
  .bonus-badge-nudge .bonus-glyph {
    transform: scale(1.5);
    letter-spacing: -0.5px;
  }

  /* Bank-flight animation: a throwaway clone of the just-triggered badge,
     spawned at its slot and flown toward a destination while fading —
     feedback that something is heading somewhere else. Used for Wild/
     Unlock banking to the power-up tray (spawnBankFlight, held 300ms
     then a 380ms flight) and for Bonus-6 flying to the slot it's about
     to fill (spawnBonusSixFlight, held 300ms then a 300ms flight — the
     actual number placement is held to match, landing in sync). */
  .bank-flight-badge {
    position: fixed;
    width: 18px;
    height: 18px;
    border-radius: 4px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--ink);
    transform: translate(-50%, -50%) rotate(10deg);
    opacity: 1;
    transition: transform 0.38s ease-out, opacity 0.38s ease-out;
    z-index: 40;
    pointer-events: none;
  }
  .bank-flight-badge svg { width: 11px; height: 11px; display: block; }

  /* Multiplier-slot flight — same technique again, held 300ms then a
     380ms flight (matching spawnBankFlight's timing), landing on the
     scoreboard's star badge with the same wiggle treatment. Now a copy
     of the heart-with-× badge (see .mult-badge) rather than the old
     striped-circle icon — dropped the circular clip since the heart
     has its own shape and doesn't need one. */
  .mult-flight-badge {
    position: fixed;
    width: 24px;
    height: 24px;
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLDIxLjM1bC0xLjQ1LTEuMzJDNS40LDE1LjM2LDIsMTIuMjgsMiw4LjUgMiw1LjQyLDQuNDIsMyw3LjUsM2MxLjc0LDAsMy40MSwwLjgxLDQuNSwyLjA5QzEzLjA5LDMuODEsMTQuNzYsMywxNi41LDMgMTkuNTgsMywyMiw1LjQyLDIyLDguNWMwLDMuNzgtMy40LDYuODYtOC41NSwxMS41NEwxMiwyMS4zNXoiIGZpbGw9IiNFNTQ4NEQiLz48ZyBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxsaW5lIHgxPSI5LjQiIHkxPSI4LjQiIHgyPSIxNC42IiB5Mj0iMTMuNiIvPjxsaW5lIHgxPSIxNC42IiB5MT0iOC40IiB4Mj0iOS40IiB5Mj0iMTMuNiIvPjwvZz48L3N2Zz4=");
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
    transform: translate(-50%, -50%) rotate(10deg);
    opacity: 1;
    transition: transform 0.38s ease-out, opacity 0.38s ease-out;
    z-index: 40;
    pointer-events: none;
  }

  .lock-overlay-wrap {
    position: absolute;
    inset: 0;
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    opacity: 0;
    transform: scale(0.7);
    transition: opacity 0.5s ease, transform 0.5s ease;
  }
  .lock-overlay-wrap.visible { opacity: 0.7; transform: scale(1); }
  .lock-overlay-wrap.fading { opacity: 0; transform: scale(1.15); }
  .wild-reveal-overlay {
    position: absolute;
    inset: 0;
    z-index: 2;
    border-radius: inherit;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transition: opacity 0.25s ease;
    pointer-events: none;
    will-change: opacity;
  }
  .wild-reveal-overlay.covering { opacity: 1; }
  .wild-reveal-overlay.to-wild {
    /* Same SVG-wedge fix as .die.color-wild — see comment there. */
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNNTAsNTAgTDUwLDAgTDEwMCwwIEwxMDAsNTAgWiIgZmlsbD0iIzlCNkRERSIvPjxwYXRoIGQ9Ik01MCw1MCBMMTAwLDUwIEwxMDAsMTAwIEw1MCwxMDAgWiIgZmlsbD0iI0Y1OTQzQSIvPjxwYXRoIGQ9Ik01MCw1MCBMNTAsMTAwIEwwLDEwMCBMMCw1MCBaIiBmaWxsPSIjNENBRjZFIi8+PHBhdGggZD0iTTUwLDUwIEwwLDUwIEwwLDAgTDUwLDAgWiIgZmlsbD0iI0Y0RUVERCIvPjwvc3ZnPg==");
    background-size: cover;
  }
  .die .wild-reveal-overlay.to-wild .pip-cell.active { background: #fff; }
  .wild-reveal-overlay.to-plain {
    background: var(--surface);
    box-shadow: 0 0 0 1px #D6D6D6;
  }
  .die .wild-reveal-overlay.to-plain .pip-cell.active { background: var(--ink); }
  .lock-overlay-wrap svg {
    width: 78%;
    height: 78%;
    display: block;
    color: var(--ink);
    filter:
      drop-shadow(1px 0 0 #fff)
      drop-shadow(-1px 0 0 #fff)
      drop-shadow(0 1px 0 #fff)
      drop-shadow(0 -1px 0 #fff)
      drop-shadow(0.7px 0.7px 0 #fff)
      drop-shadow(0.7px -0.7px 0 #fff)
      drop-shadow(-0.7px 0.7px 0 #fff)
      drop-shadow(-0.7px -0.7px 0 #fff);
  }
  .lock-overlay-wrap.flash svg { color: var(--ink); }

  .power-up-box {
    max-width: 400px;
    margin: 6px auto 14px;
    background: rgba(0,0,0,0.06);
    border-radius: 12px;
    padding: 6px 10px 10px;
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 44px;
  }
  .power-up-top-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    margin-bottom: 6px;
  }
  .power-up-label {
    font-size: 12px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: rgba(35,38,30,0.45);
  }
  .power-up-cards-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
    width: 100%;
  }
  .power-up-empty {
    font-size: 11.5px;
    color: rgba(35,38,30,0.35);
    padding: 2px 2px;
  }
  .power-up-card {
    flex: none;
    width: 160px;
    display: flex;
    align-items: center;
    gap: 10px;
    background: var(--bg);
    border-radius: 10px;
    padding: 8px 10px;
    cursor: pointer;
    touch-action: manipulation;
    border: none;
    transition: box-shadow 0.15s ease, opacity 0.38s ease-out;
  }
  .power-up-card.armed { box-shadow: 0 0 0 2.5px var(--gold); }
  .power-up-card.no-target {
    opacity: 0.55;
    cursor: default;
    pointer-events: none;
  }
  /* Card-level reveal for a 0→1 charge: starts fully transparent (see
     buildPowerUpCard's inline opacity:0) and stays that way through the
     bank-flight's 300ms hold. The fade to visible is triggered in
     spawnBankFlight() the instant the badge actually launches, so the
     card only becomes visible while the badge is in the air — the
     opacity transition above (0.38s) matches the flight's own travel
     time, landing together. See cardRevealPending. */
  .power-up-card .pu-icon {
    position: relative;
    width: 36px;
    height: 36px;
    border-radius: 9px;
    flex: none;
    display: flex; align-items: center; justify-content: center;
  }
  /* Shared wiggle keyframe — used for both the power-up tray's landing
     feedback (pu-icon.pu-badge-wiggle) and the track badges' activation
     feedback (bonus-badge.bonus-badge-wiggle). Grows to its largest
     over the first quarter of the duration, then eases back down to
     normal size by the end, layered on top of the rotation wiggle. */
  @keyframes puBadgeWiggle {
    0%   { transform: rotate(0deg) scale(1); }
    15%  { transform: rotate(-12deg) scale(1.1); }
    25%  { transform: rotate(-4deg) scale(1.18); }
    45%  { transform: rotate(10deg) scale(1.1); }
    65%  { transform: rotate(-6deg) scale(1.04); }
    85%  { transform: rotate(4deg) scale(1.01); }
    100% { transform: rotate(0deg) scale(1); }
  }
  .power-up-card .pu-icon.pu-badge-wiggle {
    animation: puBadgeWiggle 0.4s ease-in-out;
  }
  .power-up-card .pu-glyph-wild {
    /* Same SVG-wedge fix as .die.color-wild — see comment there. */
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNNTAsNTAgTDUwLDAgTDEwMCwwIEwxMDAsNTAgWiIgZmlsbD0iIzlCNkRERSIvPjxwYXRoIGQ9Ik01MCw1MCBMMTAwLDUwIEwxMDAsMTAwIEw1MCwxMDAgWiIgZmlsbD0iI0Y1OTQzQSIvPjxwYXRoIGQ9Ik01MCw1MCBMNTAsMTAwIEwwLDEwMCBMMCw1MCBaIiBmaWxsPSIjNENBRjZFIi8+PHBhdGggZD0iTTUwLDUwIEwwLDUwIEwwLDAgTDUwLDAgWiIgZmlsbD0iI0Y0RUVERCIvPjwvc3ZnPg==");
    background-size: cover;
  }
  .power-up-card .pu-glyph-unlock { background: #E8E8E8; }
  .power-up-card .pu-glyph-unlock svg { width: 62%; height: 62%; display: block; color: var(--ink); }
  /* Nudge's tray-card icon — same "±1" glyph as the on-track badge
     (NUDGE_GLYPH_HTML), 36px box (vs. the on-track badge's 18px), base
     font-size doubled to match (10px→20px). Scale deliberately smaller
     than Bonus-6's own nominal parity (20×1.5=30px) would give — Anton's
     heavier/condensed rendering reads visually larger than JetBrains
     Mono at a matching nominal size, so scale(1.2) (→24px) was chosen
     directly against how it looks next to a same-size "6", not derived
     from the 18px version's own math. */
  .power-up-card .pu-glyph-nudge {
    background: var(--nudge);
    color: #fff;
    font-family: 'JetBrains Mono', 'Inter', sans-serif;
    font-weight: 700;
    font-size: 20px;
    line-height: 1;
  }
  .power-up-card .pu-glyph-nudge .bonus-glyph {
    transform: scale(1.2);
    letter-spacing: -1px;
  }
  .power-up-card .pu-count {
    position: absolute;
    top: -6px; right: -6px;
    min-width: 16px; height: 16px;
    border-radius: 999px;
    background: var(--ink);
    color: var(--surface);
    font-family: 'Baloo 2', sans-serif;
    font-size: 10px;
    font-weight: 600;
    display: flex; align-items: center; justify-content: center;
    box-shadow: 0 0 0 2px var(--bg);
    padding: 0 3px;
  }
  .power-up-card .pu-text { display: flex; flex-direction: column; min-width: 0; }
  .power-up-card .pu-name { font-family: 'Inter', sans-serif; font-size: 12.5px; font-weight: 600; color: var(--ink); }
  .power-up-card .pu-sub { font-size: 11px; color: rgba(35,38,30,0.62); line-height: 1.3; }

  /* ===== Nudge's popup ("Take Your Pick") — playground-validated in
     nudge-playground.html, ported directly. Built/torn down each
     render() pass by renderNudgePopup() (board-render.js), keyed off
     state.armed==='nudge' && state.nudgeTargetIdx. No dedicated overlay
     element — main.js's existing page-wide armed-disarm listener handles
     outside-tap dismissal already (see that listener's own comment), and
     '.nudge-popup' was added to clickWithinDismissibleModalUI()'s
     exclusion list so a tap on the popup's own title/background/tail
     doesn't trigger it. */
  .nudge-popup {
    position: absolute;
    z-index: 50;
    background: #fff; /* bottom "half" (the dice options) */
    color: #fff;
    border-radius: 16px;
    /* filter:drop-shadow, not box-shadow — box-shadow draws around the
       popup's own rectangular bounding box and bleeds into the tail's
       transparent corners (visible as a shadow "cast on" the tail).
       drop-shadow wraps the actual rendered alpha shape — box + ::after
       tail together, as one combined shape — same fix as
       .track-info-bubble's own tail (drop-shadow there too, same reason). */
    filter: drop-shadow(0 0 5px rgba(35,38,30,0.32));
    animation: tibFadeIn 0.12s ease-out;
    width: max-content;
    max-width: calc(100vw - 20px);
  }
  .nudge-popup::after {
    content: '';
    position: absolute;
    width: 0; height: 0;
    border-left: 10px solid transparent;
    border-right: 10px solid transparent;
    /* No separate drop-shadow here — the parent's own filter:drop-shadow
       above already wraps this pseudo-element as part of the combined shape. */
  }
  /* Tail matches whichever half of the popup it's attached to: nudge
     color when it's at the top (against the title band), white when
     it's at the bottom (against the dice-options area). arrow-down is
     the one actually applied as of v315 — the popup opens above the die
     again (reverted from v254's below-the-die change) — .arrow-up kept
     defined rather than deleted, in case it's wanted again later. */
  .nudge-popup.arrow-up::after {
    top: -10px; left: 50%; transform: translateX(-50%);
    border-bottom: 10px solid var(--nudge);
  }
  .nudge-popup.arrow-down::after {
    bottom: -10px; left: 50%; transform: translateX(-50%);
    border-top: 10px solid #fff;
  }

  /* ===================================================================
     Onboarding tips (v323) — five short pointer-bubbles pointing at a
     control, shown once each ever, dismissed by tapping the bubble
     itself. One static reusable element, positioned/repopulated by
     showOnboardingTip() (onboarding.js) — same page-relative,
     viewport-clamped, measure-after-append technique as .nudge-popup
     above, not a copy of its two-tone styling (this is a single flat
     card, no title band/options-area split to match). Direction flips
     between arrow-up (bubble below target) and arrow-down (bubble
     above) depending on available space — see that function's own
     comment for why a single fixed direction (like .nudge-popup's own
     always-above choice) doesn't fit here: the five targets range from
     the top of the screen to the bottom.
  =================================================================== */
  .onboarding-tip {
    display: none;
    position: absolute;
    z-index: 50;
    /* v325 — width is now set as an explicit pixel value by
       showOnboardingTip() (onboarding.js) every time it shows, not
       max-content/max-width here — that combination still overflowed
       on-device even for a centered target nowhere near the screen
       edge, so sizing is fully JS-owned now; this is just a sane
       default for the brief instant before JS ever sets it. */
    width: 200px;
    background: var(--ink);
    color: #fff;
    border-radius: 12px;
    padding: 10px 14px;
    font-family: 'Inter', sans-serif;
    font-size: 13.5px;
    font-weight: 500;
    line-height: 1.35;
    text-align: center;
    cursor: pointer;
    touch-action: manipulation;
    filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));
    opacity: 0;
    transform: scale(0.85);
  }
  .onboarding-tip.visible {
    animation: onboardingTipPopIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
  }
  @keyframes onboardingTipPopIn {
    0%   { opacity: 0; transform: scale(0.85); }
    100% { opacity: 1; transform: scale(1); }
  }
  .onboarding-tip::after {
    content: '';
    position: absolute;
    width: 0; height: 0;
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    /* v324 — left driven by --tail-x (set per-show by showOnboardingTip(),
       onboarding.js), not a fixed 50%: when the bubble itself has to
       shift off-center to stay on-screen, a tail fixed at the bubble's
       own center no longer points at the actual target — found this
       on-device too. --tail-x is the target's real position relative to
       the bubble's own (possibly-shifted) left edge instead, clamped
       only enough to stay clear of the rounded corners. Falls back to
       50% if the variable is ever unset for any reason. */
    left: var(--tail-x, 50%);
    transform: translateX(-50%);
    /* No separate drop-shadow — the parent's own filter:drop-shadow
       above already wraps this pseudo-element into the combined shape,
       same reasoning as .nudge-popup's own tail above. */
  }
  .onboarding-tip.arrow-up::after {
    top: -8px;
    border-bottom: 8px solid var(--ink);
  }
  .onboarding-tip.arrow-down::after {
    bottom: -8px;
    border-top: 8px solid var(--ink);
  }

  .nudge-popup-title {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 700;
    font-size: 14.5px;
    text-align: center;
    padding: 4px 10px;
    border-radius: 16px 16px 0 0; /* matches popup's own corners — no overflow:hidden needed, which would clip the tail */
    background: var(--nudge); /* top "half" — same block the tail attaches to */
    color: #fff;
  }
  .nudge-popup-options {
    display: flex;
    justify-content: center;
    gap: 14px;
    padding: 12px 16px 16px;
  }
  .nudge-option {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    cursor: pointer;
    touch-action: manipulation;
  }
  .nudge-option .die {
    width: 42px; /* 58px base - 20%, scaled down from the tray die */
    border-radius: 10px; /* scaled proportionally from base .die's 14px @ 58px */
    box-shadow: none;
    transition: transform 0.1s ease;
  }
  .nudge-option.invalid .die {
    background: #C9C9C0 !important;
    cursor: default;
  }
  .nudge-option.invalid:active .die { transform: none; }
  .nudge-option.invalid .pip-cell.active { background: transparent !important; }
  .nudge-option:active .die { transform: scale(0.92); }

  .slot.filled.purple { background: transparent; box-shadow: inset 0 0 0 3px var(--purple); }
  .slot.filled.orange { background: transparent; box-shadow: inset 0 0 0 3px var(--orange); }
  .slot.filled.green  { background: transparent; box-shadow: inset 0 0 0 3px var(--green); }
  .slot.filled.red    { background: transparent; box-shadow: inset 0 0 0 3px var(--red); }
  /* Blue's own filled slots need a real opaque background, unlike every
     other track's (transparent is fine for them — nothing sits behind
     a slot that needs hiding). Blue's connector lines run directly
     through every slot's center, and .slot-die-inner's own opaque fill
     is inset 6px from the slot's edge (styles.css, .slot-die-inner) —
     it doesn't reach the slot's own outer margin, so a transparent
     .slot background here left the line visible right through that
     margin even with the correct paint order (z-index alone controls
     order, not whether something is actually opaque enough to hide
     what's behind it — this was the real gap in the first fix). */
  .slot.filled.blue   { background: var(--blue-tint); box-shadow: inset 0 0 0 3px var(--blue); }

  .slot-die-inner {
    position: absolute;
    inset: 6px;
    border-radius: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .slot-die-inner.purple { background: var(--purple); }
  .slot-die-inner.orange { background: var(--orange); }
  .slot-die-inner.green  { background: var(--green); }
  .slot-die-inner.red    { background: var(--red); }
  .slot-die-inner.blue   { background: var(--blue); }
  /* v371 (design-doc.md §115) — revised from v370's --ink background to
     a dedicated dark midnight-blue, per Allen's direct call. A one-off
     literal rather than a new custom-property token — this exact shade
     isn't reused anywhere else, unlike --blue/--blue-dark/--blue-tint,
     which are each used in multiple places. */
  .slot-die-inner.blue.prefilled { background: #0D1B2E; }
  /* v435 — Yellow: filled slot on the tint, dark X (white is too low-
     contrast on this yellow). */
  .slot.filled.yellow { background: var(--yellow-tint); box-shadow: inset 0 0 0 3px var(--yellow); }
  .slot-die-inner.yellow { background: var(--yellow); }
  .slot-die-inner.yellow .slot-x { color: var(--ink); }
  .slot-numeral {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 700;
    color: #fff;
    font-size: 22px;
    line-height: 1;
  }
  /* Green's filled-slot glyph (v316) — replaces .slot-numeral there;
     see buildSlotX()'s own comment (board-render.js) for why. Chosen
     directly against green-slot-x-playground.html (Permanent Marker and
     JetBrains Mono compared alongside it) — Baloo 2 at 26px won out.
     font-weight set explicitly rather than relying on inheriting
     .slot's own 600 through .slot-die-inner, same as .slot-numeral's
     own explicit weight above. */
  .slot-x {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    color: #fff;
    font-size: 26px;
    line-height: 1;
  }
  .slot-x.anim-fade-in { animation: slotNumeralFadeIn 0.15s ease-in-out; }
  /* Blue's 2 pre-filled slots (design-doc.md §112) get a distinct "X"
     look, alongside the tile-color change above: 26px base -> 24px
     (v370) -> 22px (v371), and white -> very light gray (v371) — both
     per Allen's direct calls. */
  .slot-x.prefilled { font-size: 22px; color: #E5E5E5; }

  /* v285 — the numeral half of the flying-die-clone's landing handoff
     (design-doc.md §32): a real CSS `animation`, not a `transition` on a
     toggled class, and applied at build time in renderTrack()
     (board-render.js) rather than via a later class-add. renderTrack()
     fully rebuilds every slot's innerHTML on every render() pass — a
     transition-based approach guarded by adding/removing a class later
     would risk exactly the bug this codebase has hit three times before
     (documented in design-doc.md's lessons-learned): a rebuild wiping
     out an in-progress animation. An `animation` triggered once at
     element-creation time, on a condition that's only ever true for the
     single render() pass where this exact slot was just filled (the
     same state.lastPlacedTrack/lastPlacedIdx one-shot check .anim-ripple
     already uses), plays to completion regardless of what any later,
     unrelated render() does — there's no persistent class for a rebuild
     to interfere with. */
  .slot-numeral.anim-fade-in { animation: slotNumeralFadeIn 0.15s ease-in-out; }
  @keyframes slotNumeralFadeIn {
    0%   { opacity: 0; }
    100% { opacity: 1; }
  }

  .slot.anim-ripple.purple { animation: rippleBorderEchoPurple 0.65s ease-out; }
  @keyframes rippleBorderEchoPurple {
    0%   { box-shadow: inset 0 0 0 3px var(--purple), 0 0 0 0 rgba(122,92,168,0.6); }
    100% { box-shadow: inset 0 0 0 3px var(--purple), 0 0 0 18px rgba(122,92,168,0); }
  }
  .slot.anim-ripple.orange { animation: rippleBorderEchoOrange 0.65s ease-out; }
  @keyframes rippleBorderEchoOrange {
    0%   { box-shadow: inset 0 0 0 3px var(--orange), 0 0 0 0 rgba(217,130,43,0.6); }
    100% { box-shadow: inset 0 0 0 3px var(--orange), 0 0 0 18px rgba(217,130,43,0); }
  }
  .slot.anim-ripple.green { animation: rippleBorderEchoGreen 0.65s ease-out; }
  @keyframes rippleBorderEchoGreen {
    0%   { box-shadow: inset 0 0 0 3px var(--green), 0 0 0 0 rgba(63,139,92,0.6); }
    100% { box-shadow: inset 0 0 0 3px var(--green), 0 0 0 18px rgba(63,139,92,0); }
  }
  .slot.anim-ripple.red { animation: rippleBorderEchoRed 0.65s ease-out; }
  @keyframes rippleBorderEchoRed {
    0%   { box-shadow: inset 0 0 0 3px var(--red), 0 0 0 0 rgba(193,59,59,0.6); }
    100% { box-shadow: inset 0 0 0 3px var(--red), 0 0 0 18px rgba(193,59,59,0); }
  }
  .slot.anim-ripple.yellow { animation: rippleBorderEchoYellow 0.65s ease-out; }
  @keyframes rippleBorderEchoYellow {
    0%   { box-shadow: inset 0 0 0 3px var(--yellow), 0 0 0 0 rgba(239,198,68,0.6); }
    100% { box-shadow: inset 0 0 0 3px var(--yellow), 0 0 0 18px rgba(239,198,68,0); }
  }

  /* Multiplier badge: a heart-with-× badge, same positioning/rotation
     mechanics as the bonus badges (.bonus-badge-wrap), replacing the old
     spoke-stripe ring wrap entirely — see renderMultiplierBadges() in
     game.js. Multiplier slots render as plain slots now; the badge is a
     separate overlay layer, exactly like bonus badges. */
  .mult-badge {
    width: 100%;
    height: 100%;
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLDIxLjM1bC0xLjQ1LTEuMzJDNS40LDE1LjM2LDIsMTIuMjgsMiw4LjUgMiw1LjQyLDQuNDIsMyw3LjUsM2MxLjc0LDAsMy40MSwwLjgxLDQuNSwyLjA5QzEzLjA5LDMuODEsMTQuNzYsMywxNi41LDMgMTkuNTgsMywyMiw1LjQyLDIyLDguNWMwLDMuNzgtMy40LDYuODYtOC41NSwxMS41NEwxMiwyMS4zNXoiIGZpbGw9IiNFNTQ4NEQiLz48ZyBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxsaW5lIHgxPSI5LjQiIHkxPSI4LjQiIHgyPSIxNC42IiB5Mj0iMTMuNiIvPjxsaW5lIHgxPSIxNC42IiB5MT0iOC40IiB4Mj0iOS40IiB5Mj0iMTMuNiIvPjwvZz48L3N2Zz4=");
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
    transition: filter 0.15s ease;
  }
  .mult-badge.used {
    filter: grayscale(0.6) brightness(0.7);
  }
  .mult-badge.mult-badge-wiggle {
    animation: puBadgeWiggle 0.4s ease-in-out;
  }

  /* ===== Tap-for-info (v191) =====
     Tapping a track row with no die armed opens two things at once: a
     rule/scoring bubble above the track, and a row of badge-description
     bubbles below it (one per bonus badge on that track, plus the
     multiplier). See openInfoBubble() in game.js. Playground-validated
     before porting — game-over-modal-playground.html-style workbench,
     discarded once ported here (per usual practice; this one wasn't a
     long-term-kept exception). */

  .track-info-bubble {
    position: absolute;
    /* left/width set inline via JS (viewportCenteredBox() in
       positionInfoElements()), not CSS left:50%/transform:translateX.
       .board uses width:100vw to break out of centering, and on a page
       tall enough to scroll, 100vw can render wider than the true
       visible viewport on iOS Safari — any element centering itself
       against that same containing block inherits the same off-center
       shift (confirmed on-device during the playground build).
       Computing from window.innerWidth directly sidesteps that. */
    background: var(--tib-bg, var(--purple));
    color: #fff;
    z-index: 50;
    border-radius: 16px;
    box-shadow: 0 0 8px rgba(35,38,30,0.28);
    animation: tibFadeIn 0.12s ease-out;
  }
  .track-info-bubble.tib-yellow { color: var(--ink); } /* v435 — dark text on yellow */
  .track-info-bubble.closing {
    animation: tibFadeOut 0.12s ease-in forwards;
  }
  @keyframes tibFadeIn {
    0%   { opacity: 0; }
    100% { opacity: 1; }
  }
  @keyframes tibFadeOut {
    0%   { opacity: 1; }
    100% { opacity: 0; }
  }
  .track-info-bubble::after {
    content: '';
    position: absolute;
    bottom: -10px;
    left: calc(50% + 15px); /* was 50% (centered) — nudged 15px right per request */
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 10px solid transparent;
    border-right: 10px solid transparent;
    border-top: 10px solid var(--tib-bg, var(--purple));
    filter: drop-shadow(0 1px 1px rgba(35,38,30,0.22));
  }
  .tib-content {
    padding: 12px 20px 14px;
  }
  .tib-rule {
    margin: 0 0 14px;
    font-family: 'Inter', sans-serif;
    font-size: 14.5px;
    line-height: 1.5;
    color: rgba(255,255,255,0.92);
  }
  .tib-rule:last-child { margin-bottom: 0; }
  /* v437 — .tib-rule's own white color above overrode the bubble's dark
     text on Yellow (.tib-yellow); this restores near-black ink there. */
  .track-info-bubble.tib-yellow .tib-rule { color: var(--ink); }

  .badge-bubble-row {
    position: absolute;
    /* left/width set inline via JS — same 100vw-quirk fix as
       .track-info-bubble above. Each individual .badge-bubble is also
       positioned via inline left/width (see layoutBadgeRow() in
       game.js) to sit as close as possible to its actual badge's
       x-position on the track above, rather than being evenly spread.
       Children are position: absolute, so this row naturally collapses
       to 0 height — fine, it has no visible box of its own. */
    z-index: 50;
  }
  .badge-bubble {
    position: absolute;
    top: 0;
    /* width set inline via JS (layoutBadgeRow) — between 90px and
       115px depending on how many badges are in the row. max-width
       stays as a hard cap either way. */
    max-width: 115px;
    background: var(--bubble-bg, #fff);
    color: var(--ink);
    border-radius: 12px;
    /* Small blur + negative spread so the shadow's visible reach stays
       roughly within the 5px minimum gap between bubbles — a later
       sibling paints on top of an earlier one, so a shadow that extends
       too far can visibly overlap its face instead of being covered
       by it. */
    box-shadow: 0 0 4px -1px rgba(35,38,30,0.32);
    padding: 10px 6px 8px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    animation: tibFadeIn 0.12s ease-out;
  }
  .badge-bubble.closing {
    animation: tibFadeOut 0.12s ease-in forwards;
  }
  .badge-bubble::before {
    content: '';
    position: absolute;
    top: -8px;
    left: 50%;
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-bottom: 8px solid var(--bubble-bg, #fff);
    filter: drop-shadow(0 -1px 1px rgba(35,38,30,0.18));
  }
  .badge-bubble-icon-wrap {
    width: 32px;
    height: 32px;
    flex-shrink: 0;
  }
  .badge-bubble-icon-wrap .bonus-badge {
    border-radius: 8px;
    box-shadow: 0 1px 2px rgba(35,38,30,0.3);
  }
  .badge-bubble-icon-wrap .mult-badge {
    filter: drop-shadow(0 1px 1.5px rgba(35,38,30,0.3));
  }
  .badge-bubble-icon-wrap .bonus-glyph {
    transform: scale(2.4);
  }
  /* Nudge override — the rule above (scale(2.4), sized for Bonus-6's
     single digit) was previously winning here on a same-specificity/
     later-source-order tie against nudge's own intended smaller scale,
     rendering "±1" wider than this 32px badge box (a real bug, not just
     a sizing preference — see the fuller writeup on .bonus-badge-nudge
     above). This selector's extra class (.bonus-badge-nudge) gives it
     higher specificity, so it reliably wins regardless of source order.
     scale(2.0) (→20px, vs. "6"'s 24px here) chosen directly against how
     it looks next to a same-size "6" — Anton reads visually heavier than
     JetBrains Mono at a matching nominal size, so nominal parity (2.4,
     same as "6") still looked oversized. */
  .badge-bubble-icon-wrap .bonus-badge-nudge .bonus-glyph {
    transform: scale(2.0);
    letter-spacing: -1px;
  }
  .badge-bubble-icon-wrap .bonus-badge-unlock svg {
    width: 18px;
    height: 18px;
  }
  .badge-bubble-desc {
    font-family: 'Inter', sans-serif;
    font-size: 14.5px;
    line-height: 1.3;
    text-align: center;
  }

  /* Spotlight/dim overlay: two solid rectangles (above/below the open
     track's row) rather than one element with holes cut for the track
     and each popup — simpler because the popups only ever sit strictly
     above or below the track row, never beside it. The track row
     itself is the gap between the two rectangles (never covered), and
     each popup just needs its higher z-index (50 above) than these
     rectangles (45) to paint on top of whichever one it happens to
     overlap. Matches the same rgba/blur treatment as the existing
     .game-over-overlay for visual consistency. pointer-events: auto
     (v218) — an outside tap needs to land ON this overlay and dismiss
     the bubble there, not fall through to whatever real element sits
     underneath it (a die, a header button, the scoreboard tab); see
     design-doc.md §7 for the general "outside-tap-to-dismiss shouldn't
     also register on what's below" lesson this and the two other
     modals' dismiss listeners were all fixed for at the same time. */
  .dim-overlay {
    position: absolute;
    left: 0;
    width: 100%;
    background: rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    z-index: 45;
    pointer-events: auto;
    animation: tibFadeIn 0.12s ease-out;
  }
  .dim-overlay-top { top: 0; }
  .dim-overlay.closing {
    animation: tibFadeOut 0.12s ease-in forwards;
  }

  .slot.legal {
    cursor: pointer;
    touch-action: manipulation;
    background: rgba(255,255,255,0.5);
  }
  .slot.legal.empty-purple { box-shadow: inset 0 0 0 3px var(--purple), inset 0 0 0 6px #fff; }
  .slot.legal.empty-orange { box-shadow: inset 0 0 0 3px var(--orange), inset 0 0 0 6px #fff; }
  .slot.legal.empty-green  { box-shadow: inset 0 0 0 3px var(--green), inset 0 0 0 6px #fff; }
  .slot.legal.empty-red    { box-shadow: inset 0 0 0 3px var(--red), inset 0 0 0 6px #fff; }
  /* Blue-specific: the shared .slot.legal rule above only sets a 50%
     translucent white (rgba(255,255,255,0.5)) — fine for every other
     track, since nothing sits behind their own slots worth hiding, but
     not opaque enough to fully hide Blue's connector line running
     through this slot's center. Rather than a stark solid white (which
     read as visually inconsistent with Purple/Orange/Green/Red's own
     soft, lightly-tinted legal look), this is the same 50%-white-over-
     tint blend precomputed as a flat opaque color: 0.5×white +
     0.5×--blue-tint (#D3E0EA) = #E9F0F4. Looks identical to the
     translucent version, just solid underneath. */
  .slot.legal.empty-blue   { box-shadow: inset 0 0 0 3px var(--blue), inset 0 0 0 6px #fff; background: #E9F0F4; }
  .slot.legal.empty-yellow { box-shadow: inset 0 0 0 3px var(--yellow), inset 0 0 0 6px #fff; background: #FDF8E8; } /* v435 */

  .tray-label {
    font-size: 12px;
    color: rgba(35,38,30,0.55);
    margin-bottom: 10px;
    letter-spacing: 0.3px;
  }

  .dice-tray-container {
    background: rgba(0,0,0,0.1);
    border-radius: 18px;
    display: flex;
    align-items: stretch;
    margin-top: 1px;
    margin-bottom: 14px;
    max-width: 400px;
    margin-left: auto;
    margin-right: auto;
  }
  .dice-zone {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 14px;
    flex-wrap: nowrap;
    padding: 16px;
    min-width: 0;
  }
  .dice-zone .die {
    flex-shrink: 1;
  }
  /* v430 (§183) — next-roll peek. A 7px strip just under the tray's
     bottom edge (inside the tray's 14px bottom margin) with overflow
     hidden; each tab sits above it (translateY(-100%)) and slides down
     into view, so it reads as the next die's edge peeking out from under
     the tray. The tray itself is translucent, so tabs are clipped rather
     than tucked behind it. Positions/widths set inline per die. */
  .dice-tray-container { position: relative; }
  .next-peek {
    position: absolute;
    left: 0; right: 0; top: 100%;
    height: 7px;
    overflow: hidden;
  }
  /* v431 (§184) — one persistent rail slides, not the individual tabs:
     iOS Safari skipped transitions on freshly created tabs (they popped),
     while the playground's persistent sliding container animated fine.
     Tabs are swapped only while the rail is tucked. */
  .peek-rail {
    position: absolute;
    inset: 0;
    transform: translateY(-100%);
    transition: transform 200ms cubic-bezier(.2,.8,.2,1); /* v432 — was 140ms */
  }
  .next-peek.out .peek-rail { transform: translateY(0); }
  .peek-tab {
    position: absolute;
    top: 0; height: 100%;
    border-radius: 0 0 10px 10px;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px #D6D6D6;
  }
  .peek-tab.color-purple { background: var(--purple); box-shadow: none; }
  .peek-tab.color-orange { background: var(--orange); box-shadow: none; }
  .peek-tab.color-green  { background: var(--green);  box-shadow: none; }
  .peek-tab.color-red    { background: var(--red);    box-shadow: none; }
  .peek-tab.color-blue   { background: var(--blue);   box-shadow: none; }
  .peek-tab.color-yellow { background: var(--yellow); box-shadow: none; }
  @media (prefers-reduced-motion: reduce) { .peek-rail { transition: none; } }
  .tray-right-col {
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    width: 140px;
    padding-right: 4px;
  }
  .tray-round-counter {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #555;
    background: rgba(255,255,255,0.4);
    border-radius: 2px 15px 2px 2px;
    margin: 4px 0 0 0;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 18px;
    text-align: center;
  }
  .tray-round-counter.full-height {
    border-radius: 2px 15px 15px 2px;
    margin: 4px 0;
  }
  .tray-round-counter.game-over-pill {
    cursor: pointer;
    touch-action: manipulation;
  }
  /* v298 — see noMoreMovesPhase() (board-render.js). display:none
     rather than removing the element: #trayRoundCounter is a fixed DOM
     node, always updated in place, never recreated. */
  .tray-round-counter.hidden {
    display: none;
  }
  .tray-next-btn {
    flex: 2;
    margin: 4px 0 4px 0;
    width: 136px;
    background: #3CB371;
    border-radius: 2px 2px 15px 2px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 22px;
    color: var(--ink);
    cursor: pointer;
    touch-action: manipulation;
    padding: 0 6px;
    text-align: center;
    transition: background 0.12s ease;
  }
  .tray-next-btn:hover { background: #4CC17E; }
  .tray-next-btn:active { background: #339B62; }
  .tray-next-btn.desaturated { background: #BBE4CD; color: rgba(35,38,30,0.55); }
  .tray-next-btn.desaturated:hover { background: #A8DABF; }
  .tray-next-btn.desaturated:active { background: #8FCBAA; }
  /* v298 — mirrors .tray-round-counter.full-height's own radius fix
     above, just for the opposite element: with #trayRoundCounter hidden
     (noMoreMovesPhase()), this button is the sole flex child and
     naturally fills the column's height on its own (flex-grow already
     does that regardless of the exact value — no flex/margin change
     needed), but its top-right corner still needs the same 15px round
     the counter's own full-height state gets, since this button now
     sits at the top of the column too, not just the bottom. */
  .tray-next-btn.full-height {
    border-radius: 2px 15px 15px 2px;
  }

  .tray-confirm-row {
    flex: 2;
    margin: 4px 0 4px 0;
    width: 136px;
    display: flex;
    gap: 4px;
  }
  .tray-confirm-btn {
    flex: 1;
    min-width: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 17px;
    line-height: 1.05;
    text-align: center;
    padding: 2px 3px;
    overflow-wrap: break-word;
    word-break: break-word;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.12s ease;
  }
  .tray-confirm-btn.end {
    background: #3CB371;
    color: var(--ink);
    border-radius: 2px;
  }
  .tray-confirm-btn.end:hover { background: #4CC17E; }
  .tray-confirm-btn.end:active { background: #339B62; }
  .tray-confirm-btn.cancel {
    background: #B4B4B4;
    color: #444;
    border-radius: 2px 2px 15px 2px;
  }
  .tray-confirm-btn.cancel:hover { background: #A0A0A0; }
  .tray-confirm-btn.cancel:active { background: #8C8C8C; }

  /* Round Reset (v245, single-button confirm as of v259) — pill control
     below the power-up cards row, playground-validated
     (round-reset-playground.html). First tap swaps this same button's
     own label/style to .confirm (gold-dark, "Are you sure?") rather
     than showing a separate confirm row — see buildRoundResetControl().
     No more .inbox-confirm-row / .tray-confirm-btn pairing here; that
     was the old two-button version, now removed along with
     cancelReset(). */
  .inbox-reset-wrap {
    display: flex;
    flex: none;
  }
  .standalone-reset-btn {
    display: flex;
    align-items: center;
    gap: 6px;
    font-family: 'Inter', sans-serif;
    font-size: 12px;
    font-weight: 600;
    color: rgba(35,38,30,0.62);
    padding: 5px 14px;
    border-radius: 999px;
    background: rgba(0,0,0,0.07);
    cursor: pointer;
    touch-action: manipulation;
  }
  .standalone-reset-btn svg { width: 14px; height: 14px; }
  .standalone-reset-btn:active { background: rgba(0,0,0,0.12); color: var(--ink); }
  .standalone-reset-btn.disabled {
    opacity: 0.55;
    cursor: default;
    pointer-events: none;
  }
  .standalone-reset-btn.confirm {
    background: var(--gold-dark);
    color: var(--ink);
  }
  .standalone-reset-btn.confirm:active { background: var(--gold-dark); opacity: 0.85; }

  .die {
    width: 58px;
    aspect-ratio: 1;
    border-radius: 14px;
    background: var(--surface);
    color: var(--ink);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    border: none;
    position: relative;
    transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.2s ease;
    user-select: none;
  }
  .die.placed {
    background: #000;
    opacity: 0.1;
    cursor: default;
    box-shadow: none;
    transform: none;
  }
  .die.discarded {
    opacity: 0.35;
    cursor: default;
    box-shadow: none;
    text-decoration: line-through;
  }

  /* v285 — die-flight animation (design-doc.md §32), validated in
     die-flight-playground.html before porting. Settled parameters:
     200ms flight (PLACEMENT_FLIGHT_MS, placement.js), landing at 98%
     of the full distance (position stops just short of the slot's
     center; scale still resolves fully — see spawnPlacementFlight()),
     easing to 50% opacity in flight, then a final 50ms fade to 0 at
     landing (see landPlacement()'s caller in spawnPlacementFlight()). */
  .flying-die-clone {
    position: fixed;
    z-index: 40;
    pointer-events: none;
    transition-property: transform, opacity;
    /* Paired positionally with transition-property above: transform
       gets ease-in (starts slow, accelerates the whole way, no
       deceleration at the end), opacity keeps ease-in-out for both the
       in-flight fade toward 50% and the final landing fade to 0 — same
       split arrived at in the playground. */
    transition-timing-function: ease-in, ease-in-out;
    /* transition-duration set inline per-flight from placement.js —
       200ms for the flight itself, then overridden to 50ms for the
       final landing fade. */
  }

  /* The real tray die currently mid-flight — its clone (above) is
     standing in for it visually, spawned at the same position it
     already occupies, so hiding it via opacity (not display:none)
     keeps its layout box and rect stable rather than collapsing it.
     Applied by renderDice() checking state.flightDieIdx on every
     render() pass, not a one-time DOM mutation at flight-launch — see
     that function's own comment for why. */
  .die.flight-source-hidden { opacity: 0; }

  .die.color-purple { background: var(--purple); color: #fff; }
  .die.color-orange { background: var(--orange); color: #fff; }
  .die.color-green  { background: var(--green); color: #fff; }
  .die.color-red    { background: var(--red); color: #fff; }
  .die.color-blue   { background: var(--blue); color: #fff; }
  /* v435 — Yellow dice keep the default dark pips (not in the white-pip
     rule below): white on this yellow is too low-contrast. */
  .die.color-yellow { background: var(--yellow); color: var(--ink); }
  .die.color-wild {
    /* SVG quadrant wedges, not conic-gradient — Firefox rasterizes
       hard-stop conic-gradients with visible aliasing at small sizes;
       an SVG background renders as vector paths with clean edges in
       every browser. Same four colors/angles as before. Colors are
       baked into the SVG as static hex (matching --purple-bright,
       --orange-bright, --green-bright, --surface) since these don't
       vary with dark mode. */
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNNTAsNTAgTDUwLDAgTDEwMCwwIEwxMDAsNTAgWiIgZmlsbD0iIzlCNkRERSIvPjxwYXRoIGQ9Ik01MCw1MCBMMTAwLDUwIEwxMDAsMTAwIEw1MCwxMDAgWiIgZmlsbD0iI0Y1OTQzQSIvPjxwYXRoIGQ9Ik01MCw1MCBMNTAsMTAwIEwwLDEwMCBMMCw1MCBaIiBmaWxsPSIjNENBRjZFIi8+PHBhdGggZD0iTTUwLDUwIEwwLDUwIEwwLDAgTDUwLDAgWiIgZmlsbD0iI0Y0RUVERCIvPjwvc3ZnPg==");
    background-size: cover;
    color: #fff;
  }
  .die:not(.color-purple):not(.color-orange):not(.color-green):not(.color-red):not(.color-blue):not(.color-yellow):not(.color-wild) {
    box-shadow: 0 0 0 1px #D6D6D6;
  }

  .die.selected {
    box-shadow: 0 0 0 4px #fff;
  }
  .die.selected:not(.color-purple):not(.color-orange):not(.color-green):not(.color-red):not(.color-blue):not(.color-yellow):not(.color-wild) {
    box-shadow: 0 0 0 4px #fff;
  }

  .die.eligible-target {
    box-shadow: 0 0 0 4px var(--gold);
  }
  .die.eligible-target:not(.color-purple):not(.color-orange):not(.color-green):not(.color-red):not(.color-blue):not(.color-yellow):not(.color-wild) {
    box-shadow: 0 0 0 4px var(--gold);
  }

  .die.locked-dim { filter: brightness(0.45); }
  .die.anim-ripple { animation: dieRipple 0.7s ease-out; }
  @keyframes dieRipple {
    0%   { box-shadow: 0 0 0 0 rgba(255,255,255,0.7); }
    100% { box-shadow: 0 0 0 20px rgba(255,255,255,0); }
  }

  /* Roll-in flicker for newly-rolled dice — validated in
     dice-roll-playground.html (option 5). JS-driven, not a pure CSS
     animation: renderDice() cycles the pip pattern through a few random
     values before landing on the real one (see the flicker loop there),
     then this class plays a small pop on the final frame. 5 steps at
     50ms + this 150ms pop = 400ms total. Only applied when
     state.diceJustRolled is true — a fresh roll, not just any render
     that happens to touch the dice row (selecting/placing a die
     shouldn't replay this). */
  @keyframes diceRollFlickerPop {
    0%   { transform: scale(0.85); }
    60%  { transform: scale(1.06); }
    100% { transform: scale(1); }
  }
  .die.anim-flicker-pop { animation: diceRollFlickerPop 0.15s ease-out; }

  .die .pip-grid { width: 64%; height: 64%; }
  .die .pip-cell.active { background: var(--ink); }
  .die.color-purple .pip-cell.active,
  .die.color-orange .pip-cell.active,
  .die.color-green .pip-cell.active,
  .die.color-red .pip-cell.active,
  .die.color-blue .pip-cell.active,
  .die.color-wild .pip-cell.active {
    background: #fff;
  }

  .status {
    max-width: 80vw;
    margin: 0 auto;
    background: transparent;
    border-radius: 0;
    padding: 0;
  }
  .status-screen {
    font-family: 'Inter', sans-serif;
    font-weight: 500;
    font-size: 17px;
    line-height: 1.25;
    color: rgba(35,38,30,0.75);
    background: transparent;
    box-shadow: none;
    min-height: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 0;
  }
  .status-screen.warn { color: var(--orange-dark); }

  /* ===================================================================
     Scoreboard tab — collapsed to a star-shaped total badge (v155+),
     tap to expand into a breakdown drawer. The badge and the drawer are
     two independent elements now, not one growing element: the badge
     sits down onto the purple track for visual flair, and the drawer
     is anchored separately so it stays flush with the top of the board
     regardless of where the badge sits or how far it dips. No
     animation (instant show/hide) and no drop shadow.

     Positioning: right-aligned to .sb-zone's own 412px centered box —
     intentionally 12px wider than the 400px .dice-tray-container/board
     box below it, so the badge and drawer sit slightly outside that
     shared alignment rather than flush with it. .board itself is
     full-bleed 100vw. The zone has height:0; the badge (.sb-tab) is
     anchored via a fixed negative top offset that lets part of its own
     height sit below that reference point, into the board; the drawer
     (.sb-tab-table) is anchored to top:0 directly, independent of the
     badge's offset.
  =================================================================== */
  .sb-zone {
    position: relative;
    max-width: 412px;
    margin: 0 auto;
    padding: 0 18px;
    height: 0;
  }

  .sb-tab {
    position: absolute;
    top: -52px; /* v232 — scaled up with the badge (was -38px/55px);
                   right also nudged -4px past flush, both by explicit
                   request while iterating in the header playground */
    right: -4px;
    z-index: 8; /* above .sb-tab-table (z-index:3) — equal values would
                   fall back to DOM order, and the table comes after the
                   badge in the markup, so it'd win that tie without this.
                   Also above .board-scrim (6) and .round-banner (7) —
                   v375 — so the round-transition dim doesn't obscure the
                   badge; still well below .game-over-overlay/.modal-scrim
                   (10/11), which should cover it. */
    width: 64px;
    height: 64px;
    cursor: pointer;
    touch-action: manipulation;
    user-select: none;
  }

  .sb-tab-badge-face {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--gold-dark);
    color: #fff;
    font-family: 'JetBrains Mono', monospace;
    font-weight: 700;
    font-size: 28px; /* v232 — scaled with the badge, was 24px/55px */
    transform: rotate(7deg);
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }
  /* Landing feedback for a multiplier flight's arrival. Its own keyframe,
     sbBadgeWiggle, rather than reusing puBadgeWiggle directly: this badge
     has a permanent 7deg resting tilt (see the static transform above),
     and puBadgeWiggle's start/end points are rotate(0deg) — applying it
     here would snap 7deg->0deg the instant the animation starts and
     0deg->7deg the instant it ends. sbBadgeWiggle is identical but with
     every rotation value offset by +7deg, so it starts and ends exactly
     at the resting tilt instead. Same scale growth/shrink either way. */
  @keyframes sbBadgeWiggle {
    0%   { transform: rotate(7deg) scale(1); }
    15%  { transform: rotate(-5deg) scale(1.1); }
    25%  { transform: rotate(3deg) scale(1.18); }
    45%  { transform: rotate(17deg) scale(1.1); }
    65%  { transform: rotate(1deg) scale(1.04); }
    85%  { transform: rotate(11deg) scale(1.01); }
    100% { transform: rotate(7deg) scale(1); }
  }
  .sb-tab-badge-face.badge-wiggle {
    animation: sbBadgeWiggle 0.4s ease-in-out;
  }

  /* A sibling of .sb-tab within .sb-zone, not a descendant of it — that
     would tie the drawer's top edge to wherever the badge's own bottom
     happens to sit (it dips ~45% down into the board on purpose, and
     the drawer shouldn't follow it down). Shown via an adjacent-sibling
     selector off .sb-tab.expanded instead of a descendant one. min-width
     is pinned to the badge's own 55px, not 100%: 100% would resolve
     against .sb-zone (400px) now that this is .sb-zone's direct child,
     not against the 55px badge as it did when nested inside it. */
  .sb-tab-table {
    display: none;
    position: absolute;
    top: 0;
    right: 0;
    z-index: 3;
    min-width: 55px;
    width: max-content;
    background: #fff;
    border-top: 7px solid #E1E0DD;
    border-radius: 0 0 8px 8px;
    padding: 14px 14px 12px;
  }
  .sb-tab.expanded + .sb-tab-table { display: block; }

  .sb-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 18px;
    padding: 6px 0;
    font-size: 16px;
    white-space: nowrap;
  }
  .sb-row .num { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 18px; }
  .sb-row + .sb-row { border-top: 1px solid rgba(35,38,30,0.08); }
  .sb-row.total-row {
    border-top: 1.5px solid rgba(35,38,30,0.18);
    margin-top: 2px;
    padding-top: 7px;
    font-weight: 700;
  }
  .sb-row.total-row .num { font-size: 21px; }
  .sb-row-label {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .sb-row-label {
    /* Two-column grid, not flex: icon column is a fixed width (matching
       the larger heart icon, .sb-swatch-striped below) so every row's
       label text starts at the same x regardless of that row's actual
       icon width — a plain 13px swatch and the 18px heart otherwise
       shift the text after them by different amounts. The description
       sits in the same text column on its own row, so it lines up
       under the label text rather than under the icon. */
    display: grid;
    grid-template-columns: 18px 1fr;
    column-gap: 7px;
    row-gap: 2px;
    align-items: center;
  }
  .sb-row-name {
    grid-column: 2;
    grid-row: 1;
    font-family: 'Inter', sans-serif;
    font-weight: 600;
  }
  .sb-row-desc {
    grid-column: 2;
    grid-row: 2;
    font-size: 12.5px;
    font-weight: 400;
    color: rgba(35,38,30,0.7);
  }
  /* Highlights the multiplier count within the Multiplier row's own
     description ("Lowest × N multipliers") — see scoreRowsHtml(). Red
     to match the heart-with-× badge that now represents the multiplier
     everywhere else (was gold, matching the old spoke-ring). */
  .sb-desc-highlight {
    font-weight: 700;
    color: var(--heart-red);
  }
  .sb-swatch {
    grid-column: 1;
    grid-row: 1;
    justify-self: center;
    width: 13px;
    height: 13px;
    border-radius: 3px;
  }
  /* Mini copy of the heart-with-× multiplier badge (see .mult-badge),
     used as this row's own icon — was a striped circle echoing the old
     spoke-ring; same swap applies here. Not circular anymore since the
     heart has its own shape, but keeping the class name (and the
     13x13 sizing other swatches use) rather than renaming, to avoid
     touching scoreRowsHtml()'s swatchClass logic for no visual gain. */
  .sb-swatch-striped {
    /* Larger than the base 13px swatch — same reasoning as
       .mult-badge-wrap: the heart doesn't fill its box edge-to-edge like
       a solid color fill does, so at matching sizes it read smaller than
       the plain Purple/Orange/Green swatches next to it. Kept at the
       same ~1.36x ratio over the base swatch as before (was 15px over an
       11px base), rather than a fixed pixel offset, so the heart keeps
       reading the same relative weight as the swatch size changes. The
       18px icon-column width above matches this exactly, so the larger
       heart still sits centered in the same column as the smaller
       swatches rather than pushing the text column over.
       grid-column/grid-row/justify-self repeated here (not inherited
       from .sb-swatch, since this class is applied alongside it but
       these properties aren't additive across two classes on one
       element in any special way — restating them is just for clarity
       reading this rule on its own). */
    width: 18px;
    height: 18px;
    border-radius: 0;
    background-color: transparent;
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLDIxLjM1bC0xLjQ1LTEuMzJDNS40LDE1LjM2LDIsMTIuMjgsMiw4LjUgMiw1LjQyLDQuNDIsMyw3LjUsM2MxLjc0LDAsMy40MSwwLjgxLDQuNSwyLjA5QzEzLjA5LDMuODEsMTQuNzYsMywxNi41LDMgMTkuNTgsMywyMiw1LjQyLDIyLDguNWMwLDMuNzgtMy40LDYuODYtOC41NSwxMS41NEwxMiwyMS4zNXoiIGZpbGw9IiNFNTQ4NEQiLz48ZyBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxsaW5lIHgxPSI5LjQiIHkxPSI4LjQiIHgyPSIxNC42IiB5Mj0iMTMuNiIvPjxsaW5lIHgxPSIxNC42IiB5MT0iOC40IiB4Mj0iOS40IiB5Mj0iMTMuNiIvPjwvZz48L3N2Zz4=");
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
  }
  /* Star-shaped total badge — as of v155, used ONLY by the game-over
     modal (.game-over-rows below). The scoreboard tab's own drawer now
     renders its total as plain bold text (see scoreRowsHtml()'s
     totalStyle param) since the badge shape moved up to become the
     collapsed tab itself (.sb-tab-badge-face) — keeping it in the
     drawer too read as redundant right next to the tab. */
  .sb-total-badge {
    /* Exposes its own resting tilt as a variable so .wiggle's keyframes
       (see scoreWiggle) can animate relative to it instead of an
       absolute 0deg — otherwise the wiggle snapped to/from 0deg at
       both ends instead of smoothly moving around this badge's actual
       resting angle. */
    --wiggle-base-rotate: 7deg;
    width: 48px;
    height: 48px;
    flex: none;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--gold-dark);
    color: #fff;
    font-family: 'JetBrains Mono', monospace;
    font-weight: 700;
    font-size: 21px;
    transform: rotate(7deg);
    clip-path: polygon(50.00% 0.00%, 61.74% 13.86%, 79.39% 9.55%, 80.74% 27.66%, 97.55% 34.55%, 88.00% 50.00%, 97.55% 65.45%, 80.74% 72.34%, 79.39% 90.45%, 61.74% 86.14%, 50.00% 100.00%, 38.26% 86.14%, 20.61% 90.45%, 19.26% 72.34%, 2.45% 65.45%, 12.00% 50.00%, 2.45% 34.55%, 19.26% 27.66%, 20.61% 9.55%, 38.26% 13.86%);
  }
  /* Larger badge on the game-over modal specifically — text stays the
     same size as this base version, only the badge grows. */
  .game-over-rows .sb-total-badge {
    width: 60px;
    height: 60px;
  }

  .version-tag {
    text-align: center;
    font-size: 10.5px;
    color: rgba(35,38,30,0.3);
    margin-top: 22px;
    font-family: 'Inter', sans-serif;
  }


  /* ===================================================================
     Game-over modal — centered overlay, blurred/whitened backdrop,
     locked open (no tap-outside dismiss) until New Game or Return to
     Game is pressed. Reuses .sb-row markup for the breakdown so the
     numbers can't drift from the scoreboard tab's own rendering.
  =================================================================== */
  .game-over-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 10;
    background: rgba(128,128,128,0.75);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    align-items: center;
    justify-content: center;
    padding: 24px;
  }
  .game-over-modal {
    width: 100%;
    max-width: 360px;
    background: var(--bg);
    border-radius: 18px;
    padding: 24px 20px 20px;
    box-shadow: 0 12px 40px rgba(0,0,0,0.18);
    /* v277 — anchors .game-over-home-btn's absolute positioning below. */
    position: relative;
  }
  /* v277 — a second copy of the header's home button (.hamburger-btn),
     scaled down to fit this corner without crowding the centered "Game
     Over" heading. Same glyph/colors/hover treatment, just smaller and
     absolutely positioned rather than sitting in a flex row.
     v279 — border-radius corrected from var(--home-radius) (14px, the
     header button's own value) to a flat 10px: at .hamburger-btn's
     full 42px size 14px reads proportionate, but carried over unscaled
     to this button's smaller 34px it read noticeably more rounded than
     intended. 10px roughly preserves the header button's own
     radius-to-size ratio instead of reusing its absolute value. */
  .game-over-home-btn {
    position: absolute;
    top: 14px;
    right: 14px;
    width: 34px;
    height: 34px;
    border-radius: 10px;
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: rgba(35,38,30,0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .game-over-home-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }
  .game-over-home-btn .home-glyph { width: 18px; height: 18px; }
  .game-over-modal h2 {
    font-family: 'Baloo 2', sans-serif;
    color: var(--gold-dark);
    font-size: 28px;
    text-align: center;
    margin: 0 0 18px;
  }
  .game-over-rows { margin-bottom: 20px; }
  .best-possible-row {
    text-align: center;
    font-family: 'Inter', sans-serif;
    font-size: 16px;
    font-weight: 600;
    line-height: 20px;
    color: var(--ink-soft);
    margin: -8px 0 20px;
    /* min-height reserves one line's worth of box up front, so the row
       doesn't collapse to zero height while empty/hidden — otherwise
       the modal grows/reflows the moment "Calculating…" or the final
       number first appears. Kept even in the hidden (opacity: 0)
       state, not just .shown/.calculating, so the space is claimed
       from the very first render. */
    min-height: 20px;
    /* Hidden by default — the final number only ever appears via
       runBestPossibleCountUp() (first time seeing today's result) or
       showBestPossibleInstantly() (every later visit that day), both
       of which add .shown. "Calculating…" is the one exception, shown
       plainly since it isn't spoiling anything. */
    opacity: 0;
  }
  .best-possible-row.shown,
  .best-possible-row.calculating,
  .best-possible-row.unavailable {
    opacity: 1;
  }
  .best-possible-row .bp-number {
    display: inline-block;
    color: rgba(35,38,30,0.75);
  }
  /* v393 (design-doc.md §138) — the running anytime estimate shown
     alongside "Calculating best possible…". Already inherits italic
     from .calculating below; muted further than .bp-number's own
     0.75 opacity so a provisional number never reads as confidently
     as the real, final revealed one. */
  .best-possible-row .bp-estimate {
    display: inline-block;
    color: rgba(35,38,30,0.5);
  }
  .best-possible-row.calculating,
  .best-possible-row.unavailable { font-weight: 400; font-style: italic; }

  /* Tier badge/reveal — replaces the earlier first-pass plain circle.
     Full writeup in design-doc.md §19/§20: shape+font+colors settled
     across several playground passes (game-over-modal-playground.html,
     tier-stamp-playground.html, tier-colors-playground.html), colors
     are option 2 from tier-colors-playground.html (distinct hue per
     tier, report-card style) rather than gold-only. Taken out of
     normal page flow and centered over the score table specifically
     (.score-table-wrap), not the whole modal. */
  .score-table-wrap {
    position: relative;
  }
  .tier-badge-wrap {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) translate(39px, -50px);
    z-index: 5;
    pointer-events: none;
  }
  .tier-badge {
    position: relative;
    width: 104px;
    height: 104px;
    border-radius: 50%;
    border: 4px solid var(--ink-soft);
    outline: 4px solid var(--ink-soft);
    outline-offset: 3px;
    background: var(--bg);
    display: flex;
    align-items: center;
    justify-content: center;
    /* Hidden until either the slam-in reveal plays (first time seeing
       today's result) or the .shown class is applied directly (every
       later visit that same day — see revealTierBadge()/
       showTierBadgeInstantly() in game.js). */
    opacity: 0;
    transform: rotate(var(--stamp-rotation, 10deg)) scale(4);
  }
  .tier-badge.shown {
    /* Final resting state applied directly, no animation — used on
       every visit after the first reveal of the day. */
    opacity: 0.35;
    transform: rotate(var(--stamp-rotation, 10deg)) scale(1.3);
  }
  .tier-badge.slam-in {
    /* Ease-in — motion stays fast right up to the very end instead of
       decelerating into the stop, so it reads as an abrupt impact
       rather than a gentle landing. */
    animation: stampSlam 320ms cubic-bezier(0.95, 0.05, 0.795, 0.035) forwards;
  }
  @keyframes stampSlam {
    /* Rotation stays fixed at the randomized angle throughout — real
       stamps don't spin as they land, only size/impact animate. No
       overshoot — straight deceleration from huge to final resting
       scale. */
    0%   { transform: rotate(var(--stamp-rotation)) scale(4);   opacity: 0; }
    100% { transform: rotate(var(--stamp-rotation)) scale(1.3); opacity: 0.35; }
  }

  /* ===================================================================
     Round-start banner (v319) — big centered "Round N"/"Final Round",
     slam-in then a quick fade. Settled through direct iteration in
     final-round-banner-playground.html (font/size/scale/easing/timing/
     color all chosen there, ported directly, not re-derived) — a softer
     overshoot ease rather than the tier stamp's abrupt impact curve
     above, and white text rather than gold, since this needs to read
     clearly over any of the three track colors behind it, not just sit
     as a translucent watermark the way the tier stamp does. Triggered
     from playRoundBanner() (board-render.js), called from
     advanceRound() (round-flow.js) — see that call site's own comment
     for why it only ever fires on rounds 2 through 5, never round 1.
  =================================================================== */
  .board-scrim {
    position: absolute;
    inset: 0;
    z-index: 6;
    background: rgba(0,0,0,0);
    pointer-events: none;
    transition: background 120ms ease;
  }
  .board-scrim.on { background: rgba(0,0,0,0.28); }

  .round-banner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) scale(1);
    z-index: 7;
    max-width: calc(100% - 32px);
    pointer-events: none;
    opacity: 0;
    font-family: 'Anton', sans-serif;
    font-size: 64px;
    line-height: 1.05;
    letter-spacing: 0.02em;
    color: #fff;
    text-shadow: 0 2px 12px rgba(0,0,0,0.45);
    text-align: center;
    text-transform: uppercase;
    /* v320 — Safari can miscalculate shrink-to-fit width for an
       absolutely-positioned, transform-centered element with no
       explicit width, wrapping short text that has plenty of room.
       nowrap sidesteps that calculation entirely — max-width above is
       still a safety cap against genuine overflow on a very long custom
       string, it just won't wrap into a second line if hit. */
    white-space: nowrap;
  }
  .round-banner.slam-in {
    /* Soft overshoot — springs slightly past rest scale before settling,
       reads as energetic without the tier stamp's harsher impact. */
    animation: roundBannerSlam 250ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
  }
  @keyframes roundBannerSlam {
    0%   { transform: translate(-50%, -50%) scale(3.2); opacity: 0; }
    100% { transform: translate(-50%, -50%) scale(1);   opacity: 1; }
  }
  .round-banner.fade-out {
    animation: roundBannerFadeOut 240ms ease-in forwards;
  }
  @keyframes roundBannerFadeOut {
    0%   { opacity: 1; }
    100% { opacity: 0; }
  }

  /* Score-reveal wiggle — plays on each score in turn before the stamp
     (see startTierRevealSequence() in game.js), ending on the total
     badge. Rotates relative to --wiggle-base-rotate rather than an
     absolute 0deg, since the total badge has its own resting 7deg tilt
     (see .sb-total-badge) — animating an absolute rotate(0deg) on top
     of that caused a hard snap at both ends of its wiggle. */
  @keyframes scoreWiggle {
    0%   { transform: rotate(var(--wiggle-base-rotate, 0deg)) scale(1); }
    25%  { transform: rotate(calc(var(--wiggle-base-rotate, 0deg) - 9deg)) scale(1.28); }
    50%  { transform: rotate(calc(var(--wiggle-base-rotate, 0deg) + 7deg)) scale(1.16); }
    75%  { transform: rotate(calc(var(--wiggle-base-rotate, 0deg) - 4deg)) scale(1.22); }
    100% { transform: rotate(var(--wiggle-base-rotate, 0deg)) scale(1); }
  }
  .num.wiggle {
    animation: scoreWiggle 350ms ease-in-out;
  }

  /* Uneven-ink overlay — a handful of soft radial-gradient patches,
     alternating lighter/darker, blended with mix-blend-mode so the
     stamp reads as unevenly pressed rather than a flat uniform color. */
  .tier-badge::after {
    content: '';
    position: absolute;
    inset: -8px;
    border-radius: 50%;
    background:
      radial-gradient(circle at 28% 22%, rgba(255,255,255,0.55) 0%, transparent 38%),
      radial-gradient(circle at 78% 68%, rgba(255,255,255,0.4) 0%, transparent 42%),
      radial-gradient(circle at 60% 85%, rgba(0,0,0,0.18) 0%, transparent 45%),
      radial-gradient(circle at 15% 75%, rgba(0,0,0,0.12) 0%, transparent 35%);
    mix-blend-mode: overlay;
    pointer-events: none;
  }
  .tier-badge .tier-letter {
    font-family: 'Anton', sans-serif;
    font-size: 85px;
    line-height: 1;
    color: var(--ink-soft);
  }
  .tier-badge .tier-plus {
    font-family: 'Anton', sans-serif;
    font-size: 65px;
    line-height: 1;
    vertical-align: top;
    margin-left: 2px;
    color: var(--ink-soft);
  }
  .tier-badge.tier-splus { --tc: #D9B85C; }
  .tier-badge.tier-s     { --tc: #C9A23E; }
  .tier-badge.tier-a     { --tc: #3F8B5C; }
  .tier-badge.tier-b     { --tc: #4A7FB5; }
  .tier-badge.tier-c     { --tc: #D9822B; }
  .tier-badge.tier-d     { --tc: #B5503F; }
  .tier-badge.tier-splus,
  .tier-badge.tier-s,
  .tier-badge.tier-a,
  .tier-badge.tier-b,
  .tier-badge.tier-c,
  .tier-badge.tier-d {
    border-color: var(--tc);
    outline-color: var(--tc);
  }
  .tier-badge.tier-splus .tier-letter, .tier-badge.tier-splus .tier-plus,
  .tier-badge.tier-s .tier-letter,     .tier-badge.tier-s .tier-plus,
  .tier-badge.tier-a .tier-letter,     .tier-badge.tier-a .tier-plus,
  .tier-badge.tier-b .tier-letter,     .tier-badge.tier-b .tier-plus,
  .tier-badge.tier-c .tier-letter,     .tier-badge.tier-c .tier-plus,
  .tier-badge.tier-d .tier-letter,     .tier-badge.tier-d .tier-plus {
    color: var(--tc);
  }
  .game-over-actions {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  /* v266 — wraps Retry This Game + Return to Game as a shared row inside
     .game-over-actions' column flow. flex:1 on each child means a single
     visible child (Retry hidden on daily's screen) still fills the row. */
  .game-over-actions-row {
    display: flex;
    gap: 10px;
  }
  .game-over-actions-row > button {
    flex: 1;
  }
  .game-over-new-btn {
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    font-size: 16px;
    background: var(--green);
    color: #fff;
    border: none;
    border-radius: 10px;
    padding: 13px;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease;
  }
  .game-over-new-btn:hover { background: var(--green-bright); }
  .game-over-new-btn:active { background: var(--green-dark); }
  .game-over-share-btn {
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    font-size: 16px;
    background: var(--purple);
    color: #fff;
    border: none;
    border-radius: 10px;
    padding: 13px;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease;
    position: relative;
  }
  .game-over-share-btn:hover { background: var(--purple-bright); }
  .game-over-share-btn:active { background: var(--purple); }
  .game-over-share-btn.copied::after {
    content: 'Copied!';
    position: absolute;
    top: -30px;
    left: 50%;
    transform: translateX(-50%);
    background: var(--ink);
    color: #fff;
    font-family: 'Inter', sans-serif;
    font-size: 12px;
    font-weight: 600;
    padding: 5px 10px;
    border-radius: 6px;
    white-space: nowrap;
    animation: shareCopiedFade 1.6s ease forwards;
    pointer-events: none;
  }
  @keyframes shareCopiedFade {
    0%   { opacity: 0; transform: translateX(-50%) translateY(4px); }
    15%  { opacity: 1; transform: translateX(-50%) translateY(0); }
    75%  { opacity: 1; }
    100% { opacity: 0; }
  }
  .game-over-return-btn {
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    font-size: 13px;
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: var(--ink);
    padding: 11px;
    border-radius: 10px;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .game-over-return-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }

  /* v270 — .practice-menu-primary, .practice-menu-highlight-daily,
     .practice-menu-modal, and .practice-menu-body (the practice-mode
     menu's own styling) were removed here — practiceMenuOverlay is
     retired, replaced by the home page (.home-page-overlay etc., below)
     plus the challenge-link/practice-confirmation modals (.modal-scrim
     etc.). See design-doc.md for the full writeup of what this
     replaced. */

  /* ===================================================================
     Home page (v270) — replaces practiceMenuOverlay as the app's mode
     switcher. Always shown first on launch, reachable any time via the
     header's home button. Validated first in
     home-page-playground.html (discarded once ported) — Metro/Windows-
     Phone-tile-style flat rectangles, sharp-ish corners
     (var(--home-radius)), no shadows/gradients, using the game's
     existing track colors rather than a separate palette.
     Deliberately opaque (background: var(--bg), no blur/translucency)
     unlike every other overlay in this file — no game state should ever
     show through behind it. Mobile-width-constrained
     (.home-page's own max-width) and centered on anything wider, rather
     than stretching full-bleed on desktop.
     v274 — .home-page-overlay's own background changed from var(--ink)
     to var(--bg), matching .home-page's own card exactly (this comment
     block already described var(--bg) as the intent even before this
     fix — the two had quietly drifted apart at some point). This was
     originally the dark ink surround visible outside .home-page's
     max-width on a wide viewport; an iOS elastic-bounce gap at the
     top/bottom of this element's own scroll (overflow-y:auto) could
     reveal the game view's own var(--bg) underneath instead of that
     ink, breaking continuity once the home page grew tall enough to
     actually scroll (§26's new Challenge tile). A same-color match
     everywhere — this overlay, .home-page, and html/body/#bg-fill
     beneath it all read the identical var(--bg) — means there's
     nothing left to mismatch regardless of what any bounce reveals, a
     simpler and more robust fix than the v274-original attempt (toggle
     a class to match html/body/#bg-fill to var(--ink) instead), which
     didn't resolve it in practice. See design-doc.md §27 for the full
     history.
     v281 — bigger structural change, superseding v276/v278/v280's
     failed attempts at the real "cut off by Safari's translucent
     toolbar" bug. This element is no longer position:fixed or its own
     overflow-y:auto scroll container at all — it's normal document
     flow now, toggled display:none/block alongside #gameWrap (see
     openHomePage()/closeHomePage(), round-flow.js) rather than
     layering on top of it. Root cause: Safari's "let content scroll
     behind the dynamic toolbar" accommodation applies to the root
     document scroll (body) — which is why the main game view never had
     this problem — but does not extend to a NESTED fixed+overflow:auto
     container the way this element used to be, regardless of which
     height unit (dvh, plain inset:0) or how much padding it was given;
     three straight version bumps tuning those values produced zero
     visible change, which is what pointed at an architectural cause
     rather than a wrong number. Making this element part of normal
     body-level scroll instead means it inherits the exact same
     behavior that already works correctly for the main game view. */
  /* Page-swap crossfade (v291) — home<->game, home<->achievements, and
     (v312) the Help page's own pairs, deliberately not applied to any
     true modal (.modal-scrim, .game-over-overlay) — those stay instant. Durations
     set inline via JS (crossfadePages(), round-flow.js) from a single
     PAGE_TRANSITION_MS constant rather than duplicated here, same
     precedent as the die-flight animation's transitionDuration
     (placement.js) — nothing to keep in sync by hand. .page-fade-in has
     no transition of its own on purpose: it's the pre-reveal resting
     state (opacity 0, scale 0.98) applied *before* display:block, so
     the element doesn't animate from its old visible state when it's
     freshly shown; .page-fade-in-active (added one double-rAF later,
     once the browser has painted that resting state — same technique
     the achievement toast already uses, achievements.js) is what
     actually transitions it in. */
  .page-fade-out {
    transition-property: opacity, transform;
    transition-timing-function: ease;
    opacity: 0;
    transform: scale(0.98);
  }
  .page-fade-in {
    opacity: 0;
    transform: scale(0.98);
  }
  .page-fade-in.page-fade-in-active {
    transition-property: opacity, transform;
    transition-timing-function: ease;
    opacity: 1;
    transform: scale(1);
  }
  /* v293 — #gameWrap's entrance drops the scale component entirely
     (opacity fade only), scoped to this element alone so achievements'
     own entrance keeps the pop. Root cause: game entry positions
     badges/connectors via getBoundingClientRect() (round-flow.js's
     onGameVisible callback, board-render.js/badges-connectors.js) the
     instant gameWrap becomes visible — before the double-rAF above even
     starts the scale transition, i.e. while gameWrap sits statically at
     the scale(0.98) *resting* value. getBoundingClientRect() reports
     post-transform coordinates, so every measurement taken at that
     moment comes back ~2% off from where things belong once gameWrap
     settles at its true scale(1). The existing 260ms fallback re-render
     (board-render.js's render(), there originally for web-font-metric
     settling, not this) then corrects it once the transition's done —
     visible as a "twitch" into place shortly after the game loads.
     Achievements has no measured/positioned content, so its own
     scale-pop entrance never hits this and is left untouched. */
  #gameWrap.page-fade-in {
    transform: none;
  }
  #gameWrap.page-fade-in.page-fade-in-active {
    transform: none;
  }

  .home-page-overlay {
    display: none;
    width: 100%;
  }
  .home-page {
    width: 100%;
    max-width: 420px;
    /* v281 — was min-height:100% (relative to .home-page-overlay's own
       explicit fixed-position height, now removed). Declared directly
       here instead, same two-step progressive-enhancement pair body
       already uses, so this still fills at least one full screen with
       sparse content even now that its parent has no explicit height
       of its own to inherit a percentage from. */
    min-height: 100vh;
    min-height: 100dvh;
    margin: 0 auto;
    background: var(--bg);
    /* v309 — horizontal padding dropped to 0: body now supplies the
       10px edge inset directly (see body's own comment), so this
       layer would otherwise double it to 20px. */
    padding: 0 0 40px;
    /* v278 — env(safe-area-inset-bottom) only reflects hardware safe
       areas (notch, home indicator), not Safari's own toolbar height —
       that's browser chrome, not a hardware inset, and iOS doesn't
       expose it through this variable. A generous flat 120px fallback
       instead of trying to derive the exact right number, with the
       real hardware inset still added on top for the notched-device
       home-indicator case specifically. Genuinely takes effect now
       that this is real body-level scroll content (v281) rather than
       trapped inside a nested scroller that, per that version's
       theory, may never have granted this padding any real clearance
       at all regardless of its value. */
    padding-bottom: calc(120px + env(safe-area-inset-bottom));
  }
  .home-header {
    text-align: center;
    margin-bottom: 12px;
  }
  .home-title {
    font-family: 'Anton', sans-serif;
    font-size: 30px;
    letter-spacing: 0.5px;
    margin: 0;
    color: var(--ink);
  }
  .tile-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
  }
  .tile {
    grid-column: span 2;
    padding: 18px 16px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    min-height: 122px;
    cursor: pointer;
    border: none;
    text-align: left;
    font-family: inherit;
    position: relative;
    overflow: hidden;
    border-radius: var(--home-radius);
    color: #fff;
  }
  .tile:active { transform: scale(0.98); }
  .tile-eyebrow {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    font-weight: 700;
    letter-spacing: 0.02em;
    opacity: 0.75;
  }
  .tile-title {
    font-family: 'Anton', sans-serif;
    font-size: 28px;
    line-height: 1.05;
    margin: 6px 0 0;
  }
  .tile-meta {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    margin-top: 10px;
    opacity: 0.85;
  }
  .tile-action {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 17px;
    margin-top: 12px;
    align-self: flex-start;
    padding: 6px 12px;
    background: rgba(35,38,30,0.5);
    border-radius: calc(var(--home-radius) * 0.6);
  }

  /* Daily — the big featured tile, gold-dark. */
  .tile-daily { background: var(--gold-dark); }
  /* Wraps eyebrow/title/meta/action as one column — needed so the
     completed state's flex-direction:row (below) has a single text
     item to place beside the stamp, rather than each child becoming
     its own row item. Fills the tile normally (flex:1, column) in the
     non-completed states, where there's no stamp to make room for. */
  .tile-daily-text {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    flex: 1;
    min-width: 0;
  }

  /* Completed daily splits into two columns: text on the left, a large
     tier stamp (see .home-tier-stamp below) on the right half of the
     card — hidden entirely when no best-possible score was captured for
     that game (see renderHomeDailyTile(), round-flow.js). */
  .tile-daily.tile-daily-done {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }
  .tile-daily-done .tile-daily-text {
    align-self: stretch;
  }
  /* v411 — stamp sizing via custom properties (§164); half mode
     overrides them below. Full mode: 130px (was 120), letter 106.2px
     (was 98), plus 80.2px (was 74), opacity 0.65 (was 1) — the 98/120
     and 74/120 ratios kept. */
  .tile-daily {
    --stamp-size: 130px;
    --stamp-letter: 106.2px;
    --stamp-plus: 80.2px;
    --stamp-op: 0.65;
  }
  .tile-stamp-wrap {
    flex-shrink: 0;
    width: var(--stamp-size);
    height: var(--stamp-size);
    opacity: var(--stamp-op);
    margin-right: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* A smaller, separately-colored echo of the game-over screen's own
     .tier-badge (styles.css, search "score-table-wrap") — same ring +
     big-letter-stamp shape, but a deliberately different, black-blended
     palette chosen for the home page specifically (see
     HOME_TIER_CLASS_BY_TIER in round-flow.js) rather than reusing
     .tier-badge's saturated colors as-is. No white fill or ink-texture
     overlay (tried first, didn't read well at this size) — just the
     ring and the letter. */
  .home-tier-stamp {
    position: relative;
    width: var(--stamp-size);
    height: var(--stamp-size);
    border-radius: 50%;
    border: 3px solid var(--ink-soft);
    outline: 3px solid var(--ink-soft);
    outline-offset: 2px;
    display: flex;
    align-items: center;
    justify-content: center;
    transform: rotate(9deg);
  }
  .home-tier-stamp .tile-stamp-letter {
    font-family: 'Anton', sans-serif;
    font-size: var(--stamp-letter);
    line-height: 1;
    color: var(--tc);
  }
  .home-tier-stamp .tile-stamp-plus {
    font-family: 'Anton', sans-serif;
    font-size: var(--stamp-plus);
    line-height: 1;
    vertical-align: top;
    margin-left: 1px;
    color: var(--tc);
  }
  /* Black-blended (50%) versions of .tier-badge's own tier colors — a
     deliberate separate choice for the home page, not a change to
     .tier-badge itself. */
  .home-tier-stamp.tier-splus { --tc: #6C5C2E; border-color: var(--tc); outline-color: var(--tc); }
  .home-tier-stamp.tier-s     { --tc: #64511F; border-color: var(--tc); outline-color: var(--tc); }
  .home-tier-stamp.tier-a     { --tc: #20462E; border-color: var(--tc); outline-color: var(--tc); }
  .home-tier-stamp.tier-b     { --tc: #25405A; border-color: var(--tc); outline-color: var(--tc); }
  .home-tier-stamp.tier-c     { --tc: #6C4116; border-color: var(--tc); outline-color: var(--tc); }
  .home-tier-stamp.tier-d     { --tc: #5A2820; border-color: var(--tc); outline-color: var(--tc); }

  /* Practice — half-width pair. Full green for Resume/Play Again (an
     active, in-motion state), a lighter green-bright for New Game (a
     calmer "starting fresh" state) — same track color, two weights of
     it, rather than two unrelated colors. */
  .tile-practice-half { grid-column: span 1; }

  /* Home layout v411 (§164) — tile order depends on whether today's
     Daily is done (.daily-half on .tile-grid), whether a challenge tile
     is showing (.has-challenge) and whether the bounty tile is hidden
     (.bounty-hidden). DOM order is untouched; only CSS order changes.
       Daily not done: Daily (full) / Free Play | Bounty / [Challenge |] Horde
         | Help-or-next / Achievements | Stats / [Help]
       Daily done:     Free Play | Bounty / Daily | Horde / [Challenge | Help]
         / Achievements | Stats / [Help]
     Help fills whichever half slot is left over, else sits alone last. */
  .tile-grid .tile-daily        { order: 1; }
  .tile-grid .tile-freeplay     { order: 2; }
  .tile-grid .tile-bounty       { order: 3; }
  .tile-grid .tile-challenge    { order: 4; }
  .tile-grid .tile-horde        { order: 5; }
  .tile-grid .tile-achievements { order: 7; }
  .tile-grid .tile-stats        { order: 8; }
  .tile-grid .tile-help         { order: 9; }
  .tile-grid:not(.daily-half):not(.has-challenge) .tile-help { order: 6; }
  .tile-grid.daily-half .tile-freeplay  { order: 1; }
  .tile-grid.daily-half .tile-bounty    { order: 2; }
  .tile-grid.daily-half .tile-daily     { order: 3; }
  .tile-grid.daily-half .tile-horde     { order: 4; }
  .tile-grid.daily-half .tile-challenge { order: 5; }
  .tile-grid.daily-half.has-challenge .tile-help { order: 6; }
  .tile-grid.bounty-hidden .tile-freeplay { grid-column: span 2; }

  /* Half-width Daily (settled in daily-tile-half-playground.html): label
     "DAILY", 24px title, no View Results chip (round-flow.js), and the
     stamp in the top-right corner behind the text instead of inline. */
  .tile-grid.daily-half .tile-daily {
    grid-column: span 1;
    --stamp-size: 86px;
    --stamp-letter: 70.2px;
    --stamp-plus: 53.0px;
  }
  .tile-grid.daily-half .tile-daily .tile-title { font-size: 24px; }
  .tile-grid.daily-half .tile-daily.tile-daily-done { flex-direction: column; }
  .tile-grid.daily-half .tile-stamp-wrap {
    position: absolute;
    top: 3px;
    right: -1px;
    z-index: 0;
    margin: 0;
  }
  .tile-grid.daily-half .tile-daily-text { position: relative; z-index: 1; }
  /* v408 — the single Free Play tile keeps the pair's two weights:
     green-bright when there's nothing to come back to ("New Game"),
     full green when a game is in progress or just finished. */
  .tile-freeplay { background: var(--green-bright); }
  .tile-freeplay.tile-freeplay-active { background: var(--green); }

  /* Challenge — full-width, single tile (grid-column: span 2 already
     the default on .tile, no override needed). Red/red-dark, per the
     color plan settled at v270 that specifically reserved red for
     challenge mode "once it has its own save slot" — now that it does.
     Hidden entirely with no save (see renderHomeChallengeTile(),
     round-flow.js), same pattern as Practice's resume tile. */
  .tile-challenge { background: var(--red-dark); }
  /* v411 — half width; 24px title so "Challenge Completed" fits. */
  .tile-challenge { grid-column: span 1; }
  .tile-challenge .tile-title { font-size: 24px; }

  /* Horde (v398) — half-width (span 1) but full tile height (the default
     122px .tile min-height, not the 78px Achievements/Stats shape), sitting
     alone under the Practice pair for now. Plain --orange: no visible tile
     used it (only the hidden Settings placeholder below). The counts are
     grouped so they sit together at the bottom under .tile's own
     space-between layout. */
  .tile-horde {
    grid-column: span 1;
    background: var(--orange);
  }
  .tile-horde-counts .tile-meta + .tile-meta { margin-top: 4px; }

  /* Bounty (v404, §157) — left half of the Horde row, full tile height,
     --orange-dark so it reads as related to Horde's --orange but distinct
     (the Achievements/Stats two-shade pattern). Smaller title than a
     full tile: bounty names run longer than "Your Horde". The bar sits
     with the meta line at the bottom under .tile's space-between. */
  .tile-bounty {
    grid-column: span 1;
    background: var(--orange-dark);
  }
  .tile-bounty .tile-title { font-size: 20px; }
  .tile-bounty .tile-meta { margin-top: 6px; font-size: 13px; }
  .tile-bounty .tile-bounty-time { margin-top: 2px; }
  .tile-bounty .tile-bounty-time:empty { display: none; }
  .bounty-bar {
    height: 8px;
    margin-top: 10px;
    border-radius: 4px;
    background: rgba(255,255,255,0.28);
    overflow: hidden;
  }
  .bounty-bar-fill {
    height: 100%;
    width: 0;
    border-radius: 4px;
    background: #fff;
    transition: width 0.4s ease;
  }
  /* Completed, unclaimed: the bar turns gold until the reward is claimed. */
  .tile-bounty.bounty-ready .bounty-bar-fill { background: var(--gold); }

  /* Bounty modal (v404). */
  .bounty-modal-body { margin-bottom: 14px; }
  .bounty-bar-modal { margin-top: 0; background: var(--line); }
  .bounty-bar-modal .bounty-bar-fill { background: var(--orange-dark); }
  .bounty-modal-status {
    display: flex;
    justify-content: space-between;
    margin-top: 6px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    color: var(--ink-soft);
  }
  .bounty-reward {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-top: 16px;
    padding: 12px;
    background: var(--orange-tint);
    border-radius: calc(var(--home-radius) * 0.6);
  }
  .bounty-reward-icons {
    display: flex;
    align-items: flex-end;
    gap: 4px;
    min-width: 72px;
  }
  .bm-die {
    width: 16px;
    height: 16px;
    border-radius: 4px;
    display: inline-block;
  }
  .bm-die-big { width: 26px; height: 26px; border-radius: 6px; }
  .bm-die-purple { background: var(--purple); }
  .bm-die-orange { background: var(--orange); }
  .bm-die-green { background: var(--green); }
  .bm-die-red { background: var(--red); }
  .bm-die-blue { background: var(--blue); }
  .bm-die-yellow { background: var(--yellow); }
  .bounty-reward-label {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    font-weight: 700;
    color: var(--ink-soft);
  }
  .bounty-reward-amount {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 17px;
    color: var(--ink);
    line-height: 1.2;
  }
  .bounty-reward-status {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 14px;
    color: var(--orange-dark);
    min-height: 0;
  }
  .bounty-reward-status:empty { display: none; }
  /* v424 (§177) — completed bounty: the reward box links to the Horde. */
  .bounty-reward.to-horde { cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform 0.1s ease; }
  .bounty-reward.to-horde:active { transform: scale(0.98); }
  /* Claim animation: the reward dice hop in turn and the status line
     fades up — one moment, answering the player opening the modal. */
  .bounty-reward.claiming .bm-die { animation: bountyDieHop 0.5s ease-out both; }
  .bounty-reward.claiming .bounty-reward-status { animation: bountyStatusIn 0.4s ease-out 0.35s both; }
  .bounty-reward.claiming, .bounty-reward.claimed { background: #F6E3B4; }
  @keyframes bountyDieHop {
    0% { transform: translateY(0) scale(1); }
    40% { transform: translateY(-12px) scale(1.15); }
    70% { transform: translateY(0) scale(0.95); }
    100% { transform: translateY(0) scale(1); }
  }
  @keyframes bountyStatusIn {
    from { opacity: 0; transform: translateY(4px); }
    to { opacity: 1; transform: translateY(0); }
  }
  @media (prefers-reduced-motion: reduce) {
    .bounty-reward.claiming .bm-die,
    .bounty-reward.claiming .bounty-reward-status { animation: none; }
  }
  .bounty-rule {
    margin: 12px 0 16px;
    font-family: 'Baloo 2', sans-serif;
    font-size: 13px;
    line-height: 1.35;
    color: var(--ink-soft);
  }
  .modal-btn-bounty-play { background: var(--orange-dark); }
  /* v424 (§177) — Free Play green, matching the game-over screen's
     New Free Play Game button (.game-over-new-btn). The Fresh Start
     notice's button keeps .modal-btn-bounty-play (orange). */
  .modal-btn-free-play { background: var(--green); }
  .modal-btn-free-play:hover { background: var(--green-bright); }
  .modal-btn-free-play:active { background: var(--green-dark); }

  /* Achievements (v288, recolored v289, swapped back v290) — live
     half-tile preceding Stats. Takes --purple-mid; Stats carries plain
     --purple. Colors have been swapped once already (v289 had this
     flipped) — --purple-mid is the shade that stays constant regardless
     of which tile wears it, only the assignment has moved. Same
     78px/20px-title shape as .tile-future below for row-height
     consistency, but full opacity and cursor:pointer since this one is
     actually live. */
  .tile-achievements {
    grid-column: span 1;
    min-height: 78px;
    background: var(--purple-mid);
  }
  .tile-achievements .tile-title { font-size: 20px; }
  .tile-achievements .tile-meta {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    margin-top: 8px;
    opacity: 0.85;
  }

  /* Stats (v305) — live half-tile pairing with Achievements, same
     78px/20px-title shape and full-opacity/cursor:pointer treatment as
     .tile-achievements above (mirrored directly, not re-derived).
     Plain --purple (not --purple-mid) — matches .tile-future-stats'
     former shade, so the tile doesn't visually jump now that it's live. */
  .tile-stats {
    grid-column: span 1;
    min-height: 78px;
    background: var(--purple);
  }
  .tile-stats .tile-title { font-size: 20px; }
  .tile-stats .tile-meta {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    margin-top: 8px;
    opacity: 0.85;
  }

  /* Help (v312) — live, tappable, same shape as Achievements/Stats
     above but paired with Settings below instead. No .tile-meta line
     (no per-game data to show), so no separate font-size override
     needed the way .tile-stats/.tile-achievements have. */
  .tile-help {
    grid-column: span 1;
    min-height: 78px;
    background: var(--blue);
  }
  .tile-help .tile-title { font-size: 20px; }

  /* Locked/future placeholders — Settings (orange), matching the actual
     matching the actual dice-track colors rather than a washed-out
     "disabled" tint (tried first in the playground — read as broken
     rather than "coming soon"). A slight overall dim (opacity) is the
     one signal that these aren't live yet. */
  .tile-future {
    grid-column: span 1;
    min-height: 78px;
    cursor: default;
    opacity: 0.82;
  }
  .tile-future-settings { background: var(--orange); }
  .tile-future .tile-title { font-size: 20px; }
  .tile-future::after {
    content: 'Coming soon';
    font-family: 'JetBrains Mono', monospace;
    font-size: 10px;
    margin-top: 8px;
    display: block;
    opacity: 0.75;
  }

  /* ===================================================================
     Achievements page (v288) — reached from the home page's Achievements
     tile. Same mutually-exclusive normal-document-flow-sibling pattern
     as .home-page-overlay (see that block's own v281 comment for why —
     a nested position:fixed;overflow-y:auto container gets cut off by
     iOS Safari's translucent toolbar; normal document flow doesn't).
     .achievements-page reuses .home-page's own sizing/background/padding
     rules directly rather than duplicating them, so the two pages stay
     visually identical without needing to keep two copies in sync. */
  .achievements-page-overlay {
    display: none;
    width: 100%;
  }
  .achievements-page {
    width: 100%;
    max-width: 420px;
    min-height: 100vh;
    min-height: 100dvh;
    margin: 0 auto;
    background: var(--bg);
    /* v313 — doubled again, 6px \u2192 12px (was 0 as of v309, 10px
       before that): body's 10px + this page's own 12px = 22px total,
       up from v310's 16px. .home-page itself is unaffected \u2014 not
       part of this request, still at the tighter 10px total. */
    padding: 28px 12px 40px;
    padding-bottom: calc(120px + env(safe-area-inset-bottom));
  }
  .achievements-header {
    position: relative;
    text-align: center;
    margin-bottom: 22px;
    /* v309 — dropped to 0, same reasoning as .achievements-page above:
       this was previously a second 10px layer stacked on top of the
       page's own padding. */
    padding: 0;
  }
  .achievements-back-btn {
    position: absolute;
    /* v309 — was 10px: with .achievements-header's own padding now 0
       (see that block's own comment), this offset is the only thing
       left controlling the button's inset — 0 keeps it flush with the
       rows below rather than sitting 10px further in than everything
       else now that the page/header/list no longer double up. */
    left: 0;
    top: -2px;
    width: 34px;
    height: 34px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: none;
    color: var(--ink);
    cursor: pointer;
    touch-action: manipulation;
  }
  .achievements-back-btn svg { width: 22px; height: 22px; }
  .achievements-progress {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    color: var(--ink-soft);
    margin-top: 6px;
  }
  .achievements-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    /* v309 — dropped to 0, same reasoning as .achievements-header above. */
    padding: 0;
  }
  .achievement-row {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 14px 16px;
    border-radius: var(--home-radius);
    background: var(--purple-tint);
  }
  .achievement-row .ar-icon {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    border-radius: 10px;
    background: rgba(255,255,255,0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 20px;
  }
  .achievement-row .ar-text { min-width: 0; }
  .achievement-row .ar-name {
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 16px;
    color: var(--ink);
  }
  .achievement-row .ar-desc {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--ink-soft);
    margin-top: 3px;
  }
  /* Unearned rows — same shape, dimmed and desaturated so earned ones
     read as the "collected" set at a glance; no description text at
     all (not even blanked-out), per spec — nothing to reveal. */
  .achievement-row.ar-locked {
    background: var(--surface);
    opacity: 0.55;
  }
  .achievement-row.ar-locked .ar-icon { background: rgba(0,0,0,0.06); }

  /* ===================================================================
     Stats page (v305) — reached from the home page's Stats tile. Same
     mutually-exclusive normal-document-flow-sibling pattern as
     .achievements-page-overlay above; .stats-page reuses .achievements-
     page's own sizing/background/padding rather than duplicating it, so
     the two stay visually identical without keeping two copies in sync.
     The header/back-button markup itself reuses .achievements-header/
     .achievements-back-btn directly (see index.html) rather than a
     stats-specific copy — only the section/row styling below is new. */
  .stats-page-overlay {
    display: none;
    width: 100%;
  }
  .stats-page {
    width: 100%;
    max-width: 420px;
    min-height: 100vh;
    min-height: 100dvh;
    margin: 0 auto;
    background: var(--bg);
    /* v313 — doubled again, 6px → 12px, same reasoning as
       .achievements-page's own v313 change (its comment has the full
       reasoning) — 12px here + body's 10px = 22px total. */
    padding: 28px 12px 40px;
    padding-bottom: calc(120px + env(safe-area-inset-bottom));
  }
  /* ===================================================================
     Horde page (v398) — same overlay/page pair and shared back-arrow
     header as Stats above, same max-width/side padding (12px here +
     body's 10px = 22px). No min-height or big bottom padding: this page
     doesn't scroll — #hordeBox's height is set by hordeSizeBox()
     (horde.js) to fill the rest of the screen, 22px above the bottom.
  =================================================================== */
  .horde-page-overlay {
    display: none;
    width: 100%;
  }
  .horde-page {
    width: 100%;
    max-width: 420px;
    margin: 0 auto;
    background: var(--bg);
    padding: 28px 12px 0;
  }
  /* Recessed charcoal box the dice pile up in; canvases are stacked
     inside it by hordeInit(). */
  .horde-box {
    position: relative;
    min-height: 160px;
    border-radius: 10px;
    overflow: hidden;
    background: linear-gradient(180deg, #3A3D35 0%, #2A2C26 100%);
    box-shadow: inset 0 2px 6px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(0,0,0,0.12);
  }
  .horde-box canvas.horde-layer {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
  }
  /* Dice count, top-right inside the box, above the tap overlay. */
  .horde-total {
    position: absolute;
    top: 10px;
    right: 14px;
    z-index: 3;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 700;
    font-size: 28px;
    line-height: 1;
    color: #FFFFFF;
    font-variant-numeric: tabular-nums;
    text-shadow: 0 1px 3px rgba(0,0,0,0.5);
    /* v425 (§178) — a button now: opens the stats modal. Disabled (and
       click-through) while the "You've claimed…" overlay waits. */
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }
  .horde-total:disabled { pointer-events: none; cursor: default; }
  .horde-total:active { opacity: 0.75; }
  /* v425 (§178) — view toggle (segmented pill) and Share, under the box. */
  .horde-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-top: 12px;
  }
  .horde-view-toggle {
    display: inline-flex;
    padding: 3px;
    border-radius: 999px;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--line);
  }
  .horde-view-btn, .horde-share-btn {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    font-weight: 700;
    border: 0;
    border-radius: 999px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
  }
  .horde-view-btn {
    padding: 8px 14px;
    background: none;
    color: var(--ink-soft);
    transition: background 0.15s ease, color 0.15s ease;
  }
  .horde-view-btn.is-active { background: var(--ink); color: var(--bg); }
  .horde-share-btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 11px 16px;
    background: var(--surface);
    color: var(--ink);
    box-shadow: inset 0 0 0 1px var(--line);
  }
  .horde-share-btn svg { width: 16px; height: 16px; }
  .horde-share-btn:active { transform: scale(0.97); }
  .horde-share-btn:disabled { opacity: 0.4; cursor: default; transform: none; }
  /* v425 (§178) — Horde stats modal: color x size table. */
  .horde-stats-table {
    width: 100%;
    margin-top: 14px;
    border-collapse: collapse;
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    font-variant-numeric: tabular-nums;
    color: var(--ink);
  }
  .horde-stats-table th, .horde-stats-table td { padding: 7px 4px; text-align: right; }
  .horde-stats-table thead th { font-size: 12px; color: var(--ink-soft); font-weight: 700; padding-top: 0; }
  .horde-stats-table tbody th, .horde-stats-table tfoot th { text-align: left; font-weight: 700; white-space: nowrap; }
  .horde-stats-table tbody tr + tr { border-top: 1px solid var(--line); }
  .horde-stats-table tfoot { border-top: 2px solid var(--ink); }
  .horde-stats-table tfoot td, .horde-stats-table tfoot th { font-weight: 700; }
  .horde-stats-table .is-zero { color: var(--ink-soft); opacity: 0.5; }
  .horde-swatch {
    display: inline-block;
    width: 14px;
    height: 14px;
    margin-right: 8px;
    vertical-align: -2px;
    border-radius: 4px;
  }
  .horde-swatch-purple { background: var(--purple); }
  .horde-swatch-orange { background: var(--orange); }
  .horde-swatch-green { background: var(--green); }
  .horde-swatch-red { background: var(--red); }
  .horde-swatch-blue { background: var(--blue); }
  .horde-swatch-yellow { background: var(--yellow); }
  .horde-swatch-white { background: var(--surface); box-shadow: inset 0 0 0 1px #D6D6D6; }

  /* "You've claimed N new dice…" — darkens/blurs the pile until tapped. */
  .horde-add-overlay {
    position: absolute;
    inset: 0;
    z-index: 2;
    width: 100%;
    margin: 0;
    padding: 24px;
    border: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: rgba(20, 21, 18, 0.45);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    color: #FFFFFF;
    text-align: center;
    cursor: pointer;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 700;
    font-size: 30px;
    line-height: 1.15;
    transition: opacity 0.2s ease;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }
  .horde-add-overlay span { display: block; }
  .horde-add-overlay .horde-add-tap { font-size: 22px; margin-top: 8px; opacity: 0.85; }
  .horde-add-overlay[hidden] { display: none; }
  .horde-add-overlay.is-hiding { opacity: 0; pointer-events: none; }
  .horde-empty {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: rgba(255,255,255,0.85);
    font-size: 14px;
    pointer-events: none;
  }
  .horde-empty[hidden] { display: none; }

  .stats-list {
    display: flex;
    flex-direction: column;
    gap: 18px;
    /* v309 — dropped to 0, same reasoning as .achievements-list above. */
    padding: 0;
  }
  .stats-section-title {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--ink-soft);
    margin-bottom: 8px;
    padding-left: 2px;
  }
  .stat-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    padding: 12px 16px;
    border-radius: var(--home-radius);
    background: var(--purple-tint);
    margin-bottom: 8px;
  }
  .stat-row:last-child { margin-bottom: 0; }
  .stat-label {
    display: flex;
    align-items: center;
    gap: 8px;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 15px;
    color: var(--ink);
  }
  .stat-value {
    font-family: 'JetBrains Mono', monospace;
    font-size: 15px;
    color: var(--ink);
    flex-shrink: 0;
  }
  .stat-row-split { align-items: flex-start; }
  .stat-split {
    display: flex;
    gap: 18px;
    flex-shrink: 0;
  }
  .stat-split-col {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
  }
  .stat-split-tag {
    font-family: 'JetBrains Mono', monospace;
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--ink-soft);
  }
  .stat-split-val {
    font-family: 'JetBrains Mono', monospace;
    font-size: 15px;
    color: var(--ink);
    margin-top: 2px;
  }
  .stat-track-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .stat-track-dot.dot-purple { background: var(--purple); }
  .stat-track-dot.dot-orange { background: var(--orange); }
  .stat-track-dot.dot-green { background: var(--green); }
  .stat-track-dot.dot-red { background: var(--red); }
  .stat-track-dot.dot-blue { background: var(--blue); }
  .stat-track-dot.dot-yellow { background: var(--yellow); }
  .stat-track-vals {
    display: flex;
    /* v317 — tightened from 14px now that "avg X pts," carries its own
       trailing comma as the separator; 14px on top of that read as an
       oversized gap after the comma. */
    gap: 6px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    color: var(--ink);
    flex-shrink: 0;
  }

  /* ===================================================================
     Challenge-link and practice-confirmation modals (v270) — share one
     visual system (.modal-scrim/.modal-card/.modal-btn), styled to match
     the home page's tiles (same fonts, same var(--home-radius)) rather
     than the older .game-over-overlay/.game-over-modal treatment.
     Opaque scrim (unlike the translucent/blurred .game-over-overlay) —
     these sit on top of the home page, which is itself already opaque,
     so there's no game board to deliberately obscure through blur. */
  .modal-scrim {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 11;
    background: rgba(0,0,0,0.55);
    align-items: center;
    justify-content: center;
    padding: 24px;
  }
  .modal-card {
    width: 100%;
    max-width: 340px;
    background: var(--surface);
    padding: 22px 20px 20px;
    border-radius: var(--home-radius);
  }
  .modal-eyebrow {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
    font-weight: 700;
    letter-spacing: 0.02em;
    color: var(--ink-soft);
  }
  .modal-title {
    font-family: 'Anton', sans-serif;
    font-size: 26px;
    line-height: 1.05;
    color: var(--ink);
    margin: 6px 0 0;
  }
  .modal-body {
    font-family: 'Baloo 2', sans-serif;
    font-size: 15px;
    color: var(--ink-soft);
    margin: 10px 0 20px;
    line-height: 1.4;
  }
  /* v272 — conditionally shown second .modal-body in the challenge
     modal specifically (see challengeOverwriteWarning, index.html);
     red matches the modal's own accept-button color, both reserved for
     challenge-specific UI per the color plan settled at v270. Negative
     top margin pulls it up against the first .modal-body's own 20px
     bottom margin rather than stacking both gaps. */
  .modal-body-warning {
    color: var(--red-dark);
    font-weight: 600;
    margin-top: -8px;
  }
  .modal-actions {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .modal-btn {
    border: none;
    padding: 14px 16px;
    font-family: 'Baloo 2', sans-serif;
    font-weight: 600;
    font-size: 16px;
    color: #fff;
    cursor: pointer;
    touch-action: manipulation;
    text-align: left;
    border-radius: calc(var(--home-radius) * 0.6);
  }
  .modal-btn:active { transform: scale(0.98); }
  /* Red — reserved for challenge-mode controls specifically (see
     design-doc.md's queued item on challenge links eventually getting
     their own save slot), not used anywhere else on the home page. */
  .modal-btn-accept-challenge { background: var(--red); }
  .modal-btn-cancel { background: var(--ink); }
  /* Same greens as the practice tiles above, for the same reason —
     Resume/Start New here are the exact same two actions, just reached
     from daily's game-over screen instead of the home page. */
  .modal-btn-resume { background: var(--green); }
  .modal-btn-new { background: var(--green-bright); }

  /* ===================================================================
     Help page (v312) — was a dismissible modal through v311 (blurred
     backdrop, centered box, ✕-close). Now a real page, same overlay/
     page pair pattern as .achievements-page-overlay/.achievements-page
     (see that block's own comment) — reachable from home or mid-game
     (see openHelpPage()'s own comment, modals.js). Content-level rules
     below (.help-content h2/h3/h4/p/ul/li) are otherwise unchanged from
     the old .help-modal versions, just renamed to match the new wrapper.
  =================================================================== */
  .help-page-overlay {
    display: none;
    width: 100%;
  }
  .help-page {
    width: 100%;
    max-width: 420px;
    min-height: 100vh;
    min-height: 100dvh;
    margin: 0 auto;
    background: var(--bg);
    /* v313 — doubled, 6px → 12px, same reasoning as .achievements-page's
       own v313 change — 12px here + body's 10px = 22px total. */
    padding: 28px 12px 40px;
    padding-bottom: calc(120px + env(safe-area-inset-bottom));
  }
  .help-content {
    display: flex;
    flex-direction: column;
  }
  /* No <h2> here — the "How to Play" title now lives in .achievements-
     header's shared .home-title (see index.html), not a first content
     element, same as the other two pages' own titles. */
  .help-content h3 {
    font-family: 'Baloo 2', sans-serif;
    color: var(--ink);
    font-size: 16px;
    margin: 18px 0 6px;
  }
  .help-content h3:first-of-type { margin-top: 0; }
  /* v215 — new heading tier for individual items nested under the new
     "Color Areas"/"Bonus Badges" h3 subheads (Purple Area, Multiplier
     Hearts, Free Space Bonuses, Power-ups) — smaller than h3, same
     family/color, so it reads as one level down rather than a sibling. */
  .help-content h4 {
    font-family: 'Baloo 2', sans-serif;
    color: var(--ink);
    font-size: 14.5px;
    margin: 14px 0 4px;
  }
  .help-content h4:first-of-type { margin-top: 6px; }
  .help-content p {
    font-family: 'Inter', sans-serif;
    font-size: 14.5px;
    line-height: 1.5;
    color: rgba(35,38,30,0.8);
    margin: 0 0 8px;
  }
  /* v339 — the collapsed "tracks not in this game" section, mid-game
     Help only (see applyHelpTrackVisibility(), modals.js). Summary
     styled to match .help-content h4 (same tier as the "Purple Area"
     etc. headings it's visually replacing at this position) rather
     than introducing a new heading weight; native <details> marker
     left as-is rather than a custom icon, first use of the element
     anywhere in this app. */
  .help-other-tracks {
    margin-top: 14px;
    padding-top: 6px;
    border-top: 1px solid rgba(35,38,30,0.12);
  }
  .help-other-tracks summary {
    font-family: 'Baloo 2', sans-serif;
    color: var(--ink);
    font-size: 14.5px;
    cursor: pointer;
  }
  .help-other-tracks[open] summary {
    margin-bottom: 4px;
  }
  .help-other-tracks .help-track-section:first-of-type h4 {
    margin-top: 10px;
  }
  .help-content ul {
    margin: 0 0 8px;
    padding-left: 18px;
  }
  .help-content li {
    font-family: 'Inter', sans-serif;
    font-size: 14.5px;
    line-height: 1.5;
    color: rgba(35,38,30,0.8);
    margin-bottom: 4px;
  }
  .help-legend-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 6px 0;
    font-family: 'Inter', sans-serif;
    font-size: 14px;
    color: rgba(35,38,30,0.8);
  }
  .help-legend-icon {
    flex: none;
    width: 22px;
    height: 22px;
    position: relative;
  }
  .help-legend-icon .bonus-badge { border-radius: 5px; }
  /* v216 — Wild and Unlock's outer boxes are already identical (both
     22x22, from .help-legend-icon above) — the visual mismatch reported
     was the CONTENT within: Wild's tricolor background fills its box
     edge-to-edge, while Unlock's lock icon sat at 13px inside the same
     22px box (~59% fill), reading noticeably smaller side by side.
     Bumped to better match Wild's visual weight. */
  .help-legend-icon .bonus-badge svg { width: 17px; height: 17px; }
  .help-legend-trio {
    flex: none;
    display: flex;
    gap: 3px;
  }
  .help-legend-trio .bonus-badge {
    width: 15px;
    height: 15px;
    border-radius: 4px;
    font-size: 8px;
  }
  .help-legend-mult {
    flex: none;
    width: 22px;
    height: 22px;
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLDIxLjM1bC0xLjQ1LTEuMzJDNS40LDE1LjM2LDIsMTIuMjgsMiw4LjUgMiw1LjQyLDQuNDIsMyw3LjUsM2MxLjc0LDAsMy40MSwwLjgxLDQuNSwyLjA5QzEzLjA5LDMuODEsMTQuNzYsMywxNi41LDMgMTkuNTgsMywyMiw1LjQyLDIyLDguNWMwLDMuNzgtMy40LDYuODYtOC41NSwxMS41NEwxMiwyMS4zNXoiIGZpbGw9IiNFNTQ4NEQiLz48ZyBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxsaW5lIHgxPSI5LjQiIHkxPSI4LjQiIHgyPSIxNC42IiB5Mj0iMTMuNiIvPjxsaW5lIHgxPSIxNC42IiB5MT0iOC40IiB4Mj0iOS40IiB5Mj0iMTMuNiIvPjwvZz48L3N2Zz4=");
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
  }

  /* v215 — Green/Red Area inline illustrations, added alongside the new
     Help modal copy pass. Reuse the REAL .corner-score/.pair-score
     classes (same star-badge shapes seen on the actual board), just
     given a small static, non-absolute wrapper sized to match — same
     "icons in the modal are guaranteed to match the real board, since
     they're the same CSS" principle already used for the bonus/mult
     badges above, not custom art. .pair-score needs an explicit
     font-size here since the real board sets that via JS
     (pairScoreFontSize()) per digit count, which doesn't run for a
     static illustration — matches what a 2-character score like "-3"
     would actually render at (PAIR_SCORE_FONT_SIZE[2] = 13).
     v216 — the Red minus-sign connector icon that originally sat next
     to this badge was removed (star badge alone was enough), taking
     .help-legend-connector's CSS out with it — nothing else used that
     class. */
  .help-legend-star {
    flex: none;
    width: 23px;
    height: 23px;
    transform: rotate(5deg);
  }
  .help-legend-star-red {
    width: 30px;
    height: 30px;
  }
  .help-legend-star-red .pair-score {
    font-size: 13px;
  }

  /* ===================================================================
     Welcome modal — v224. Same overlay/card treatment as the game-over
     and help modals (blurred/whitened backdrop, centered box on
     var(--bg), 18px radius, same shadow) — own class pair, not reused
     ones, same reasoning as help-overlay/help-modal above: this one has
     its own interaction model (swipeable multi-step cards, no tap-
     outside or × dismiss, only the explicit primary button) that
     shouldn't share a toggle-behavior code path with modals that behave
     differently. Shows on every load until dismissed (see
     welcomeDismissedKey() in modals.js) — deliberately not a one-time
     "first visit only" flag, so it stays available as a light refresher
     until the player actively clears it themselves.
  =================================================================== */
  .welcome-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 10;
    background: rgba(128,128,128,0.75);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    align-items: center;
    justify-content: center;
    padding: 24px;
  }
  .welcome-modal {
    position: relative;
    width: 100%;
    max-width: 360px;
    background: var(--bg);
    border-radius: 18px;
    padding: 24px 0 20px;
    box-shadow: 0 12px 40px rgba(0,0,0,0.18);
    overflow: hidden;
  }
  /* Horizontal scroll-snap carousel — swiping is native browser scroll
     behavior, not a custom touch-gesture handler, so it's free and
     reliable on iOS Safari/WKWebView. scrollbar hidden since the dots
     below are the visible position indicator. */
  .welcome-cards {
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .welcome-cards::-webkit-scrollbar { display: none; }
  .welcome-card {
    flex: 0 0 100%;
    scroll-snap-align: start;
    scroll-snap-stop: always;
    box-sizing: border-box;
    padding: 0 28px;
    text-align: center;
    /* v228 — vertically centers each card's content within the shared
       fixed height (driven by the tallest card, the flex row's default
       align-items: stretch — see .welcome-cards). Without this, content
       top-aligns and the plain text-only cards (Welcome, One Game a
       Day) dump all their slack as dead space below the paragraph
       instead of distributing it evenly, reading as visually unbalanced
       next to the illustrated cards. */
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .welcome-card-emoji {
    font-size: 40px;
    line-height: 1;
    margin-bottom: 12px;
  }
  .welcome-card h2 {
    font-family: 'Baloo 2', sans-serif;
    color: var(--gold-dark);
    font-size: 21px;
    margin: 0 0 10px;
  }
  .welcome-card p {
    font-family: 'Inter', sans-serif;
    font-size: 14.5px;
    line-height: 1.5;
    color: rgba(35,38,30,0.8);
    margin: 0;
    /* Fixed min-height so all four cards claim the same vertical space
       regardless of copy length — otherwise the modal's height would
       jump as the carousel scrolls between a short card and a longer
       one. */
    min-height: 84px;
  }
  .welcome-dots {
    display: flex;
    justify-content: center;
    gap: 7px;
    margin: 16px 0 18px;
  }
  .welcome-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: rgba(35,38,30,0.2);
    transition: background 0.15s ease, transform 0.15s ease;
  }
  .welcome-dot.active {
    background: var(--gold-dark);
    transform: scale(1.3);
  }
  .welcome-actions {
    display: flex;
    gap: 10px;
    padding: 0 20px;
  }
  /* Back stays visible but invisible-and-inert on the first card
     (rather than display:none) so Next/"Start Playing" doesn't shift
     width or position as the player moves through the cards. */
  .welcome-back-btn {
    flex: none;
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    font-size: 15px;
    background: transparent;
    border: 1px solid rgba(35,38,30,0.3);
    color: var(--ink);
    padding: 12px 16px;
    border-radius: 10px;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease, border-color 0.15s ease;
  }
  .welcome-back-btn:hover { background: rgba(35,38,30,0.06); border-color: rgba(35,38,30,0.5); }
  .welcome-back-btn.welcome-btn-hidden {
    visibility: hidden;
    pointer-events: none;
  }
  .welcome-next-btn {
    flex: 1;
    font-family: 'Inter', sans-serif;
    font-weight: 600;
    font-size: 16px;
    background: var(--green);
    color: #fff;
    border: none;
    border-radius: 10px;
    padding: 13px;
    cursor: pointer;
    touch-action: manipulation;
    transition: background 0.15s ease;
  }
  .welcome-next-btn:hover { background: var(--green-bright); }
  .welcome-next-btn:active { background: var(--green-dark); }

  /* Per-card illustrations — v226. All reuse the real board's own CSS
     classes (.die, .slot, .red-connector, .pair-score-wrap, .bonus-badge)
     rather than custom art, same "guaranteed to match the real board"
     principle already used for the Help modal's icons (§ styles.css
     around .help-legend-mult/.help-legend-star-red). Static/illustrative
     only — fixed example values, no live game-state wiring, no click
     handlers. */

  /* Roll & Place card — three .die elements. .die is self-contained
     (fixed 58px width, aspect-ratio 1), so no extra sizing needed here,
     just layout + a cursor override since .die defaults to
     cursor:pointer for its real, clickable use on the board. */
  .welcome-die-row {
    display: flex;
    justify-content: center;
    gap: 14px;
    margin: 10px 0 2px;
  }
  .welcome-die-row .die.welcome-die {
    cursor: default;
  }

  /* Three Color Areas card — a static Red pair: two filled .slot.red
     squares (10 and 4, real .slot-die-inner/.slot-numeral markup, same
     as an in-progress board) plus the real .red-connector/.pair-score-
     wrap badges showing their sum (6), positioned with the same inline
     left/top technique renderRedConnectors()/renderRedPairBadges() use
     on the live board (there computed from getBoundingClientRect(); here
     hand-positioned since this card's layout is fixed, not live). See
     index.html for the exact left/top values — derived directly from
     this wrapper's own 52px slot width + 14px gap, not arbitrary.
     v230 — slots switched from empty to filled (10/4, summing to the
     pair-score's 6) on request, reading more like an actual in-progress
     board than two placeholder tints. */
  .welcome-red-pair {
    position: relative;
    display: flex;
    gap: 14px;
    width: 118px;
    /* v228 — top margin widened 14px → 24px on request: sat too close
       to the paragraph text above at the original spacing. */
    margin: 24px auto 4px;
  }
  .welcome-red-pair .slot {
    flex: none;
    width: 52px;
  }

  /* Bonuses & Multipliers card — a row of real bonus-badge/heart icons.
     .bonus-badge sizes itself to 100% of its parent (see .bonus-badge in
     styles.css), so each needs a sized wrapper — same role
     .help-legend-icon/.help-legend-trio play for the Help modal's
     badges, just a bit larger here since this row stands alone rather
     than sitting next to explanatory text on the same line. */
  .welcome-badge-row {
    display: flex;
    justify-content: center;
    gap: 14px;
    margin: 10px 0 2px;
  }
  .welcome-badge {
    flex: none;
    width: 32px;
    height: 32px;
    border-radius: 7px;
    overflow: hidden;
  }
  .welcome-badge .bonus-badge { border-radius: 0; }
  .welcome-badge .bonus-badge-unlock svg {
    width: 15px;
    height: 15px;
  }
  /* Multiplier heart — same icon/data-uri as .help-legend-mult, just
     duplicated at this row's own 32px size rather than reusing that
     22px-fixed class directly (same reasoning .help-legend-star-red
     duplicated .help-legend-star's approach instead of overriding it). */
  .welcome-badge-mult {
    background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLDIxLjM1bC0xLjQ1LTEuMzJDNS40LDE1LjM2LDIsMTIuMjgsMiw4LjUgMiw1LjQyLDQuNDIsMyw3LjUsM2MxLjc0LDAsMy40MSwwLjgxLDQuNSwyLjA5QzEzLjA5LDMuODEsMTQuNzYsMywxNi41LDMgMTkuNTgsMywyMiw1LjQyLDIyLDguNWMwLDMuNzgtMy40LDYuODYtOC41NSwxMS41NEwxMiwyMS4zNXoiIGZpbGw9IiNFNTQ4NEQiLz48ZyBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxsaW5lIHgxPSI5LjQiIHkxPSI4LjQiIHgyPSIxNC42IiB5Mj0iMTMuNiIvPjxsaW5lIHgxPSIxNC42IiB5MT0iOC40IiB4Mj0iOS40IiB5Mj0iMTMuNiIvPjwvZz48L3N2Zz4=");
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
  }

  /* ===== Achievement unlock toast (v287, achievements.js) =====
     Dark/inverted card, top-of-screen, non-blocking, sequential queue
     — settled in achievement-toast-playground.html /
     achievement-icon-playground.html (both safe to delete now that
     this is ported). Icon tile is a neutral rgba(255,255,255,0.2) fill
     behind an emoji glyph, not tone/color-coded — see the icon
     playground's own notes on why. */
  #achievementToastLayer {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    display: flex;
    justify-content: center;
    padding-top: max(14px, env(safe-area-inset-top));
    pointer-events: none;
    z-index: 500;
  }
  .achievement-toast {
    display: flex;
    align-items: center;
    gap: 10px;
    background: var(--ink);
    border-radius: 12px;
    padding: 9px 16px 9px 9px;
    box-shadow: 0 12px 40px rgba(0,0,0,0.35);
    max-width: min(86vw, 360px);
    transform: translateY(-16px);
    opacity: 0;
    transition: transform 0.32s cubic-bezier(0.34, 1.2, 0.4, 1), opacity 0.24s ease-out;
  }
  .achievement-toast.in {
    transform: translateY(0);
    opacity: 1;
  }
  .achievement-toast.out {
    transform: translateY(-10px);
    opacity: 0;
    transition: transform 0.22s ease-in, opacity 0.22s ease-in;
  }
  .achievement-toast .at-icon {
    width: 36px;
    height: 36px;
    border-radius: 9px;
    flex: none;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 18px;
    background: rgba(255,255,255,0.2);
  }
  .achievement-toast .at-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
    gap: 1px;
  }
  .achievement-toast .at-eyebrow {
    font-size: 10.5px;
    font-weight: 600;
    color: var(--gold);
  }
  .achievement-toast .at-name {
    font-family: 'Inter', sans-serif;
    font-size: 13.5px;
    font-weight: 700;
    color: var(--surface);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
