    /* 2048-specific styles. Theme colors come from style.css variables so
       light/dark mode follow the gomoku page automatically. */
    .g2048-app {
      max-width: 520px; margin: 0 auto; padding: 8px 16px 24px;
      display: flex; flex-direction: column; gap: 14px;
      /* Allow vertical scroll so the inline leaderboard below the board stays
         reachable on phones; only the board itself opts out of scroll (below)
         so swipes drive moves instead of panning the page. */
      touch-action: pan-y;
    }
    .g2048-scores {
      display: flex; gap: 10px; justify-content: center;
    }
    .g2048-score {
      flex: 1; min-width: 0;
      background: var(--surface-2);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      padding: 8px 14px;
      text-align: center;
    }
    .g2048-score-label {
      font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
      text-transform: uppercase; color: var(--fg-muted);
    }
    .g2048-score-value {
      font-family: var(--font-mono);
      font-size: 22px; font-weight: 700; color: var(--fg);
      line-height: 1.1; margin-top: 2px;
    }
    .g2048-actions {
      display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;
    }
    /* .btn:disabled is now shared in style.css. */
    /* AI auto-play toggle: lit (neutral "ink" fill) when engaged — matches
       .btn.primary so colour stays reserved for the brand wordmark. */
    #ai_auto_btn[aria-pressed="true"] {
      background: var(--fg); border-color: var(--fg); color: var(--bg);
    }
    /* The button label carries the SkyZero wordmark. Drop the flex gap so the
       wordmark + " 接管" keep their original single-space spacing. While engaged
       only "Sky" follows the button's text colour (else it'd vanish into the ink
       fill); "Zero" keeps its rainbow, which pops on the dark/light fill. */
    #ai_auto_btn { gap: 0; }
    #ai_auto_btn[aria-pressed="true"] .bw-sky {
      color: currentColor; -webkit-text-fill-color: currentColor;
    }
    .g2048-ai-status {
      min-height: 18px; text-align: center; font-size: 12.5px;
      font-family: var(--font-mono); color: var(--fg-muted);
    }
    /* When idle (no AI text) collapse the status row so the board can claim the
       space — mirrors the slider's collapse trick. margin-top: -14px cancels the
       flex gap above it; it re-expands the instant setAiStatus() puts text in. */
    .g2048-ai-status:empty { min-height: 0; margin-top: -14px; }
    /* AI speed/strength slider: left = faster (shorter time/move), right =
       stronger (longer time/move). Minimal style — a thin neutral rail (no fill)
       with a solid foreground-colour knob riding on top, distinct from the rail. */
    .g2048-ai-speed {
      display: flex; flex-direction: column; gap: 2px;
      max-width: 360px; margin: 0 auto; width: 100%;
      /* Collapsed until AI autoplay is engaged; .open slides + fades it in.
         margin-bottom cancels the parent .g2048-app gap (14px) while collapsed
         so the hidden slider takes up no vertical space. */
      max-height: 0; opacity: 0; overflow: hidden; visibility: hidden;
      margin-bottom: -14px; transform: translateY(-4px); pointer-events: none;
      transition: max-height .28s ease, opacity .24s ease, margin-bottom .28s ease,
                  transform .28s ease, visibility .28s;
    }
    .g2048-ai-speed.open {
      max-height: 58px; opacity: 1; visibility: visible;   /* 32px slider row + readout */
      margin-bottom: 0; transform: translateY(0); pointer-events: auto;
    }
    /* Slider + its two end labels share one row; the live readout sits below it. */
    .g2048-speed-track {
      display: flex; align-items: center; gap: 12px; width: 100%;
    }
    /* Live "steps/sec" readout under the slider. */
    .g2048-speed-val {
      text-align: center; font-size: 11px; font-weight: 600;
      font-family: var(--font-mono); color: var(--fg-muted);
      line-height: 1; user-select: none; -webkit-user-select: none;
    }
    .g2048-speed-end {
      font-size: 11px; font-weight: 600; color: var(--fg-muted);
      white-space: nowrap; user-select: none; -webkit-user-select: none;
    }
    .g2048-speed-wrap { position: relative; flex: 1; min-width: 0; }
    /* Small triangle under the rail marking the default (snap) position. JS sets
       --default-pos to the default value's normalized [0,1] spot; 9px = thumb
       radius, 18px = thumb width, so it lines up with where the thumb rests. */
    .g2048-speed-default {
      position: absolute; bottom: 0;
      left: calc(9px + (100% - 18px) * var(--default-pos, 0.0769));
      transform: translateX(-50%);
      width: 0; height: 0; pointer-events: none;
      border-left: 4px solid transparent;
      border-right: 4px solid transparent;
      border-bottom: 5px solid var(--fg-subtle);
    }
    #ai_speed {
      -webkit-appearance: none; appearance: none;
      /* Tall hit area (32px) so the :active-scaled thumb (18px → ~20.7px) has
         vertical room and isn't clipped top/bottom by the row's overflow, and
         so the default-position triangle (pinned to the bottom of the wrap)
         clears the thumb instead of overlapping it. The visible 8px rail and
         the 18px thumb stay auto-centered within this height. */
      display: block; width: 100%; height: 32px; margin: 0;
      background: transparent; cursor: pointer;
    }
    /* WebKit/Blink: rail in the score-card gray (--surface-2 + --border edge)
       + foreground-colour knob riding on top. */
    #ai_speed::-webkit-slider-runnable-track {
      height: 8px; border-radius: 999px;
      background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border);
    }
    #ai_speed::-webkit-slider-thumb {
      -webkit-appearance: none; appearance: none;
      width: 18px; height: 18px; margin-top: -5px;   /* (8-18)/2 — ride on rail */
      border-radius: 50%; background: var(--fg);
      box-shadow: 0 1px 3px rgba(0,0,0,.3);
      transition: transform .12s ease, box-shadow .12s ease;
    }
    #ai_speed:hover::-webkit-slider-thumb { box-shadow: 0 1px 5px rgba(0,0,0,.38); }
    #ai_speed:active::-webkit-slider-thumb { transform: scale(1.15); }
    /* Firefox: keep ::-moz-range-progress the same gray as the track so there
       is no left-side fill; foreground-colour knob. */
    #ai_speed::-moz-range-track {
      height: 8px; border-radius: 999px;
      background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border);
    }
    #ai_speed::-moz-range-progress {
      height: 8px; border-radius: 999px; background: var(--surface-2);
    }
    #ai_speed::-moz-range-thumb {
      width: 18px; height: 18px; border: none; border-radius: 50%;
      background: var(--fg); box-shadow: 0 1px 3px rgba(0,0,0,.3);
    }
    #ai_speed:focus { outline: none; }
    #ai_speed:focus-visible::-webkit-slider-thumb {
      box-shadow: 0 0 0 4px color-mix(in srgb, var(--fg) 30%, transparent);
    }
    #ai_speed:focus-visible::-moz-range-thumb {
      box-shadow: 0 0 0 4px color-mix(in srgb, var(--fg) 30%, transparent);
    }
    .g2048-board {
      position: relative;
      width: 100%;
      /* `max-width` is set live by fitBoard() in JS to the vertical room left
         below the board, so on short/landscape windows (and after the AI speed
         slider expands and pushes the board down) the square shrinks to fit the
         viewport instead of running off the bottom. align-self centers it once
         it's narrower than the full-width score/action rows above. */
      align-self: center;
      aspect-ratio: 1 / 1;
      /* Reuse the gomoku wood color so the two pages feel like the same app.
         Tracks the theme through the shared CSS variable. */
      background: var(--board-bg);
      border: 1px solid var(--border);
      /* Concentric corners with the inner cells: outer radius = cell radius
         + distance from cell to board outer edge. Distance is 1px border +
         10px grid inset (.g2048-grid is absolute with inset:10px from the
         padding-box). Equal radial gap on every point of the curve. */
      --board-radius: calc(var(--radius-md) + 11px);
      border-radius: var(--board-radius);
      padding: 10px;
      box-sizing: border-box;
      box-shadow: var(--shadow-xs);
      user-select: none;
      -webkit-user-select: none;
      /* Swipes on the board drive moves, not page scroll. */
      touch-action: none;
    }
    /* The board is focusable (tabindex=0) so keyboard arrows work. Suppress the
       ring for mouse/touch focus (it framed the whole board), but KEEP a visible
       ring for keyboard users (:focus-visible) so they can tell it has focus. */
    .g2048-board:focus { outline: none; }
    .g2048-board:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }
    .g2048-grid {
      position: absolute; inset: 10px;
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      grid-template-rows: repeat(4, 1fr);
      gap: 10px;
    }
    .g2048-cell {
      background: color-mix(in srgb, var(--fg-subtle) 18%, transparent);
      border-radius: var(--radius-md);
    }
    .g2048-tiles {
      position: absolute; inset: 10px;
      pointer-events: none;
    }
    /* Two-element tile: outer owns translate + slide transition; inner owns
       scale + pop/merge keyframes. Splitting them means the merge/appear
       animations never touch translate, so a fast slide that's interrupted
       by another slide blends smoothly instead of snapping to its final
       position via an overriding keyframe. */
    .g2048-tile {
      position: absolute;
      /* `--slide-ms` is set by JS per-move on `#tiles` and inherits down. The
         default keeps things sane if JS hasn't set it yet. */
      transition: transform var(--slide-ms, 120ms) cubic-bezier(0.22, 0.5, 0.28, 1);
      will-change: transform;
    }
    /* During a resize re-layout (reflowTiles) tiles must SNAP to the new cell
       geometry instead of sliding. Without this the slide transition makes them
       lag behind the instantly-resized board — e.g. while the AI speed slider
       opens, the tiles trail toward the shrinking board's bottom-right and only
       re-center afterwards. JS adds .no-slide for the synchronous re-place, then
       removes it next frame so real moves still animate. */
    .g2048-tiles.no-slide .g2048-tile { transition: none; }
    .g2048-tile-inner {
      width: 100%; height: 100%;
      display: flex; align-items: center; justify-content: center;
      font-family: var(--font-sans);
      font-weight: 700;
      border-radius: var(--radius-md);
      will-change: transform;
    }
    .g2048-tile.appear .g2048-tile-inner { animation: g2048-pop 150ms ease-out; }
    .g2048-tile.merged .g2048-tile-inner { animation: g2048-merge 160ms ease-out; }
    .g2048-tile.merged { z-index: 2; }
    @keyframes g2048-pop {
      from { transform: scale(0); }
      60%  { transform: scale(1.10); }
      to   { transform: scale(1); }
    }
    @keyframes g2048-merge {
      0%, 100% { transform: scale(1); }
      50%      { transform: scale(1.20); }
    }
    /* Brief pulse on the live score whenever it goes up (merge gain). transform
       doesn't reflow, so the surrounding card doesn't jump. */
    @keyframes g2048-score-bump {
      0%   { transform: scale(1); }
      30%  { transform: scale(1.16); }
      100% { transform: scale(1); }
    }
    .g2048-score-value.bump { animation: g2048-score-bump 180ms ease-out; }
    /* Tile palette — classic 2048 colors, tuned to look OK in dark mode too. */
    .g2048-tile-inner[data-v="2"]    { background:#eee4da; color:#776e65; }
    .g2048-tile-inner[data-v="4"]    { background:#ede0c8; color:#776e65; }
    .g2048-tile-inner[data-v="8"]    { background:#f2b179; color:#f9f6f2; }
    .g2048-tile-inner[data-v="16"]   { background:#f59563; color:#f9f6f2; }
    .g2048-tile-inner[data-v="32"]   { background:#f67c5f; color:#f9f6f2; }
    .g2048-tile-inner[data-v="64"]   { background:#f65e3b; color:#f9f6f2; }
    .g2048-tile-inner[data-v="128"]  { background:#edcf72; color:#f9f6f2; }
    .g2048-tile-inner[data-v="256"]  { background:#edcc61; color:#f9f6f2; }
    .g2048-tile-inner[data-v="512"]  { background:#edc850; color:#f9f6f2; }
    .g2048-tile-inner[data-v="1024"] { background:#edc53f; color:#f9f6f2; }
    .g2048-tile-inner[data-v="2048"] { background:#edc22e; color:#f9f6f2; }
    .g2048-tile-inner[data-v-big]    { background:#3c3a32; color:#f9f6f2; }
    .g2048-overlay {
      position: absolute;
      /* `inset: 0` would size the overlay to the board's padding-box (inside
         the 1px border) — then matching the corner curve means subtracting
         border-width, which never lines up perfectly under sub-pixel rendering
         or after theme tweaks. Instead extend the overlay 1px past every edge
         so it covers the border too, and use the same `--radius-lg` as the
         board's OUTER corner. Now the curves coincide by construction. */
      inset: -1px;
      display: none; flex-direction: column; align-items: center; justify-content: center;
      gap: 10px;
      background: color-mix(in srgb, var(--bg) 78%, transparent);
      backdrop-filter: blur(2px);
      /* Inherits --board-radius from .g2048-board so they always match. */
      border-radius: var(--board-radius);
      z-index: 3;
    }
    .g2048-overlay.show { display: flex; }
    .g2048-overlay-title {
      font-size: 28px; font-weight: 700; color: var(--fg);
    }
    .g2048-overlay-note {
      font-size: 12.5px; color: var(--fg-muted); text-align: center; max-width: 240px;
    }
    .g2048-overlay-note[hidden] { display: none; }

    /* Honor the OS "reduce motion" setting: tiles, score pulse, slider thumb and
       the speed-row slide-in all snap instead of animating. The board still
       updates instantly — only the animation is dropped. */
    @media (prefers-reduced-motion: reduce) {
      .g2048-tile { transition: none; }
      .g2048-tile.appear .g2048-tile-inner,
      .g2048-tile.merged .g2048-tile-inner,
      .g2048-score-value.bump { animation: none; }
      .g2048-ai-speed { transition: none; }
      #ai_speed::-webkit-slider-thumb { transition: none; }
      #ai_speed:active::-webkit-slider-thumb { transform: none; }
    }

