  /* ─── WORLD MAP ──────────────────────────────────────────────────────────
     Paginated atlas-style picker rendered into #stage-select-grid (which
     also holds the secret-tile row beneath). 5 chapters × 10 nodes = 50.
     #stage-select-grid stops being a card grid and becomes a vertical
     flex column hosting the world-map and (optionally) the secret row. */
  #stage-select-grid:has(.world-map) {
    display: flex; flex-direction: column;
    grid-template-columns: none;
    gap: 14px;
    padding: 14px 20px 22px;
  }
  /* Fallback for browsers without :has() — still works because the JS sets
     .world-map-root on the grid whenever the world map is rendered. */
  #stage-select-grid.world-map-root {
    display: flex; flex-direction: column;
    grid-template-columns: none;
    gap: 14px;
    padding: 14px 20px 22px;
  }
  .world-map {
    position: relative;
    flex: 1 1 auto; min-height: 0;
    display: flex; flex-direction: column;
    gap: 10px;
    color: var(--text);
  }
  .world-map-pager {
    display: flex; align-items: center; gap: 10px;
    padding: 4px 2px 6px;
  }
  .wm-chapter-title {
    flex: 1 1 auto;
    text-align: center;
    /* Subtler than before so the chapter title doesn't compete with the
       painted backdrop + tile thumbnails. Still readable, still gold. */
    font-weight: 600;
    font-size: 13px;
    letter-spacing: 1.4px;
    text-transform: uppercase;
    color: rgba(255, 232, 182, 0.78);
    text-shadow: 0 1px 4px rgba(0, 0, 0, 0.85);
  }
  /* Ghost-style pager arrows. Demoted intentionally — chapter dots + swipe
     handle navigation just as well, and the painted tiles deserve the
     visual budget. Hover restores full contrast for affordance. */
  .wm-arrow {
    background: rgba(20, 24, 44, 0.35);
    border: 1px solid rgba(255, 215, 110, 0.18);
    color: rgba(255, 232, 182, 0.65);
    width: 30px; height: 30px;
    border-radius: 50%;
    font-family: inherit;
    font-size: 18px; line-height: 1;
    cursor: pointer;
    display: inline-flex; align-items: center; justify-content: center;
    transition: transform 0.15s, border-color 0.2s, background 0.2s, color 0.2s;
  }
  .wm-arrow:hover:not(:disabled) {
    transform: scale(1.1);
    border-color: rgba(255, 215, 110, 0.5);
    background: rgba(40, 36, 70, 0.75);
    color: var(--text);
  }
  .wm-arrow:disabled {
    opacity: 0.25; cursor: default;
  }
  .world-map-stage {
    position: relative;
    /* rc189 — min-height dropped (was 460 for the legacy serpentine
       layout). The CSS Grid in .wm-nodes auto-fits whatever vertical
       space the flex parent gives it, so a min-height that exceeds
       the container just clips the bottom row. */
    flex: 1 1 auto; min-height: 0;
    border-radius: 14px;
    overflow: hidden;
    background: linear-gradient(160deg, #2a2440, #16142c);
    border: 1px solid rgba(255, 215, 110, 0.22);
    box-shadow: inset 0 0 80px rgba(0, 0, 0, 0.55);
    animation: wmStageIn 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  }
  @keyframes wmStageIn {
    0%   { opacity: 0; transform: scale(0.985); }
    100% { opacity: 1; transform: scale(1); }
  }
  .wm-backdrop, .wm-backdrop img {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    object-fit: cover;
    user-select: none;
    -webkit-user-drag: none;
  }
  .wm-backdrop::after {
    /* Two-layer overlay: a uniform dim so the backdrop's painted place-
       labels recede into atmosphere (otherwise they read as competing
       stage names, which they're not), plus a vignette to push the
       corners darker so foreground tiles + captions pop. */
    content: '';
    position: absolute; inset: 0;
    background:
      radial-gradient(ellipse at center,
        rgba(8, 6, 18, 0.32) 0%, rgba(8, 6, 18, 0.62) 100%);
    pointer-events: none;
  }
  .wm-path {
    /* 16px top/bottom inset gives the painted nodes + connecting line
       breathing room from the stage card edges (rc108 polish). The SVG
       viewBox is 0..100 with preserveAspectRatio="none" so reducing the
       layer height just compresses the path into the same percentage
       range — nodes shift in lockstep because .wm-nodes uses the same
       inset. */
    position: absolute; inset: 16px 0;
    width: 100%; height: calc(100% - 32px);
    pointer-events: none;
  }
  /* Connecting path between nodes — the visual "journey" thread. SVG
     viewBox is 0..100 in both axes; preserveAspectRatio="none" stretches
     it across the painted backdrop so dashes appear elongated/squashed
     depending on the stage's aspect. The values below balance "thick
     enough to read on busy chapter art" against "thin enough to feel
     like a trail of footprints, not a highway". */
  .wm-path-line {
    fill: none;
    stroke: #ffe9a8;
    stroke-width: 1.8;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 2.4 2.6;
    /* Two-layer glow: tight cream halo gives definition against the
       darker backdrop wash, wider amber halo gives the path its
       lantern-trail warmth. */
    filter:
      drop-shadow(0 0 2px rgba(255, 240, 180, 0.95))
      drop-shadow(0 0 8px rgba(255, 170, 60, 0.55));
    animation: wmPathDance 14s linear infinite;
    opacity: 0.95;
  }
  @keyframes wmPathDance {
    0%   { stroke-dashoffset: 0; }
    100% { stroke-dashoffset: -64; }
  }
  .wm-nodes {
    /* rc194 — CSS Grid with explicit % column widths so tile centres
       land at the user's calibrated positions (24/39/60/75) on every
       viewport. Explicit width/height + overflow: hidden so the grid
       rows resolve as 50% of the stage height (not max-content, which
       was making rows 400 px each = 800 total and pushing the top row
       above the visible map area). */
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    display: grid;
    grid-template-columns: 16.5% 15% 15% 6% 15% 15% 17.5%;
    grid-template-rows: auto auto;
    align-content: start;
    justify-items: center;
    align-items: center;
    row-gap: 15%;
    padding-top: 1%;
    pointer-events: none;
  }
  /* Map node order (0..7) to grid cells. Nodes 0-3 fill the LEFT
     page (top-row then bottom-row); 4-7 fill the RIGHT page. */
  .wm-nodes .wm-node:nth-child(1) { grid-column: 2; grid-row: 1; }
  .wm-nodes .wm-node:nth-child(2) { grid-column: 3; grid-row: 1; }
  .wm-nodes .wm-node:nth-child(3) { grid-column: 2; grid-row: 2; }
  .wm-nodes .wm-node:nth-child(4) { grid-column: 3; grid-row: 2; }
  .wm-nodes .wm-node:nth-child(5) { grid-column: 5; grid-row: 1; }
  .wm-nodes .wm-node:nth-child(6) { grid-column: 6; grid-row: 1; }
  .wm-nodes .wm-node:nth-child(7) { grid-column: 5; grid-row: 2; }
  .wm-nodes .wm-node:nth-child(8) { grid-column: 6; grid-row: 2; }
  .wm-nodes .wm-marker {
    pointer-events: none;
  }
  /* Circular medallion nodes. Bumped 48 → 72px so the painted card art
     reads at a glance rather than as a tiny texture; shape stays a circle
     to match the world-map's medallion vibe (rolled back from a
     rounded-square experiment that lost the journey/coin feel). */
  .wm-node {
    /* rc193 — bumped further (12 → 18 vmin) to better match the
       reference image's ~20% of vmin tile size. On 1280×720 → 130 px.
       On 667×375 (iPhone SE landscape) → 67 px, still fits the
       ~136 px map area with two rows. */
    position: relative;
    width: clamp(36px, 18vmin, 180px);
    height: clamp(36px, 18vmin, 180px);
    pointer-events: auto;
    border-radius: 50%;
    border: 2px solid rgba(255, 220, 130, 0.55);
    /* Painted card art rides as background-image. The dark fallback
       shows while the asset decodes; background-size:cover crops the
       art to fill the tile so we never see letterboxing. */
    background-color: #1a1632;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    color: #ffffff;
    font-family: inherit;
    cursor: pointer;
    display: block;
    pointer-events: auto;
    overflow: visible;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6),
                inset 0 0 0 1px rgba(255, 255, 255, 0.04);
    transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1),
                border-color 0.2s, box-shadow 0.25s;
  }
  /* Soft vignette: an upper-left darkening for the corner number badge
     legibility plus a gentle bottom fade so the painted scene reads
     bright in the middle. Way lighter than the prior full-dark wash. */
  .wm-node::before {
    content: '';
    position: absolute; inset: 0;
    border-radius: inherit;
    background:
      radial-gradient(circle at 24% 22%,
        rgba(0, 0, 0, 0.55) 0%,
        rgba(0, 0, 0, 0) 38%),
      radial-gradient(circle at 50% 100%,
        rgba(0, 0, 0, 0.35) 0%,
        rgba(0, 0, 0, 0) 55%);
    pointer-events: none;
  }
  /* Number badge — small corner pill, top-left. Was the centered label
     dominating the old 48px circle; now it's a quiet locator and the
     painted art owns the rest of the tile. */
  .wm-node-label {
    position: absolute;
    top: 4px; left: 4px;
    min-width: 18px; height: 18px;
    padding: 0 4px;
    border-radius: 9px;
    background: rgba(10, 8, 22, 0.78);
    border: 1px solid rgba(255, 215, 110, 0.45);
    color: rgba(255, 232, 182, 0.95);
    font-size: 11px; font-weight: 700; line-height: 16px;
    text-align: center;
    letter-spacing: 0.2px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
    z-index: 2;
  }
  .wm-node-lock {
    position: absolute; bottom: -6px; right: -6px;
    width: 22px; height: 22px;
    background: #16142c;
    border: 1px solid rgba(255, 200, 100, 0.6);
    border-radius: 50%;
    font-size: 12px; line-height: 20px; text-align: center;
    box-shadow: 0 3px 8px rgba(0, 0, 0, 0.6);
    z-index: 3;
  }
  /* ── Unlock-progress ring (NEXT locked node only — world-map.js adds it) ──
     Conic arc driven by --deg; masked to a ring so the tile art stays
     visible. Static on purpose: the current node already owns the map's
     pulse animation. */
  .wm-node-ring {
    position: absolute;
    inset: -8px;
    border-radius: 50%;
    background: conic-gradient(#ffd966 0deg, #ffaa3c var(--deg, 0deg),
        rgba(255, 255, 255, 0.10) var(--deg, 0deg) 360deg);
    -webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
            mask: radial-gradient(circle, transparent 62%, #000 63%);
    filter: drop-shadow(0 0 4px rgba(255, 200, 80, 0.7));
    pointer-events: none;
    z-index: 2;
  }
  /* Wave-progress pill — sits where .wm-node-name does on unlocked nodes
     (locked nodes have no caption, so no collision). Deliberately SURVIVES
     the max-height:500px caption cut below — it's one pill carrying the
     unlock rule, not eight colliding labels; it just shrinks. */
  .wm-node-wave {
    position: absolute;
    top: calc(100% + 8px);
    left: 50%;
    transform: translateX(-50%);
    white-space: nowrap;
    font-size: clamp(10px, 1.5vmin, 13px);
    font-weight: 800;
    letter-spacing: 0.5px;
    color: #ffd966;
    background: rgba(10, 8, 22, 0.85);
    border: 1px solid rgba(255, 215, 110, 0.5);
    border-radius: 8px;
    padding: 2px 9px;
    pointer-events: none;
    z-index: 2;
  }
  @media (max-height: 500px) {
    .wm-node-wave { font-size: 9px; padding: 1px 7px; }
  }
  /* Next-locked node: NO element filter (it would rasterize the gold
     ring/pill gray along with the art — CSS filters hit all descendants).
     Instead the luminosity blend dims and shifts the background art toward
     the purple-dark base color (a tinted dim, not neutral gray — intentional;
     children like the ring/pill are unaffected), and the ::before veil below
     handles the darkening. */
  .wm-node.locked.wm-node--next {
    filter: none;
    background-color: #2a2742;
    background-blend-mode: luminosity;
  }
  /* Replaces the base ::before pair: keeps the corner-badge gradient, swaps
     the bottom-fade for a flat veil — this node should read uniformly dim,
     not "bright middle" like its cleared siblings. */
  .wm-node.locked.wm-node--next::before {
    background:
      radial-gradient(circle at 24% 22%,
        rgba(0, 0, 0, 0.55) 0%,
        rgba(0, 0, 0, 0) 38%),
      rgba(8, 6, 18, 0.45);
  }
  /* Stage-name caption — floats just below the tile so each destination
     is identifiable at a glance, no popover needed. Hidden on phone-touch
     (the touch breakpoint at the bottom of this block) because phone
     stages get too crowded otherwise. */
  .wm-node-name {
    /* rc194 — caption constrained to tile width with ellipsis. */
    position: absolute;
    top: calc(100% + 6px);
    left: 50%;
    transform: translateX(-50%);
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: clamp(10px, 1.6vmin, 15px);
    font-weight: 700;
    letter-spacing: 0.3px;
    color: #ffe8b6;
    padding: 2px 8px;
    background: rgba(10, 8, 22, 0.62);
    border-radius: 7px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
    pointer-events: none;
    z-index: 2;
    transition: color 0.2s, text-shadow 0.2s, background 0.2s;
  }
  /* Hide tile name captions on short landscape viewports (iPhone SE
     landscape, etc.) so they don't overlap adjacent rows. The number
     badge on each tile still identifies the stage. */
  @media (max-height: 500px) {
    .wm-node-name { display: none; }
  }
  .wm-node.cleared {
    border-color: rgba(126, 226, 255, 0.7);
  }
  .wm-node.current {
    border-color: var(--gold);
    box-shadow: 0 10px 26px rgba(255, 200, 80, 0.5),
                0 0 0 4px rgba(255, 215, 110, 0.22),
                inset 0 0 0 1px rgba(255, 255, 255, 0.08);
    animation: wmCurrentPulse 2.2s ease-in-out infinite;
  }
  .wm-node.current .wm-node-name {
    color: #fff5d0;
    text-shadow:
      0 1px 3px rgba(0, 0, 0, 0.95),
      0 0 10px rgba(255, 215, 110, 0.6);
  }
  @keyframes wmCurrentPulse {
    /* rc194 — translate(-50%, -50%) was for the rc184 absolute
       positioning. rc191+ uses CSS Grid so the tile is centred by
       place-items; no translate needed. Just pulse scale. */
    0%, 100% { transform: scale(1); }
    50%      { transform: scale(1.06); }
  }
  .wm-node.locked {
    border-color: rgba(170, 170, 200, 0.35);
    cursor: default; /* locked nodes are inert — no popover, no commit */
    filter: grayscale(0.7) brightness(0.55);
  }
  /* Preview popover — appears on node click. Shows thumbnail + stage name
     + difficulty + PLAY button. Mounted inside .world-map so it sits over
     the painted backdrop and tears down on chapter flip. */
  .wm-preview {
    position: absolute;
    top: calc(50% - 38px); left: 50%;
    transform: translate(-50%, -50%);
    width: min(420px, 92%);
    max-height: calc(100% - 100px);
    overflow-y: auto;
    background: linear-gradient(160deg, rgba(36, 30, 64, 0.98), rgba(22, 18, 40, 0.98));
    border: 1px solid rgba(255, 215, 110, 0.55);
    border-radius: 16px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.75),
                0 0 60px rgba(184, 155, 255, 0.18);
    padding: 16px 18px 18px;
    z-index: 10;
    display: flex; flex-direction: column;
    gap: 12px;
    animation: wmPreviewIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  }
  @keyframes wmPreviewIn {
    0%   { opacity: 0; transform: translate(-50%, -46%) scale(0.94); }
    100% { opacity: 1; transform: translate(-50%, -50%); }
  }
  .wm-preview-header {
    display: grid;
    grid-template-columns: 96px 1fr;
    column-gap: 14px;
    align-items: center;
  }
  .wm-preview-thumb {
    width: 96px; height: 96px;
    border-radius: 12px;
    background-size: cover; background-position: center; background-repeat: no-repeat;
    border: 1px solid rgba(255, 220, 130, 0.4);
    box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.5);
  }
  .wm-preview-body {
    display: flex; flex-direction: column;
    justify-content: center;
    gap: 4px;
    min-width: 0;
  }
  .wm-preview-eyebrow {
    font-size: 10px;
    letter-spacing: 1.4px;
    text-transform: uppercase;
    color: rgba(255, 220, 130, 0.75);
    font-weight: 700;
  }
  .wm-preview-name {
    font-size: 18px;
    font-weight: 800;
    color: var(--text);
    line-height: 1.2;
  }
  .wm-preview-diff {
    font-size: 12px;
    color: rgba(255, 220, 130, 0.85);
    letter-spacing: 0.5px;
  }
  .wm-preview-desc {
    font-size: 12.5px;
    line-height: 1.45;
    color: rgba(255, 255, 255, 0.78);
    font-style: italic;
  }
  .wm-preview-bosses,
  .wm-preview-best {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
  }
  .wm-preview-section-label {
    font-size: 10px;
    letter-spacing: 1.4px;
    text-transform: uppercase;
    color: rgba(255, 220, 130, 0.65);
    font-weight: 700;
    margin-right: 4px;
  }
  .wm-preview-chip {
    display: inline-block;
    padding: 3px 10px;
    background: rgba(80, 60, 130, 0.55);
    border: 1px solid rgba(184, 155, 255, 0.4);
    border-radius: var(--radius-pill);
    color: #f0e8ff;
    font-size: 11px;
    font-weight: 600;
    white-space: nowrap;
  }
  .wm-preview-best-value {
    color: var(--gold);
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.3px;
  }
  .wm-preview-hint {
    font-size: 11px;
    color: rgba(255, 220, 130, 0.7);
    letter-spacing: 0.3px;
    border-top: 1px dashed rgba(255, 220, 130, 0.15);
    padding-top: 8px;
    line-height: 1.4;
  }
  .wm-preview-play {
    /* rc170 — sized to the text. The popup's flex-column layout would
       otherwise stretch this block-level button to the full popup
       width; align-self + width:auto keeps it just wide enough for the
       PLAY label plus the 40-px gold endcaps. */
    align-self: center;
    width: auto;
    min-width: 0;
    /* rc169 — Figma play-button.png (246×67) as a nine-patch background.
       The 40-px-wide gold diamond endcaps on each side stay fixed; the
       middle blue stretches with the button width. */
    background: transparent;
    color: #fff2c2;
    border-style: solid;
    border-color: transparent;
    border-top-width: 12px;
    border-bottom-width: 12px;
    border-left-width: 40px;
    border-right-width: 40px;
    border-image-source: url('../assets/stage-select/figma/play-button.png');
    border-image-slice: 12 40 12 40 fill;
    border-image-width: 12px 40px 12px 40px;
    border-image-outset: 0;
    border-image-repeat: stretch;
    border-radius: 0;
    padding: 0 12px;
    min-height: 48px;
    font-weight: 800;
    font-size: 16px;
    letter-spacing: 1.6px;
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.45);
    cursor: pointer;
    font-family: inherit;
    transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
    box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
  }
  .wm-preview-play:hover:not(:disabled) {
    transform: translateY(-1px);
    box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5);
  }
  .wm-preview-play:active:not(:disabled) { transform: translateY(0) scale(0.99); }
  .wm-preview-play:disabled {
    opacity: 0.55;
    color: rgba(255, 255, 255, 0.55);
    cursor: not-allowed;
    box-shadow: none;
  }
  .wm-preview-close {
    position: absolute;
    top: 6px; right: 10px;
    background: none; border: none;
    color: rgba(255, 255, 255, 0.55);
    font-size: 24px; line-height: 1;
    cursor: pointer;
    padding: 4px 8px;
    font-family: inherit;
  }
  .wm-preview-close:hover { color: var(--text); }
  .wm-preview.locked .wm-preview-thumb { filter: grayscale(0.7) brightness(0.7); }
  .wm-node:hover:not(.locked) {
    transform: scale(1.08);
    animation: none;
    border-color: #ffe4a8;
    box-shadow: 0 14px 32px rgba(0, 0, 0, 0.7),
                0 0 0 4px rgba(255, 215, 110, 0.22),
                inset 0 0 0 1px rgba(255, 255, 255, 0.1);
    z-index: 2;
  }
  .wm-node:hover:not(.locked) .wm-node-name {
    color: #fff5d0;
    text-shadow:
      0 1px 3px rgba(0, 0, 0, 0.95),
      0 0 10px rgba(255, 215, 110, 0.55);
  }
  .wm-node.shake {
    animation: wmShake 0.4s ease-in-out;
  }
  @keyframes wmShake {
    0%, 100% { transform: translate(0, 0); }
    20%      { transform: translate(-6px, 0); }
    40%      { transform: translate(6px, 0); }
    60%      { transform: translate(-4px, 0); }
    80%      { transform: translate(4px, 0); }
  }
  .wm-marker {
    position: absolute;
    left: var(--x); top: var(--y);
    /* Anchored to the upper-right corner of the current tile (like a
       quest-marker pin) rather than dead-center, so it doesn't bury the
       painted scene. Offset stays consistent across the static state,
       the chapter-flip teleport, and the bob keyframes via the
       --wm-marker-ox / --wm-marker-oy custom properties — phone touch
       overrides these for the smaller 56px tiles. */
    --wm-marker-ox: 28px;
    --wm-marker-oy: -28px;
    transform: translate(calc(-50% + var(--wm-marker-ox)), calc(-50% + var(--wm-marker-oy)));
    width: 24px; height: 24px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #fff5c0, #ffaa44 60%, transparent 75%);
    box-shadow: 0 0 22px rgba(255, 200, 80, 0.95),
                0 0 0 2px rgba(255, 235, 170, 0.45);
    pointer-events: none;
    z-index: 4;
    /* Subtle idle bob — paused while .animating so the path-traversal
       transition reads cleanly. */
    animation: wmMarkerBob 2.4s ease-in-out infinite;
  }
  .wm-marker.animating {
    /* Linear transition between node positions. Position is driven by
       --x / --y custom properties, which we transition explicitly so
       browsers that don't yet support transitioning custom-property
       changes (older Safari) at least re-layout after JS updates them. */
    transition: left 1.2s cubic-bezier(0.55, 0.05, 0.45, 0.95),
                top  1.2s cubic-bezier(0.55, 0.05, 0.45, 0.95);
    animation: none;
  }
  @keyframes wmMarkerBob {
    0%, 100% { transform: translate(calc(-50% + var(--wm-marker-ox)), calc(-50% + var(--wm-marker-oy))) translateY(0); }
    50%      { transform: translate(calc(-50% + var(--wm-marker-ox)), calc(-50% + var(--wm-marker-oy))) translateY(-3px); }
  }
  .wm-dots {
    display: flex; gap: 8px;
    justify-content: center;
    padding: 4px 0 0;
  }
  .wm-dot {
    width: 10px; height: 10px;
    border-radius: 50%;
    border: 1px solid rgba(255, 220, 130, 0.4);
    background: rgba(20, 22, 44, 0.6);
    cursor: pointer;
    padding: 0;
    transition: background 0.2s, transform 0.2s, border-color 0.2s;
  }
  .wm-dot:hover { transform: scale(1.18); border-color: rgba(255, 220, 130, 0.7); }
  .wm-dot.active {
    background: linear-gradient(135deg, #ffd36e, #ff9ec4);
    border-color: rgba(255, 235, 170, 0.85);
    box-shadow: 0 0 10px rgba(255, 200, 80, 0.6);
  }
  .world-map-secret-row {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 12px;
    padding-top: 6px;
    border-top: 1px dashed rgba(255, 220, 130, 0.18);
  }

  /* Touch-device tweaks — same body.touch:not(.tablet) selector the legacy
     card grid uses. Make the modal full-screen, shrink chrome a bit, and
     give the map area more vertical room.
     Bottom padding is tiny (8px) — the parent .play-hub-panel already
     reserves 76px for the floating Play Hub nav, so double-padding here
     was the source of the rc105 "big empty gap between map and hub nav".
     Just enough to keep the chapter dots row off the panel's reserved
     edge. */
  body.touch:not(.tablet) #stage-select-grid:has(.world-map),
  body.touch:not(.tablet) #stage-select-grid.world-map-root {
    padding: 8px 12px 8px;
    gap: 8px;
  }
  body.touch:not(.tablet) .wm-chapter-title { font-size: 12px; letter-spacing: 1.2px; }
  body.touch:not(.tablet) .wm-arrow { width: 28px; height: 28px; font-size: 16px; }

  /* ── MOBILE WORLD MAP: flat scrolling layout ──────────────────────
     Reverted from rc100's Star Wars perspective tilt back to a flat
     painted plane. The stage is a vertical scroll viewport over a
     1500px-tall canvas; tiles + painted backdrop scroll naturally
     up the screen. No 3D transforms, no mask, no starfield overlay
     — just the same painted map desktop uses, sized for portrait. */
  body.touch:not(.tablet).portrait .world-map-stage {
    overflow-y: auto;
    overflow-x: hidden;
    /* Isolate scroll repaints to the stage — without this, layout
       changes inside (continuous SVG path-dance animation, current-
       tile pulse) propagated out and forced layer recomposition
       during iOS momentum scroll, which is what users perceived as
       blinking. */
    contain: paint;
  }
  /* The crawl-plane wrapper holds the painted backdrop + path + tiles.
     Promoted to its own GPU compositing layer via translateZ(0) +
     will-change so iOS momentum scroll just translates the existing
     rasterized texture instead of re-rasterizing the painted SVG and
     all the tile children every scroll frame. backface-visibility
     hidden is the belt-and-suspenders for older Webkit / WebView
     compositors that won't promote without it. */
  body.touch:not(.tablet).portrait .wm-crawl-plane {
    position: absolute;
    inset: auto;
    top: 0; left: 0;
    width: 100%;
    /* Goal item 1 — tighten the portrait inter-stage spacing. The 8 nodes sit
       in 4 grid rows (1fr each) that fill this plane; at the original 800px the
       row pitch was ~200px (≈126px gap between 74px medallions). 408px brings
       the pitch to ~102px (≈28px gap) — a quarter of the old spacing (halved,
       then halved again per follow-up) — now that the per-node captions are
       hidden in portrait (item 2). */
    height: 408px;
    transform: translateZ(0);
    will-change: transform;
    backface-visibility: hidden;
    -webkit-backface-visibility: hidden;
  }
  /* Same compositing-layer hint for the SVG backdrop — it's the
     heaviest element on the page (1512×732 painted scene). Keeping
     it on its own layer means scrolling never repaints the SVG. */
  body.touch:not(.tablet) .wm-backdrop {
    transform: translateZ(0);
    backface-visibility: hidden;
    -webkit-backface-visibility: hidden;
  }
  /* Kill the continuous animations on mobile. iOS momentum scrolling
     suspends animation timer updates while scroll is in-flight; when
     scroll settles, animations snap back into the right keyframe —
     which manifests as a visible "blink" on the path's dashed stroke
     and the current tile's pulse. Static state on mobile, animated
     on desktop. */
  body.touch:not(.tablet) .wm-path-line {
    animation: none;
  }
  body.touch:not(.tablet) .wm-node.current {
    animation: none;
  }
  body.touch:not(.tablet) .wm-marker {
    animation: none;
  }
  /* Landscape phones — the stage-preview popup was sized INSIDE the book
     frame (~226px tall on a 390px viewport): its max-height resolved to
     ~126px, turning the card into a tiny scroll box with the PLAY button
     clipped below the fold — and a tap at PLAY's laid-out position fell
     through to the bottom tab bar (the button sat outside the popup's
     clipped bounds, so hit-testing found the nav). Found 2026-06-11 via
     elementFromPoint after an owner report; synthetic .click() in earlier
     QA masked it by firing handlers without hit-testing.
     Fix: escape the frame entirely — fixed + viewport-centered, sized to
     clear the HUD header and the bottom nav (popup bottom ~320 < nav top
     ~329 on iPhone 13 landscape), wider to use the landscape aspect, and
     PLAY pinned sticky so the tap target is ALWAYS on screen even when
     the body scrolls. Stacking: #stage-select (z:250) is the context;
     popup z:10 beats the nav's z:6 wherever they overlap. */
  body.touch:not(.tablet).landscape .wm-preview {
    position: fixed;
    top: calc(50% - 22px);
    left: 50%;
    width: min(560px, 78vw);
    max-height: calc(100vh - 96px);
  }
  body.touch:not(.tablet).landscape .wm-preview-play {
    position: sticky;
    bottom: 0;
    z-index: 2;
  }
  body.touch:not(.tablet).portrait .wm-backdrop,
  body.touch:not(.tablet).portrait .wm-path,
  body.touch:not(.tablet).portrait .wm-nodes {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
  body.touch:not(.tablet).portrait .wm-backdrop {
    object-fit: cover;
    object-position: center;
  }
  /* #2 — portrait: reflow the cramped 4-column (2×2-per-page) node layout into
     a roomy 2-column snake over 4 rows so each medallion gets more space on a
     narrow phone. The connecting path follows via WORLD_MAP_NODE_LAYOUT_PORTRAIT
     in world-map.js (kept in sync with these cells); the player marker tracks
     live node rects so it needs no change. */
  body.touch:not(.tablet).portrait .wm-nodes {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: repeat(4, 1fr);
    row-gap: 0;
    padding-top: 0;
  }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(1) { grid-column: 1; grid-row: 1; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(2) { grid-column: 2; grid-row: 1; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(3) { grid-column: 2; grid-row: 2; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(4) { grid-column: 1; grid-row: 2; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(5) { grid-column: 1; grid-row: 3; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(6) { grid-column: 2; grid-row: 3; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(7) { grid-column: 2; grid-row: 4; }
  body.touch:not(.tablet).portrait .wm-nodes .wm-node:nth-child(8) { grid-column: 1; grid-row: 4; }
  body.touch:not(.tablet) .wm-path-line {
    vector-effect: non-scaling-stroke;
    stroke-width: 1.8;
  }

  /* Mobile tiles match desktop's 72px now that each tile has ~140px
     of vertical lane in the scrolling canvas. Captions return to
     below-tile (the side-anchoring from rc98 was a stopgap for the
     squeezed non-scroll layout). */
  /* rc188 — mobile uses the desktop tile size + layout per user
     request. No mobile-specific override here. */
  body.touch:not(.tablet) .wm-node-label {
    top: 4px; left: 4px;
    min-width: 18px; height: 18px;
    padding: 0 4px;
    border-radius: 9px;
    font-size: 11px; line-height: 16px;
  }
  body.touch:not(.tablet) .wm-node-lock {
    width: 20px; height: 20px;
    bottom: -5px; right: -5px;
    font-size: 11px; line-height: 18px;
  }
  /* Caption beneath each tile. The .wm-node--cap-l / --cap-r classes
     are still set by JS but mobile ignores them now — we have the
     vertical room to use the same below-tile pattern desktop uses.
     No backdrop-filter on mobile — combined with the parent crawl
     plane's perspective transform it was a per-frame flicker source.
     Solid-ish background instead. */
  body.touch:not(.tablet) .wm-node-name,
  body.touch:not(.tablet) .wm-node--cap-r .wm-node-name,
  body.touch:not(.tablet) .wm-node--cap-l .wm-node-name {
    top: calc(100% + 8px);
    left: 50%;
    right: auto;
    transform: translateX(-50%);
    font-size: 11px;
    letter-spacing: 0.3px;
    padding: 3px 9px;
    border-radius: 7px;
    max-width: 130px;
    white-space: normal;
    text-align: center;
    line-height: 1.2;
    background: rgba(8, 6, 18, 0.86);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
  }
  body.touch:not(.tablet) .wm-marker {
    width: 26px; height: 26px;
    --wm-marker-ox: 28px;
    --wm-marker-oy: -28px;
    /* Bob animation is killed on mobile by the rule near the top of
       this block (animation: none) — momentum-scroll-snap was making
       it blink. Static marker on mobile, animated on desktop. */
  }
  body.touch:not(.tablet) .world-map-secret-row {
    grid-template-columns: 1fr;
  }
  /* Dots can sneak behind the floating nav at very small landscape heights.
     A bit of negative margin against the bottom padding keeps them above. */
  body.touch:not(.tablet) .wm-dots { margin-top: 2px; }
  /* min-height drops to 140px so short landscape phones don't push the
     bottom nodes behind the floating Play Hub nav. The overflow/scroll
     behaviour is set in the MOBILE WORLD MAP block above. */
  body.touch:not(.tablet) .world-map-stage { min-height: 140px; }
  /* Cap the world-map flex column to the grid's content box so the stage
     can't extend past the bottom padding we reserved for the nav. */
  body.touch:not(.tablet) .world-map {
    max-height: 100%;
    min-height: 0;
  }

  /* Touch-only warning — becomes visible via JS detection */
  #requirements.unsupported {
    border-color: rgba(255, 168, 102, 0.55);
    background: linear-gradient(145deg, rgba(80, 40, 30, 0.85), rgba(40, 22, 30, 0.85));
  }
  #touch-warning {
    margin-top: 16px;
    color: var(--amber);
    font-size: 13px; max-width: 360px; line-height: 1.5;
    font-weight: 500;
  }

  /* PLAY button — hexagonal cyan-glow tile matching the polygon-shooter
     aesthetic. The hex shape lives on #start-btn via clip-path; the cyan
     glow lives on the wrapper because filters honor clip-path's outside
     and would clip the glow if applied directly to the hex. */
  /* Play wrap — relative-positioned so the ::before halo can sit behind the
     button. The halo is a blurred copy of play-button.png itself, so it
     follows the hex silhouette by definition (some mobile browsers
     rasterize filter:drop-shadow against the element's rectangular bbox,
     which produced a rectangular phantom around the hex). One CSS var,
     --play-w, drives both the button width and the halo width so they
     always line up. */
  .landing-play-wrap {
    --play-w: clamp(154px, 21vw, 238px);
    position: relative;
    display: inline-flex;
    justify-content: center;
    width: 100%;
    margin-top: 4px;
    transition: filter 0.2s ease;
  }
  .landing-play-wrap::before {
    content: '';
    position: absolute;
    left: 50%; top: 50%;
    width: var(--play-w);
    aspect-ratio: 913 / 296;
    transform: translate(-50%, -50%);
    background: url('../assets/landing/play-button.webp?v=20260503a') center / 100% 100% no-repeat;
    filter: blur(14px) brightness(1.30) saturate(1.20);
    opacity: 0.62;
    pointer-events: none;
    transition: opacity 0.2s ease, filter 0.2s ease;
    /* Pulse glow halo — the cyan ring "breathes" around the hex on a
       3.2s loop. Distinct from the title shimmer (which is a diagonal
       sweep across the lettering); this is a radial intensity pulse on
       the halo itself, no spatial movement. */
    animation: halo-pulse 3.2s ease-in-out infinite;
  }
  @media (prefers-reduced-motion: reduce) {
    .landing-play-wrap::before { animation: none; }
  }
  .landing-play-wrap:hover::before {
    /* Hover overrides the pulse with a steady high-glow state so the
       button feels firmly "active" while pointed at. */
    animation: none;
    filter: blur(22px) brightness(1.55) saturate(1.32);
    opacity: 0.92;
  }
  body.portrait .landing-play-wrap { width: auto; }

  /* PLAY button image — 913×296 crystal pill with built-in bevel + edge glow.
     The image has alpha so we don't clip the silhouette ourselves; the
     wrapper carries the outer cyan halo via drop-shadow. Aspect-ratio keeps
     the silhouette intact across viewport widths. */
  #overlay #start-btn {
    /* position:relative + z-index lifts the button above the ::before
       image-halo on the wrap so the dark "PLAY" / "CHƠI" text isn't
       washed out by the blurred copy behind it. Without this, the
       absolute-positioned ::before stacks above the in-flow button by
       default and renders the text at 0.38 alpha. */
    position: relative;
    z-index: 1;
    border: none; cursor: pointer;
    /* Strip every browser-default rectangular indicator so the only thing
       the user sees is the hex bg-image. The drop-shadow on the wrapper
       follows the png alpha; any default focus ring / tap highlight /
       UA appearance would draw a rectangle on top and ruin the silhouette. */
    outline: none;
    -webkit-tap-highlight-color: transparent;
    -webkit-appearance: none;
            appearance: none;
    background-color: transparent;
    font-family: inherit; font-weight: 800;
    font-size: clamp(18px, 2vw, 24px);
    letter-spacing: 4px; text-transform: uppercase;
    padding: 0; line-height: 1;
    color: #0a2238;
    text-shadow:
      0 1px 0 rgba(255, 255, 255, 0.55),
      0 0 12px rgba(232, 252, 255, 0.55);
    background:
      url('../assets/landing/play-button.webp?v=20260503a') center / 100% 100% no-repeat,
      transparent;
    /* Width drives the wrap's --play-w halo too. 30% smaller than the
       original 220–340px clamp so the button reads as a CTA. */
    width: var(--play-w, clamp(154px, 21vw, 238px));
    aspect-ratio: 913 / 296;
    display: inline-flex; align-items: center; justify-content: center;
    transition: transform 0.16s cubic-bezier(0.2, 0.8, 0.2, 1),
                filter 0.18s;
  }
  #overlay #start-btn:focus,
  #overlay #start-btn:focus-visible,
  #overlay #start-btn:active,
  #overlay #start-btn:hover { outline: none; box-shadow: none; }
  #overlay #start-btn::-moz-focus-inner { border: 0; padding: 0; }
  /* The shared card-burst keyframe paints a rectangular gold box-shadow
     as its glow flare. PLAY's silhouette is hex (via play-button.png),
     so that rectangle shows up as a phantom outline behind it on click.
     Override with a button-specific keyframe — same scale + brightness
     curve, no box-shadow. The wrapper's drop-shadow:filter handles the
     halo and follows the png alpha. */
  @keyframes start-btn-burst {
    0%   { transform: scale(1); filter: brightness(1); }
    30%  { transform: scale(1.18); filter: brightness(1.45) saturate(1.3); }
    75%  { transform: scale(1.08); filter: brightness(1.2); }
    100% { transform: scale(0); opacity: 0; filter: brightness(1); }
  }
  #overlay #start-btn.card-picked {
    animation: start-btn-burst 380ms cubic-bezier(0.5, 0, 0.2, 1) forwards;
    box-shadow: none !important;
  }
  #overlay #start-btn:hover,
  #overlay #start-btn:focus-visible {
    transform: translateY(-2px) scale(1.03);
    filter: brightness(1.12) saturate(1.08);
  }
  #overlay #start-btn:active {
    transform: translateY(1px) scale(0.97);
    filter: brightness(0.92) saturate(0.95);
    transition: transform 0.06s ease, filter 0.06s ease;
  }

/* Goal item 2 — portrait phones drop the per-stage name caption. The numbered
   badge on each medallion already identifies the stage, and hiding the captions
   lets the tightened node spacing (item 1) breathe without label collisions. */
body.touch:not(.tablet).portrait .wm-node-name {
  display: none !important;
}

/* Portrait stage medallions +20% — the base size is clamp(36px, 18vmin, 180px);
   bump every stop by 1.2× so each stage thumbnail reads larger on a phone. The
   tightened row pitch (408px plane) still leaves a clear gap between them. */
body.touch:not(.tablet).portrait .wm-nodes .wm-node {
  width: clamp(43px, 21.6vmin, 216px);
  height: clamp(43px, 21.6vmin, 216px);
}


