// Solo / single-player mode — adapted from design2/hitster/project/solo-screens.jsx.
// Self-contained: owns its own state machine, persistence, and audio. Loads a Spotify
// playlist (default = Hitster Norway) via /api/embed-playlist and uses the 30-second
// preview MP3 from each track for playback.

(() => {
const R = window.R;
const { Tag, Btn, Overprint, RisoStamp, RisoBars, RisoConfetti, Screen, GlobalRisoStyles, Stage, risoTex } = window;
const YearDrum = window.YearDrum;
// Pure helpers (extracted into solo-pure.js for testability across realms).
const SP = window.SoloPure;
// Per-device history store (solo-history.js) — feeds Leaderboard "By day" + Stats.
const SH = window.SoloHistory;

// MODES catalog: operator-curated, NOT user-configurable. To switch the deck
// for any mode, edit public/lib/solo-pure.js.
const MODES = SP.MODES;
const DECADES = ['60s', '70s', '80s', '90s', '00s', '10s', '20s'];
const STATS_KEY = 'hitfothit_solo_stats_v3'; // bumped: endless per difficulty + category best runs
const STATS_KEYS_LEGACY = ['hitfothit_solo_stats_v2', 'hitfothit_solo_stats_v1'];

// Shared riso palette cycle — category tiles + home mode tiles index into
// this, wrapping with palette[i % palette.length] (design handoff tokens).
const PALETTE = [R.pink, R.blue, R.yellow, R.green, R.purple, R.orange, R.teal];
const colorAt = (i) => PALETTE[i % PALETTE.length];

// Endless difficulty UI metadata. Tolerance windows live in solo-pure's
// ENDLESS_DIFFICULTY (tested); this only adds the presentation layer.
const DIFFS = ['easy', 'medium', 'hard'].map((key, i) => {
  const tol = SP.ENDLESS_DIFFICULTY[key].tol;
  return {
    key,
    name: SP.ENDLESS_DIFFICULTY[key].label,
    tol,
    win: `± ${tol} YEARS`,
    sub: ['Land inside the decade.', 'Half a decade of wiggle room.', 'Near enough to nothing.'][i],
    color: [R.green, R.yellow, R.pink][i],
    bars: i + 1,
  };
});
const diffByKey = (k) => DIFFS.find((d) => d.key === k) || DIFFS[1];
const DEVICE_KEY = 'hf_solo_device'; // solo identity. (Was hf_device_id — collided with the Spotify device picker. BUG #1.)
const DEVICE_KEY_LEGACY = 'hf_device_id';
const SPOTIFY_DEVICE_KEY = 'hf_spotify_device'; // picked Spotify Connect device, separate namespace now
const DEVICE_NAME_KEY = 'hf_device_name';

// Stable anonymous identity per browser — used as the server-side rowkey for
// solo scores. Plain UUID-ish; survives until localStorage is cleared.
//
// History: the original key was 'hf_device_id', which was ALSO used by the
// fullMode Spotify-Connect device picker. Picking a Spotify device clobbered
// the solo identity and split the player's leaderboard between two device ids
// (one anon-format, one Spotify-UUID-format). BUG #1. Fixed by:
//   - solo identity now lives under 'hf_solo_device'
//   - Spotify picker now writes 'hf_spotify_device' (defined below)
//   - migration: read legacy 'hf_device_id' if it looks like a solo id (d_*).
function getDeviceId() {
  try {
    let id = localStorage.getItem(DEVICE_KEY);
    if (!id) {
      // One-time migration from the colliding key.
      const legacy = localStorage.getItem(DEVICE_KEY_LEGACY);
      if (legacy && /^d_[a-zA-Z0-9_-]{4,}$/.test(legacy)) id = legacy;
    }
    if (!id) {
      id = 'd_' + Array.from(crypto.getRandomValues(new Uint8Array(12)))
        .map((b) => b.toString(36).padStart(2, '0')).join('').slice(0, 20);
    }
    localStorage.setItem(DEVICE_KEY, id);
    return id;
  } catch { return 'd_anon'; }
}
function getDeviceName() {
  try { return localStorage.getItem(DEVICE_NAME_KEY) || ''; } catch { return ''; }
}
function setDeviceName(n) {
  try { localStorage.setItem(DEVICE_NAME_KEY, (n || '').slice(0, 24)); } catch {}
}
async function postPlay({ mode, decade, score, rounds, playlistSlug }) {
  try {
    await fetch('/api/solo/play', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ deviceId: getDeviceId(), name: getDeviceName(), mode, decade, score, rounds, playlistSlug }),
    });
  } catch (_) { /* best-effort, local stats still hold */ }
}
async function fetchLeaderboard(mode, opts = {}) {
  try {
    const q = new URLSearchParams({ mode, limit: String(opts.limit || 20) });
    if (opts.decade) q.set('decade', opts.decade);
    if (opts.playlistSlug) q.set('playlistSlug', opts.playlistSlug);
    if (opts.difficulty) q.set('difficulty', opts.difficulty);
    if (opts.date) q.set('date', opts.date);
    if (opts.me) q.set('me', opts.me);
    const r = await fetch(`/api/solo/leaderboard?${q.toString()}`);
    if (!r.ok) return [];
    const j = await r.json();
    return j.rows || [];
  } catch { return []; }
}

// Pure helpers live in solo-pure.js (window.SoloPure aka SP). Re-bind locally
// for readability and so tests can stub.
const { scoreGuess, decadeOf, decadeLabel, todayUTC, isPriorDay,
  mulberry32, hashStr, shuffled, medianYear, endBanner, migrateStats } = SP;

function loadStats() {
  try {
    const raw = localStorage.getItem(STATS_KEY)
      || STATS_KEYS_LEGACY.map((k) => localStorage.getItem(k)).find(Boolean); // one-time migration
    return migrateStats(raw ? JSON.parse(raw) : null);
  } catch (_) { return migrateStats(null); }
}

function saveStats(s) {
  try { localStorage.setItem(STATS_KEY, JSON.stringify(s)); } catch (_) {}
}

function dailyPlayedToday(stats) {
  return stats?.daily?.lastDate === todayUTC();
}

// Run-state persistence (BUG #10) was removed with the home redesign — the
// "Resume run in progress" banner is gone by design (June 2026 handoff).
// Clear any stale blob left behind by older builds.
const RUN_STATE_KEY_LEGACY = 'hitfothit_solo_run_v1';
try { localStorage.removeItem(RUN_STATE_KEY_LEGACY); } catch (_) {}

// ── Header chrome ──────────────────────────────────────────────
function SoloHeader({ title, subtitle, back, right }) {
  return (
    <div style={{ padding: '4px 22px 0', display: 'flex', alignItems: 'center', gap: 12, position: 'relative', zIndex: 3 }}>
      <button onClick={back} style={{
        width: 38, height: 38, border: `2px solid ${R.ink}`, background: R.paper, color: R.ink,
        fontSize: 18, fontWeight: 900, cursor: 'pointer', boxShadow: `3px 3px 0 ${R.ink}`,
        fontFamily: 'inherit', flexShrink: 0,
      }}>←</button>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.22em', color: R.muted, textTransform: 'uppercase' }}>{subtitle}</div>
        <div style={{ fontSize: 22, fontWeight: 900, letterSpacing: '-.02em', color: R.ink, lineHeight: 1.05 }}>{title}</div>
      </div>
      {right}
    </div>
  );
}

// ── Leaderboard / Stats shared pieces ─────────────────────────
// Ported from the design prototype (.tmp_design/solo-leaderboard.jsx) onto this
// codebase's R palette + Screen/Btn conventions.

// 10-cell accuracy strip for a day: exact = yellow, within-2 = green, off = paper3.
function DayGrid({ ex, cl, of, size = 13, gap = 3 }) {
  const cells = [
    ...Array(ex).fill(R.yellow),
    ...Array(cl).fill(R.green),
    ...Array(of).fill(R.paper3),
  ];
  return (
    <div style={{ display: 'flex', gap }}>
      {cells.map((c, i) => (
        <div key={i} style={{ width: size, height: size, background: c, border: `1.5px solid ${R.ink}` }} />
      ))}
    </div>
  );
}

// Segmented control — value = active key, options = [{ k, label }].
function Segmented({ value, onChange, options }) {
  return (
    <div style={{ display: 'flex', gap: 0, border: `2px solid ${R.ink}`, boxShadow: `3px 3px 0 ${R.ink}`, background: R.paper2 }}>
      {options.map((o, i) => {
        const on = value === o.k;
        return (
          <button key={o.k} onClick={() => onChange(o.k)} style={{
            flex: 1, padding: '10px 8px', fontFamily: 'inherit', cursor: 'pointer',
            background: on ? R.ink : 'transparent', color: on ? R.paper : R.ink,
            border: 'none', borderLeft: i ? `2px solid ${R.ink}` : 'none',
            fontSize: 11, fontWeight: 900, letterSpacing: '.16em', textTransform: 'uppercase',
          }}>{o.label}</button>
        );
      })}
    </div>
  );
}

// Medal-style rank chip: 1=yellow, 2=paper3, 3=#d99a6c, else paper2.
function RankChip({ n, big = false }) {
  const fills = { 1: R.yellow, 2: R.paper3, 3: '#d99a6c' };
  const bg = fills[n] || R.paper2;
  return (
    <div style={{
      width: big ? 38 : 28, height: big ? 38 : 28, flexShrink: 0,
      background: bg, border: `2px solid ${R.ink}`, boxShadow: `2px 2px 0 ${R.ink}`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontFamily: 'ui-monospace, monospace', fontWeight: 900, fontSize: big ? 18 : 13, color: R.ink,
    }}>{n}</div>
  );
}

// Format an ISO yyyy-mm-dd → { label: 'JUN 3', dow: 'WED' } using UTC getters so
// the displayed date matches the UTC daily anchor regardless of local timezone.
function fmtISODate(iso) {
  try {
    const d = new Date(iso + 'T00:00:00Z');
    if (!Number.isFinite(d.getTime())) return { label: iso || '', dow: '' };
    const MON = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
    const DOW = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
    return { label: MON[d.getUTCMonth()] + ' ' + d.getUTCDate(), dow: DOW[d.getUTCDay()] };
  } catch (_) { return { label: iso || '', dow: '' }; }
}

function ModeCard({ kind, label, sub, color, locked, badge, onClick }) {
  return (
    <button onClick={onClick} disabled={locked} style={{
      width: '100%', padding: '18px 18px 16px', textAlign: 'left',
      background: color, color: R.ink, border: `2px solid ${R.ink}`,
      boxShadow: locked ? `2px 2px 0 ${R.ink}` : `5px 5px 0 ${R.ink}`,
      fontFamily: 'inherit', cursor: locked ? 'not-allowed' : 'pointer',
      position: 'relative', overflow: 'hidden',
      backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
      opacity: locked ? .55 : 1,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
        <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.28em', textTransform: 'uppercase', color: R.ink }}>{kind}</div>
        {badge && (
          <div style={{
            fontSize: 9, fontWeight: 900, letterSpacing: '.18em', padding: '3px 7px',
            background: R.ink, color: R.paper, textTransform: 'uppercase',
          }}>{badge}</div>
        )}
      </div>
      <div style={{ fontSize: 26, fontWeight: 900, letterSpacing: '-.02em', lineHeight: 1, marginTop: 6 }}>{label}</div>
      <div style={{ fontSize: 12, fontWeight: 700, color: R.ink, opacity: .75, marginTop: 6, lineHeight: 1.35 }}>{sub}</div>
    </button>
  );
}

function HeartRow({ lives, max = 3 }) {
  return (
    <div style={{ display: 'flex', gap: 4 }}>
      {Array.from({ length: max }).map((_, i) => {
        const alive = i < lives;
        return (
          <div key={i} style={{
            width: 14, height: 14,
            background: alive ? R.pink : 'transparent',
            border: `2px solid ${alive ? R.ink : R.dim}`,
            transform: 'rotate(45deg)',
            boxShadow: alive ? `2px 2px 0 ${R.ink}` : 'none',
          }} />
        );
      })}
    </div>
  );
}

// ── Screens ────────────────────────────────────────────────────
// Daily-reminder row — surfaces when the browser supports Web Push and the
// user hasn't subscribed yet (or is iOS-pre-install). Tap routes through
// window.HFPush; failures fall back to a friendly explainer.
function DailyReminderRow({ deviceId, getDeviceId, fullAccess }) {
  const [state, setState] = React.useState(() => {
    if (typeof window === 'undefined' || !window.HFPush) return 'unsupported';
    if (!window.HFPush.isSupported()) return 'unsupported';
    if (window.HFPush.needsInstallFirst()) return 'install_first';
    const p = window.HFPush.permission();
    if (p === 'denied') return 'blocked';
    if (p === 'granted') return 'maybe_active';
    return 'available';
  });
  const [busy, setBusy] = React.useState(false);
  const [hint, setHint] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    if (state !== 'maybe_active') return;
    (async () => {
      try {
        const sub = await window.HFPush.currentSubscription();
        if (!cancelled) setState(sub ? 'active' : 'available');
      } catch { if (!cancelled) setState('available'); }
    })();
    return () => { cancelled = true; };
  }, [state]);

  const onEnable = async () => {
    setBusy(true);
    setHint(null);
    const id = deviceId || (typeof getDeviceId === 'function' ? getDeviceId() : null);
    const r = await window.HFPush.subscribe(id);
    setBusy(false);
    if (r.ok) { setState('active'); setHint('You\'ll get a daily ping when the Mix refreshes.'); return; }
    if (r.error === 'install_first') {
      setState('install_first');
    } else if (/denied|NotAllowed/i.test(r.error)) {
      setState('blocked');
      setHint('Permission blocked — flip it in Settings → Notifications → Hitfothit.');
    } else {
      setHint('Could not enable reminders: ' + (r.error || 'unknown'));
    }
  };
  const onDisable = async () => {
    setBusy(true); setHint(null);
    const r = await window.HFPush.unsubscribe();
    setBusy(false);
    if (r.ok) { setState('available'); setHint('Reminders off.'); }
    else setHint('Could not turn off: ' + (r.error || 'unknown'));
  };
  if (state === 'unsupported') {
    // Show a brief explainer instead of disappearing — if the user expected
    // a reminder UI, this tells them why it's not here (iOS Safari < 16.4,
    // private-mode, in-app browser, etc.).
    return (
      <div style={{
        padding: '8px 10px', border: `2px dashed ${R.ink}`,
        fontSize: 10, fontWeight: 700, color: R.muted, lineHeight: 1.4,
      }}>
        🔔 Daily reminder needs Safari + iOS 16.4 or Chrome / Firefox. Open in your default browser to enable.
      </div>
    );
  }

  // Active = settings noise on a play screen — collapse to one thin row.
  // (The dev-only TEST push stays, tucked into the same row.)
  if (state === 'active') {
    const miniBtn = {
      padding: '5px 9px', border: `2px solid ${R.ink}`, background: R.paper,
      fontSize: 10, fontWeight: 900, letterSpacing: '.1em', cursor: 'pointer',
      fontFamily: 'inherit', color: R.ink, textTransform: 'uppercase',
    };
    return (
      <div style={{
        padding: '8px 12px', border: `2px solid ${R.ink}`, background: R.green,
        display: 'flex', flexDirection: 'column', gap: 6,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div style={{ flex: 1, fontSize: 12, fontWeight: 900, letterSpacing: '.04em' }}>🔔 Daily ping on</div>
          {fullAccess === true && (
            <button onClick={onSendTest} disabled={busy} style={miniBtn}>{busy ? '…' : 'Test'}</button>
          )}
          <button onClick={onDisable} disabled={busy} style={miniBtn}>Turn off</button>
        </div>
        {hint && <div style={{ fontSize: 10, fontWeight: 700, color: R.muted, lineHeight: 1.3 }}>{hint}</div>}
      </div>
    );
  }

  let label = '🔔 Daily reminder';
  let sub = '';
  let action = null;
  if (state === 'install_first') {
    sub = 'Add Hitfothit to Home Screen first (Share → Add to Home). iOS needs that before it can ping you.';
  } else if (state === 'blocked') {
    sub = 'Notifications blocked. iOS: Settings → Notifications → Hitfothit. Or System Settings on macOS.';
  } else if (state === 'available' || state === 'maybe_active') {
    sub = 'Get pinged each day so you don\'t miss the streak.';
    action = <Btn big={false} bg={R.green} onClick={onEnable} disabled={busy}>{busy ? 'ENABLING…' : 'ENABLE →'}</Btn>;
  }

  return (
    <div style={{
      padding: '10px 12px', border: `2px solid ${R.ink}`,
      background: R.paper2,
      
      display: 'flex', flexDirection: 'column', gap: 8,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 12, fontWeight: 900, letterSpacing: '.04em' }}>{label}</div>
          <div style={{ fontSize: 10, fontWeight: 700, color: R.muted, marginTop: 2, lineHeight: 1.35 }}>{sub}</div>
        </div>
        {action && <div style={{ flexShrink: 0 }}>{action}</div>}
      </div>
      {hint && (
        <div style={{ fontSize: 10, fontWeight: 700, color: R.muted, lineHeight: 1.3 }}>{hint}</div>
      )}
    </div>
  );
}

function SPHome({ stats, deckCount, onMode, onStats, onLeaderboard, onExit, fullAccess, deviceId }) {
  const playedToday = dailyPlayedToday(stats);
  const firstTime = !stats?.plays;
  const issueDate = React.useMemo(() => {
    const d = new Date();
    return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
  }, []);
  return (
    <Screen>
      <RisoStamp size={260} color={R.yellow} top={-80} left={-90} duration={28} />
      <RisoStamp size={180} color={R.blue} top={-30} left={250} duration={32} delay={2} />

      <div style={{ padding: '14px 22px 0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', position: 'relative', zIndex: 3 }}>
        <button onClick={onExit} style={{
          width: 38, height: 38, border: `2px solid ${R.ink}`, background: R.paper, color: R.ink,
          fontSize: 18, fontWeight: 900, cursor: 'pointer', boxShadow: `3px 3px 0 ${R.ink}`, fontFamily: 'inherit',
        }}>←</button>
        <button onClick={onStats} style={{
          padding: '8px 14px', border: `2px solid ${R.ink}`, background: R.yellow, color: R.ink,
          fontSize: 12, fontWeight: 900, letterSpacing: '.16em', cursor: 'pointer', boxShadow: `3px 3px 0 ${R.ink}`,
          fontFamily: 'inherit', textTransform: 'uppercase',
        }}>★ STATS</button>
      </div>

      <div style={{ padding: '14px 22px 4px', position: 'relative', zIndex: 3 }}>
        <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.3em', color: R.muted, textTransform: 'uppercase' }}>
          Solo · Issue 04 · {issueDate}
        </div>
        <Overprint sizes={56} color1={R.pink} color2={R.blue}><>TODAY'S<br/>MIX.</></Overprint>
        <div style={{
          display: 'inline-block', marginTop: 8,
          fontSize: 10, fontWeight: 900, letterSpacing: '.18em',
          background: R.ink, color: R.paper, padding: '3px 8px',
        }}>HEAR A SONG · GUESS THE YEAR</div>
      </div>

      <div style={{ flex: 1, padding: '12px 22px 18px', display: 'flex', flexDirection: 'column', gap: 9, position: 'relative', zIndex: 3, overflowY: 'auto' }}>

        {/* Prominent LEADERBOARD hero (purple) — your standing, taps into the
            board. Shown from the very first visit so new players can browse
            who's best before playing. PERSONAL BEST reconciles the local
            daily highscore with the best recorded daily-day. */}
        {(() => {
          const lh = SH ? SH.getStats() : null;
          const personalBest = Math.max((lh && lh.bestDailyDay) || 0, stats.highscores.daily || 0);
          const lbToday = playedToday ? (stats.daily.bestToday || 0) : 0;
          const games = (lh && lh.games) || stats.plays || 0;
          return (
            <button onClick={onLeaderboard} style={{
              position: 'relative', overflow: 'hidden', textAlign: 'left',
              padding: '13px 14px', background: R.purple, color: R.ink,
              border: `2px solid ${R.ink}`, boxShadow: `3px 3px 0 ${R.ink}`,
              fontFamily: 'inherit', cursor: 'pointer',
              backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
            }}>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
                <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.22em' }}>🏆 LEADERBOARD</div>
                <div style={{ fontSize: 15, fontWeight: 900 }}>→</div>
              </div>
              <div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, marginTop: 8 }}>
                <div>
                  <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.16em', opacity: .6 }}>PERSONAL BEST</div>
                  <div style={{ fontSize: 36, fontWeight: 900, lineHeight: .9, fontFamily: 'ui-monospace, monospace' }}>{personalBest}</div>
                </div>
                <div style={{ paddingBottom: 4, fontSize: 10, fontWeight: 800, letterSpacing: '.04em', opacity: .75, lineHeight: 1.5 }}>
                  Today {lbToday} · {stats.daily.streak}🔥 streak<br/>{games} {games === 1 ? 'game' : 'games'}
                </div>
              </div>
            </button>
          );
        })()}

        {/* 01 — DAILY MIX hero. Yellow, big number, leaderboard CTA. */}
        <button onClick={() => !playedToday && onMode('daily')} disabled={playedToday} style={{
          position: 'relative', overflow: 'hidden', textAlign: 'left',
          padding: '14px 14px 16px',
          background: playedToday ? R.paper3 : R.yellow,
          border: `2px solid ${R.ink}`, boxShadow: `3px 3px 0 ${R.ink}`,
          fontFamily: 'inherit', color: R.ink,
          cursor: playedToday ? 'default' : 'pointer',
          opacity: playedToday ? 0.85 : 1,
        }}>
          <div style={{
            position: 'absolute', right: -14, top: -28, fontSize: 130, fontWeight: 900,
            color: 'rgba(0,0,0,.10)', letterSpacing: '-.04em', lineHeight: 1, pointerEvents: 'none',
          }}>01</div>
          <div style={{ position: 'relative', zIndex: 2 }}>
            <div style={{ fontSize: 9, fontWeight: 900, letterSpacing: '.18em', color: R.muted }}>
              {playedToday ? 'DONE TODAY' : 'RECOMMENDED · TODAY ONLY'}
            </div>
            <div style={{ fontSize: 30, fontWeight: 900, letterSpacing: '.01em', lineHeight: .95, marginTop: 6 }}>
              DAILY<br/>MIX
            </div>
            <div style={{ fontSize: 11, fontWeight: 700, color: 'rgba(0,0,0,.7)', marginTop: 8, lineHeight: 1.35 }}>
              {playedToday
                ? `Best today: ${stats.daily.bestToday} · Streak ${stats.daily.streak}🔥 · Back tomorrow.`
                : `Same 10 tracks for everyone. Resets midnight.${stats.daily.streak ? ` Streak ${stats.daily.streak}🔥` : ''}`}
            </div>
            {!playedToday && (
              <div style={{
                display: 'inline-block', marginTop: 12,
                background: R.ink, color: R.paper,
                fontSize: 12, fontWeight: 900, letterSpacing: '.06em',
                padding: '8px 12px', border: `2px solid ${R.ink}`,
              }}>PLAY TODAY'S MIX →</div>
            )}
          </div>
        </button>

        {/* 02 / 03 / 04 — three equal index-card tiles (design handoff: home
            dir A "Catalogue"): Decade Drill · Endless · Category. Corner
            number watermark, kicker, emoji, name, sub. Colours cycle the
            shared palette at index 1/2/3 (blue / yellow / green). */}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 9 }}>
          {[
            { key: 'drill',    n: '02', kicker: 'TRAINING', name: 'DECADE\nDRILL', sub: 'Pick an era', emoji: '🎓' },
            { key: 'endless',  n: '03', kicker: 'SURVIVAL', name: 'ENDLESS',       sub: '3 lives',     emoji: '💀' },
            { key: 'category', n: '04', kicker: 'BROWSE',   name: 'CATEGORY',      sub: '12 genres',   emoji: '🗂️' },
          ].map((t, i) => (
            <button key={t.key} onClick={() => onMode(t.key)} style={{
              position: 'relative', overflow: 'hidden', textAlign: 'left', padding: 11,
              background: colorAt(i + 1), color: R.ink,
              border: `2px solid ${R.ink}`, boxShadow: `3px 3px 0 ${R.ink}`,
              fontFamily: 'inherit', cursor: 'pointer',
            }}>
              <div style={{
                position: 'absolute', right: -6, top: -12, fontSize: 62, fontWeight: 900,
                color: 'rgba(0,0,0,.10)', letterSpacing: '-.04em', lineHeight: 1, pointerEvents: 'none',
              }}>{t.n}</div>
              <div style={{ position: 'relative', zIndex: 2 }}>
                <div style={{ fontSize: 8.5, fontWeight: 900, letterSpacing: '.14em', color: 'rgba(0,0,0,.55)' }}>{t.kicker}</div>
                <div style={{ margin: '8px 0 6px', fontSize: 22, lineHeight: 1 }}>{t.emoji}</div>
                <div style={{ fontSize: 13, fontWeight: 900, lineHeight: 1, whiteSpace: 'pre-line' }}>{t.name}</div>
                <div style={{ fontSize: 9, fontWeight: 700, color: 'rgba(0,0,0,.65)', marginTop: 4 }}>{t.sub}</div>
              </div>
            </button>
          ))}
        </div>
      </div>

      <div style={{ padding: '0 22px 4px', position: 'relative', zIndex: 3, display: 'flex', flexDirection: 'column', gap: 8 }}>
        {/* Always rendered — the component decides what to show based on
            isSupported / install state. Previously gated on !firstTime, which
            hid the entire UI from anyone who'd just installed the PWA and
            opened solo for the first time. */}
        <DailyReminderRow deviceId={deviceId} fullAccess={fullAccess} />

        <div style={{
          padding: '8px 10px', border: `2px dashed ${R.ink}`, background: 'transparent',
          fontSize: 11, fontWeight: 700, color: R.muted, lineHeight: 1.4,
        }}>
          {firstTime
            ? <>No account, no email. <b style={{ color: R.ink }}>Scores live on this device only.</b></>
            : <>Plays: <b style={{ color: R.ink }}>{stats.plays}</b> · Deck: <b style={{ color: R.ink }}>{deckCount}</b> tracks · Stats local to this device.</>}
        </div>

        <VersionTag />
      </div>
    </Screen>
  );
}

// Build version footer — fetched from /api/version, links to the /version
// page. Silent if the endpoint isn't reachable (old server).
function VersionTag() {
  const [v, setV] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    fetch('/api/version').then((r) => r.json()).then((j) => { if (!cancelled) setV(j); }).catch(() => {});
    return () => { cancelled = true; };
  }, []);
  if (!v) return null;
  return (
    <a href="/version" style={{
      display: 'block', textAlign: 'center', textDecoration: 'none',
      fontSize: 9, fontWeight: 800, letterSpacing: '.16em', color: R.dim,
      fontFamily: 'ui-monospace, monospace', padding: '2px 0',
    }}>v{v.version} · {v.shortCommit}</a>
  );
}

function SPDecade({ deck, onPick, onBack }) {
  const counts = React.useMemo(() => {
    const m = {};
    DECADES.forEach((d) => { m[d] = deck.filter((s) => decadeOf(s.year) === d).length; });
    return m;
  }, [deck]);
  return (
    <Screen>
      <SoloHeader subtitle="Decade drill" title="PICK YOUR ERA" back={onBack} />
      <div style={{ flex: 1, padding: '20px 22px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, overflowY: 'auto' }}>
        {DECADES.map((d, i) => {
          const colors = [R.pink, R.blue, R.yellow, R.green, R.pink, R.blue, R.yellow];
          const c = colors[i % colors.length];
          const ready = counts[d] >= 6;
          return (
            <button key={d} onClick={() => ready && onPick(d)} disabled={!ready} style={{
              padding: '20px 14px', border: `2px solid ${R.ink}`, background: c, color: R.ink,
              boxShadow: `4px 4px 0 ${R.ink}`, cursor: ready ? 'pointer' : 'not-allowed', fontFamily: 'inherit',
              backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
              opacity: ready ? 1 : .5, textAlign: 'left',
            }}>
              <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em' }}>{decadeLabel(d)}</div>
              <div style={{ fontSize: 56, fontWeight: 900, letterSpacing: '-.04em', lineHeight: .9, marginTop: 6 }}>{d}</div>
              <div style={{ fontSize: 10, fontWeight: 700, marginTop: 6, opacity: .8 }}>{counts[d]} tracks {ready ? '' : '· too few'}</div>
            </button>
          );
        })}
      </div>
    </Screen>
  );
}

// Three 12×18 meter cells; filled count = difficulty rank (1/2/3) in the
// difficulty's colour. Shared by the picker rows.
function DiffMeter({ bars, color }) {
  return (
    <div style={{ display: 'flex', gap: 4 }}>
      {[0, 1, 2].map((i) => (
        <div key={i} style={{
          width: 12, height: 18, border: `2px solid ${R.ink}`,
          background: i < bars ? color : 'transparent',
        }} />
      ))}
    </div>
  );
}

// Endless difficulty picker — sits between home and the run (design handoff:
// EndlessDifficulty dir A "Meter list"). Tapping a row locks the difficulty
// and starts the run immediately.
function SPEndlessDifficulty({ onPick, onBack }) {
  return (
    <Screen>
      <RisoStamp size={300} color={R.pink} top={-100} left={-80} duration={26} />
      <SoloHeader subtitle="Endless · survival" title="CHOOSE DIFFICULTY" back={onBack} />
      <div style={{ padding: '8px 22px 0', position: 'relative', zIndex: 3 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, fontWeight: 800, color: R.muted, lineHeight: 1.4 }}>
          <span style={{ fontSize: 14 }}>💀</span> 3 lives · keep going until you miss three. Each guess inside the window survives.
        </div>
      </div>
      <div style={{ flex: 1, padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', zIndex: 3, justifyContent: 'flex-start' }}>
        {DIFFS.map((d) => (
          <button key={d.key} onClick={() => onPick(d.key)} style={{
            display: 'flex', alignItems: 'center', gap: 14, textAlign: 'left', padding: '16px 16px',
            background: R.paper2, color: R.ink, border: `2px solid ${R.ink}`,
            boxShadow: `4px 4px 0 ${R.ink}`, fontFamily: 'inherit', cursor: 'pointer',
          }}>
            {/* Colour spine — full row height. */}
            <div style={{ width: 14, alignSelf: 'stretch', background: d.color, border: `2px solid ${R.ink}`, flexShrink: 0 }} />
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 24, fontWeight: 900, letterSpacing: '-.02em', lineHeight: 1 }}>{d.name}</div>
              <div style={{ fontSize: 12, fontWeight: 700, color: R.muted, marginTop: 4 }}>{d.win} · {d.sub}</div>
            </div>
            <DiffMeter bars={d.bars} color={d.color} />
          </button>
        ))}
      </div>
    </Screen>
  );
}

function SPIntro({ mode, decade, endlessDiff, best, onContinue }) {
  const d = diffByKey(endlessDiff);
  const meta = {
    daily:   { title: "TODAY'S MIX",  sub: 'Daily',    rules: ['10 songs', 'Same set for everyone', 'Resets at midnight'], color: R.yellow },
    endless: { title: 'ENDLESS',      sub: `Survival · ${d.name}`, rules: ['3 lives', `Stay within ${d.win.toLowerCase()} to survive`, 'Score = songs survived'], color: d.color },
    drill:   { title: `${decade || '90s'} DRILL`, sub: 'Practice', rules: ['10 songs from one era', 'No lives, just score', 'Beat your best'], color: R.blue },
  }[mode] || { title: '', sub: '', rules: [], color: R.paper2 };

  React.useEffect(() => {
    const t = setTimeout(() => onContinue(), 2200);
    return () => clearTimeout(t);
  }, []);

  return (
    <Screen>
      <RisoStamp size={320} color={meta.color} top={-100} left={-80} duration={26} />
      <div style={{ padding: '18px 22px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', position: 'relative', zIndex: 3 }}>
        <Tag color={R.ink}>{meta.sub}</Tag>
        {best > 0 && <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.16em', color: R.muted }}>Best · <b style={{ color: R.ink }}>{best}</b></div>}
      </div>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 22px', position: 'relative', zIndex: 3, gap: 16 }}>
        <Overprint sizes={56} color1={R.pink} color2={R.blue}>{meta.title}</Overprint>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {meta.rules?.map((r, i) => (
            <div key={i} style={{
              padding: '10px 12px', background: R.paper2, border: `2px solid ${R.ink}`,
              display: 'flex', alignItems: 'center', gap: 10,
            }}>
              <div style={{ width: 8, height: 8, background: meta.color, border: `2px solid ${R.ink}` }} />
              <div style={{ fontSize: 14, fontWeight: 800 }}>{r}</div>
            </div>
          ))}
        </div>
      </div>
      <div style={{ padding: '0 22px', position: 'relative', zIndex: 3 }}>
        <Btn bg={meta.color} onClick={onContinue}>I'M IN →</Btn>
      </div>
    </Screen>
  );
}

function SPRoundIntro({ round, total, onContinue }) {
  React.useEffect(() => {
    const t = setTimeout(onContinue, 1100);
    return () => clearTimeout(t);
  }, []);
  return (
    <Screen>
      <RisoStamp size={400} color={R.blue} top={-120} left={-100} duration={20} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 2, gap: 8 }}>
        <div style={{ fontSize: 13, fontWeight: 800, letterSpacing: '.32em', color: R.muted, animation: 'rfade .4s' }}>TRACK</div>
        <div style={{
          fontSize: 200, fontWeight: 900, lineHeight: .8, letterSpacing: '-.06em', color: R.pink,
          mixBlendMode: 'multiply', animation: 'rdrop .5s cubic-bezier(.2,1.4,.4,1)',
          fontFamily: 'inherit',
        }}>{String(round).padStart(2, '0')}</div>
        <div style={{ fontSize: 16, fontWeight: 800, letterSpacing: '.18em', color: R.ink, opacity: .6 }}>OF {total}</div>
      </div>
    </Screen>
  );
}

// Circular colour medallion with an emoji — category picker + briefing.
function Medallion({ children, bg = R.paper, size = 46 }) {
  return (
    <div style={{
      width: size, height: size, borderRadius: '50%', background: bg,
      border: `2px solid ${R.ink}`, boxShadow: `2px 2px 0 ${R.ink}`,
      display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
      backgroundImage: risoTex('rgba(26,26,26,.10)', 3),
    }}>{children}</div>
  );
}

// Category picker — 12-tile "sleeve grid" (design handoff: CategoryPicker
// dir B). counts maps playlist slug → track count from /api/playlists; tiles
// whose playlist isn't curated yet grey out as COMING SOON.
function SPCategory({ counts, onPick, onBack }) {
  return (
    <Screen>
      <SoloHeader subtitle="Category play" title="PICK A CATEGORY" back={onBack} />
      <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '14px 22px 18px', position: 'relative', zIndex: 3 }}>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          {SP.CATEGORIES.map((cat, i) => {
            const col = colorAt(i);
            const n = counts ? counts[cat.slug] : undefined;
            // A category run is 10 songs — fewer playable tracks than that
            // and the deck isn't ready (mirrors SPDecade's too-few gating).
            const ready = Number.isFinite(n) && n >= 10;
            const nameSize = cat.name.length > 9 ? 18 : 22;
            return (
              <button key={cat.key} onClick={() => ready && onPick(cat.key)} disabled={!ready} style={{
                textAlign: 'left', background: R.paper2, color: R.ink, border: `2px solid ${R.ink}`,
                boxShadow: ready ? `3px 3px 0 ${R.ink}` : `2px 2px 0 ${R.ink}`,
                fontFamily: 'inherit', cursor: ready ? 'pointer' : 'not-allowed',
                overflow: 'hidden', padding: 0, opacity: ready ? 1 : .5,
              }}>
                {/* Top colour band. */}
                <div style={{ height: 8, background: col, borderBottom: `2px solid ${R.ink}` }} />
                <div style={{ padding: '12px 12px', display: 'flex', flexDirection: 'column', gap: 8, backgroundImage: risoTex('rgba(26,26,26,.06)', 3) }}>
                  <Medallion size={42} bg={col}><span style={{ fontSize: 22, lineHeight: 1 }}>{cat.emoji}</span></Medallion>
                  <div>
                    <div style={{ fontSize: nameSize, fontWeight: 900, letterSpacing: '-.01em', lineHeight: 1 }}>{cat.name}</div>
                    <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.1em', color: R.muted, marginTop: 4 }}>
                      {ready ? `${n} TRACKS` : 'COMING SOON'}
                    </div>
                  </div>
                </div>
              </button>
            );
          })}
        </div>
      </div>
    </Screen>
  );
}

// Category briefing — replaces SPIntro for category runs (the rules live
// here). START fetches nothing new: the deck was kicked off at pick time.
function SPCategoryIntro({ catKey, deckCount, best, onStart, onBack }) {
  const cat = SP.categoryByKey(catKey) || SP.CATEGORIES[0];
  const idx = SP.CATEGORIES.indexOf(cat);
  const col = colorAt(idx < 0 ? 0 : idx);
  return (
    <Screen>
      <RisoStamp size={300} color={col} top={-100} left={-80} duration={26} />
      <SoloHeader subtitle="Category play" title={cat.name.toUpperCase()} back={onBack} />
      <div style={{ flex: 1, padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 16, position: 'relative', zIndex: 3, justifyContent: 'center' }}>
        <div style={{ display: 'flex', justifyContent: 'center' }}>
          <Medallion size={96} bg={col}><span style={{ fontSize: 50, lineHeight: 1 }}>{cat.emoji}</span></Medallion>
        </div>
        <div style={{ padding: 18, border: `2px solid ${R.ink}`, background: R.paper2 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <Tag color={col}>10 SONGS</Tag>
            <span style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.1em', color: R.muted }}>
              {deckCount ? `${deckCount} IN THE DECK` : 'LOADING DECK…'}
            </span>
          </div>
          <div style={{ fontSize: 14, fontWeight: 700, color: R.ink, lineHeight: 1.45, marginTop: 12 }}>
            Hear a track from <b>{cat.name}</b>, spin to the year it dropped, score the spread. Ten in a row.
          </div>
          <div style={{ display: 'flex', gap: 24, marginTop: 16 }}>
            <div>
              <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>YOUR BEST</div>
              <div style={{ fontSize: 34, fontWeight: 900, lineHeight: 1, fontFamily: 'ui-monospace, monospace', marginTop: 2 }}>
                {best || 0}<span style={{ fontSize: 14 }}>/10</span>
              </div>
            </div>
            <div>
              <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>SCORING</div>
              <div style={{ fontSize: 13, fontWeight: 800, lineHeight: 1.3, marginTop: 6 }}>Closer year<br/>= more points</div>
            </div>
          </div>
        </div>
      </div>
      <div style={{ padding: '0 22px', position: 'relative', zIndex: 3 }}>
        <Btn bg={R.green} onClick={onStart}>START {cat.name.toUpperCase()} →</Btn>
      </div>
    </Screen>
  );
}

// Small ✕ pip in the corner — lets the player bail mid-run without finishing.
// Pauses audio, clears run state, and returns to home (no confirm: low-friction).
function ExitPip({ onExit }) {
  return (
    <button
      onClick={onExit}
      title="Exit run"
      style={{
        position: 'absolute', top: 8, right: 8, zIndex: 5,
        width: 32, height: 32, border: `2px solid ${R.ink}`,
        background: R.paper, color: R.ink,
        fontSize: 18, fontWeight: 900, lineHeight: 1,
        cursor: 'pointer', boxShadow: `2px 2px 0 ${R.ink}`,
        fontFamily: 'inherit',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}
    >×</button>
  );
}

function SPGuess({ round, total, mode, diffKey, survived, lives, score, year, setYear, song, useFullSongs, playFull, pauseFull, onLock, onTimeout, onExit }) {
  const TURN = useFullSongs ? 60 : 25;
  const [time, setTime] = React.useState(TURN);
  const [playbackError, setPlaybackError] = React.useState(null);
  const audioRef = React.useRef(null);

  React.useEffect(() => {
    if (!song) return;
    setPlaybackError(null);

    if (useFullSongs && playFull) {
      // Spotify Connect — fire-and-forget, errors surface inline.
      playFull(song).catch((e) => setPlaybackError(e?.message || 'Playback failed.'));
      return () => { pauseFull?.().catch(() => {}); };
    }

    // Preview mode — HTML5 audio
    if (!song.previewUrl) return;
    const a = new Audio(song.previewUrl);
    a.volume = 1;
    audioRef.current = a;
    a.play().catch(() => { /* iOS may block — user can tap card to retry */ });
    return () => { try { a.pause(); a.src = ''; } catch (_) {} audioRef.current = null; };
  }, [song?.id, useFullSongs]);

  React.useEffect(() => {
    if (time <= 0) { onTimeout(); return; }
    const t = setTimeout(() => setTime((s) => s - 1), 1000);
    return () => clearTimeout(t);
  }, [time]);

  const playAgain = () => {
    if (useFullSongs && playFull && song) {
      playFull(song).catch((e) => setPlaybackError(e?.message || 'Playback failed.'));
      return;
    }
    if (!audioRef.current || !song?.previewUrl) return;
    audioRef.current.currentTime = 0;
    audioRef.current.play().catch(() => {});
  };

  return (
    <Screen>
      <ExitPip onExit={() => {
        try { audioRef.current?.pause(); } catch (_) {}
        if (useFullSongs && pauseFull) pauseFull().catch(() => {});
        onExit?.();
      }} />
      {/* Top bar. Endless swaps the track counter + score for the difficulty
          tag, tolerance window, hearts and the survived✓ counter — the run
          has no fixed length and no graded score (design handoff). The
          countdown stays in both variants: the timeout mechanic is core. */}
      <div style={{ padding: '6px 46px 0 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
        {mode === 'endless' ? (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
            <Tag color={diffByKey(diffKey).color}>{diffByKey(diffKey).name}</Tag>
            <span style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.12em', color: R.muted, whiteSpace: 'nowrap' }}>{diffByKey(diffKey).win}</span>
          </div>
        ) : (
          <Tag color={R.ink}>Track {round}/{total}</Tag>
        )}
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          {mode === 'endless' && <HeartRow lives={lives} />}
          <div style={{
            padding: '4px 8px', border: `2px solid ${R.ink}`, background: R.paper2,
            fontSize: 12, fontWeight: 900, fontFamily: 'ui-monospace, monospace', letterSpacing: '.04em',
          }}>{mode === 'endless' ? `${survived}✓` : score}</div>
          <div style={{
            width: 34, height: 34, border: `2px solid ${R.ink}`,
            background: time <= 5 ? R.pink : R.yellow,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 14, fontWeight: 900, fontFamily: 'ui-monospace, monospace',
            
            animation: time <= 5 ? 'rpulse .6s ease-in-out infinite' : 'none',
          }}>{time}</div>
        </div>
      </div>

      <div style={{ padding: '14px 22px 0' }}>
        <div onClick={playAgain} style={{
          padding: '14px', background: R.paper2, border: `2px solid ${R.ink}`,
          boxShadow: `4px 4px 0 ${R.ink}`,
          display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer',
        }}>
          <div style={{
            width: 52, height: 52, background: R.ink, position: 'relative', overflow: 'hidden', flexShrink: 0,
            backgroundImage: `repeating-linear-gradient(45deg, ${R.pink} 0 4px, ${R.ink} 4px 9px)`,
            animation: 'rspin 12s linear infinite',
          }} />
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>
              NOW PLAYING · {useFullSongs ? 'SPOTIFY · FULL' : 'PREVIEW · 30s'}
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
              <RisoBars />
              <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.14em', color: R.ink }}>TAP TO REPLAY</div>
            </div>
          </div>
        </div>
        {playbackError && (
          <div style={{
            marginTop: 8, padding: '8px 10px', background: R.pink, color: R.paper,
            border: `2px solid ${R.ink}`, fontSize: 11, fontWeight: 700, lineHeight: 1.4,
          }}>{playbackError}</div>
        )}
      </div>

      <div style={{ flex: 1, padding: '10px 22px 0', display: 'flex', flexDirection: 'column' }}>
        <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.22em', color: R.muted, textAlign: 'center', marginBottom: 6 }}>SPIN TO YEAR</div>
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <YearDrum value={year} onChange={setYear} accent={R.pink} />
        </div>
      </div>

      <div style={{ padding: '0 22px' }}>
        <Btn bg={R.green} onClick={() => {
          try { audioRef.current?.pause(); } catch (_) {}
          if (useFullSongs && pauseFull) pauseFull().catch(() => {});
          onLock();
        }}>LOCK IT IN · {year} →</Btn>
      </div>
    </Screen>
  );
}

function SPReveal({ year, song, mode, round, total, diffKey, survived, lives, score, streak, onContinue, onExit }) {
  // Fixed-length modes know their last round — the CTA must not promise a
  // "next track" that doesn't exist. (Endless ends via lives, not rounds.)
  const isLastRound = !MODES[mode]?.lives && round >= total;
  const [step, setStep] = React.useState(0);
  const [display, setDisplay] = React.useState(year);
  const r = scoreGuess(song.year, year);
  const totalPoints = r.points + r.bonus;
  const exact = r.dist === 0;
  const close = r.dist <= 2;
  const dead = mode === 'endless' && lives <= 0;
  // BUG #4: streak prop was the value BEFORE this round, so a close hit
  // showed STREAK 0🔥 on reveal. Apply the same advancement rule the parent
  // uses (close → +1, else → 0) so the displayed streak reflects this round.
  const displayedStreak = close ? streak + 1 : 0;

  React.useEffect(() => {
    setDisplay(year);
    const t1 = setTimeout(() => {
      const start = year, end = song.year, diff = end - start;
      const dur = 1000;
      const t0 = performance.now();
      const tick = (now) => {
        const p = Math.min(1, (now - t0) / dur);
        const e = 1 - Math.pow(1 - p, 3);
        setDisplay(Math.round(start + diff * e));
        if (p < 1) requestAnimationFrame(tick);
        else setTimeout(() => setStep(1), 250);
      };
      requestAnimationFrame(tick);
    }, 350);
    return () => clearTimeout(t1);
  }, [song?.id]);

  // Endless reveal — SAFE (inside the tolerance window) vs MISS (outside).
  // Replaces the graded exact/close/off verdict + points/streak cells with
  // the survival readout (design handoff: EndlessReveal). The year count-up
  // animation and step-gated CTA carry over from the standard reveal.
  if (mode === 'endless') {
    const d = diffByKey(diffKey);
    const safe = SP.endlessSurvives(song.year, year, diffKey);
    const eAccent = safe ? R.green : R.pink;
    return (
      <Screen>
        <RisoStamp size={300} color={eAccent} top={-100} left={-90} duration={25} />
        <ExitPip onExit={onExit} />
        <div style={{ padding: '6px 46px 0 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, position: 'relative', zIndex: 3 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
            <Tag color={d.color}>{d.name}</Tag>
            <span style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.12em', color: R.muted, whiteSpace: 'nowrap' }}>{d.win}</span>
          </div>
          <HeartRow lives={lives} />
        </div>
        <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 22px', position: 'relative', zIndex: 3, gap: 14 }}>
          <div style={{ textAlign: 'center' }}>
            <Tag color={eAccent}>{safe ? `SAFE · WITHIN ${d.tol}` : `MISS · ${r.dist} ${r.dist === 1 ? 'YR' : 'YRS'} OFF`}</Tag>
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.32em', color: R.muted, marginTop: 14 }}>THE YEAR WAS</div>
            <div style={{
              fontSize: 120, fontWeight: 900, lineHeight: .85, letterSpacing: '-.05em', color: R.ink,
              fontFamily: 'ui-monospace, monospace', mixBlendMode: 'multiply', marginTop: 4,
            }}>{display}</div>
            <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.18em', color: R.muted, marginTop: 4 }}>
              YOU SAID {year} {r.dist === 0 ? '· EXACT' : `· ${r.dist} ${r.dist === 1 ? 'YR' : 'YRS'} OFF`}
            </div>
          </div>
          <div style={{
            padding: '14px', background: eAccent, border: `2px solid ${R.ink}`,
            backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
          }}>
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.18em', color: R.ink }}>THE TRACK</div>
            <div style={{ fontSize: 18, fontWeight: 900, lineHeight: 1.05, letterSpacing: '-.01em', marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.title}</div>
            <div style={{ fontSize: 12, fontWeight: 700, marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.artist}</div>
          </div>
          <div style={{
            opacity: step ? 1 : 0, transform: `translateY(${step ? 0 : 12}px)`,
            transition: 'opacity .35s, transform .35s',
            display: 'flex', gap: 10,
          }}>
            <div style={{ flex: 1, padding: '12px', background: R.paper2, border: `2px solid ${R.ink}` }}>
              <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>SURVIVED</div>
              <div style={{ fontSize: 32, fontWeight: 900, lineHeight: 1, marginTop: 2, fontFamily: 'ui-monospace, monospace' }}>{survived}</div>
            </div>
            <div style={{ flex: 1, padding: '12px', background: safe ? R.paper2 : R.pink, border: `2px solid ${R.ink}` }}>
              <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>LIVES</div>
              <div style={{ marginTop: 8 }}><HeartRow lives={lives} /></div>
            </div>
          </div>
        </div>
        <div style={{ padding: '0 22px', position: 'relative', zIndex: 3, opacity: step ? 1 : .3, pointerEvents: step ? 'auto' : 'none', transition: 'opacity .3s' }}>
          <Btn bg={dead || !safe ? R.pink : R.green} onClick={() => onContinue({ points: 0, dist: r.dist })}>
            {dead ? 'YOU DIED · SEE RESULTS →' : safe ? 'NEXT TRACK →' : 'KEEP GOING →'}
          </Btn>
        </div>
      </Screen>
    );
  }

  const accent = exact ? R.yellow : close ? R.green : R.pink;
  const verdict = exact ? 'BULLSEYE' : close ? 'NICE' : r.dist <= 7 ? 'CLOSE' : 'OFF';

  return (
    <Screen>
      {exact && <RisoConfetti count={45} />}
      <RisoStamp size={300} color={accent} top={-100} left={-90} duration={25} />
      <ExitPip onExit={onExit} />

      <div style={{ padding: '14px 46px 0 22px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', position: 'relative', zIndex: 3 }}>
        <Tag color={accent}>{verdict}</Tag>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          {mode === 'endless' && <HeartRow lives={lives} />}
          <div style={{ padding: '4px 8px', border: `2px solid ${R.ink}`, background: R.paper2, fontSize: 12, fontWeight: 900, fontFamily: 'ui-monospace, monospace' }}>
            {score + (step ? totalPoints : 0)}
          </div>
        </div>
      </div>

      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 22px', position: 'relative', zIndex: 3, gap: 14 }}>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.32em', color: R.muted }}>THE YEAR WAS</div>
          <div style={{
            fontSize: 130, fontWeight: 900, lineHeight: .85, letterSpacing: '-.05em', color: R.ink,
            fontFamily: 'ui-monospace, monospace', mixBlendMode: 'multiply', marginTop: 4,
          }}>{display}</div>
          <div style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.18em', color: R.muted, marginTop: 4 }}>
            YOU SAID {year} {r.dist === 0 ? '· EXACT' : `· ${r.dist} ${r.dist === 1 ? 'YR' : 'YRS'} OFF`}
          </div>
        </div>

        <div style={{
          padding: '14px', background: accent, border: `2px solid ${R.ink}`,
          backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
        }}>
          <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.18em', color: R.ink }}>THE TRACK</div>
          <div style={{ fontSize: 18, fontWeight: 900, lineHeight: 1.05, letterSpacing: '-.01em', marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.title}</div>
          <div style={{ fontSize: 12, fontWeight: 700, marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.artist}</div>
        </div>

        <div style={{
          opacity: step ? 1 : 0, transform: `translateY(${step ? 0 : 12}px)`,
          transition: 'opacity .35s, transform .35s',
          display: 'flex', gap: 10,
        }}>
          <div style={{ flex: 1, padding: '12px', background: R.paper2, border: `2px solid ${R.ink}` }}>
            <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>POINTS</div>
            <div style={{ fontSize: 32, fontWeight: 900, lineHeight: 1, marginTop: 2, fontFamily: 'ui-monospace, monospace' }}>+{totalPoints}</div>
            {r.bonus > 0 && <div style={{ fontSize: 10, fontWeight: 800, color: R.pink, marginTop: 2 }}>+{r.bonus} BULLSEYE</div>}
          </div>
          <div style={{ flex: 1, padding: '12px', background: displayedStreak >= 2 ? R.yellow : R.paper2, border: `2px solid ${R.ink}` }}>
            <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>STREAK</div>
            <div style={{ fontSize: 32, fontWeight: 900, lineHeight: 1, marginTop: 2, fontFamily: 'ui-monospace, monospace' }}>{displayedStreak}🔥</div>
          </div>
        </div>
      </div>

      <div style={{ padding: '0 22px', position: 'relative', zIndex: 3, opacity: step ? 1 : .3, pointerEvents: step ? 'auto' : 'none', transition: 'opacity .3s' }}>
        <Btn bg={dead ? R.pink : R.green} onClick={() => onContinue({ points: totalPoints, dist: r.dist })}>
          {dead ? 'YOU DIED · SEE RESULTS →' : isLastRound ? 'SEE RESULTS →' : 'NEXT TRACK →'}
        </Btn>
      </div>
    </Screen>
  );
}

// Push a name update to the latest play row; pairs with the
// latest-name-per-device subquery on the leaderboard so the rename is visible
// retroactively across all the player's prior rows.
async function pushName(name) {
  try {
    await fetch('/api/solo/name', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ deviceId: getDeviceId(), name }),
    });
  } catch (_) {}
}

function SPEnd({ mode, score, rounds, best, isNewHi, stats, dead, diffKey, categoryName, onShare, onReplay, onHome }) {
  const banner = endBanner({ isNewHi, mode, dead });
  const [name, setName] = React.useState(() => getDeviceName());
  const [savedFlash, setSavedFlash] = React.useState(false);
  const saveName = () => {
    const trimmed = (name || '').trim().slice(0, 24);
    setDeviceName(trimmed);
    if (trimmed) {
      pushName(trimmed);
      setSavedFlash(true);
      setTimeout(() => setSavedFlash(false), 1400);
    }
  };

  // Shared "what's your name" card — keeps leaderboard names from being
  // 'anon'. Loud on purpose: big heading, full-width input, extra emphasis
  // (yellow) while the name is still empty.
  const nameEmpty = !(name || '').trim();
  const nameSign = (
    <div style={{ padding: '12px 22px 0', position: 'relative', zIndex: 3 }}>
      <div style={{
        padding: '12px 14px', background: savedFlash ? R.green : nameEmpty ? R.yellow : R.paper2,
        border: `2px solid ${R.ink}`, boxShadow: `3px 3px 0 ${R.ink}`,
        display: 'flex', flexDirection: 'column', gap: 8, transition: 'background .2s',
      }}>
        <div style={{ fontSize: 16, fontWeight: 900, letterSpacing: '-.01em' }}>
          {savedFlash ? 'SAVED ✓' : "WHAT'S YOUR NAME?"}
        </div>
        <div style={{ fontSize: 11, fontWeight: 700, color: R.muted, lineHeight: 1.35 }}>
          Shows on the leaderboard — otherwise you're just “anon”.
        </div>
        <input
          value={name}
          onChange={(e) => setName(e.target.value.slice(0, 24))}
          onBlur={saveName}
          onKeyDown={(e) => { if (e.key === 'Enter') { e.target.blur(); } }}
          placeholder="Add your name"
          maxLength={24}
          style={{
            width: '100%', boxSizing: 'border-box', padding: '10px 12px',
            fontSize: 16, fontWeight: 800,
            border: `2px solid ${R.ink}`, background: R.paper,
            outline: 'none', fontFamily: 'inherit', minWidth: 0,
          }}
        />
      </div>
    </div>
  );

  // Endless game over — the score IS the survived count; show the window and
  // the per-difficulty best instead of points/avg (design handoff:
  // EndlessGameOver). New-best deaths keep both messages via endBanner.
  if (mode === 'endless') {
    const d = diffByKey(diffKey);
    return (
      <Screen>
        {isNewHi && <RisoConfetti count={60} />}
        <RisoStamp size={360} color={isNewHi ? R.yellow : R.pink} top={-120} left={-100} duration={28} />
        <div style={{ padding: '18px 22px 0', display: 'flex', justifyContent: 'space-between', position: 'relative', zIndex: 3 }}>
          <Tag color={R.ink}>Endless · {d.name}</Tag>
          <Tag color={isNewHi ? R.yellow : R.pink}>{isNewHi ? 'NEW BEST' : 'OUT OF LIVES'}</Tag>
        </div>
        <div style={{ flex: 1, padding: '20px 22px 0', display: 'flex', flexDirection: 'column', gap: 16, position: 'relative', zIndex: 3, overflowY: 'auto' }}>
          <Overprint sizes={48} color1={R.pink} color2={R.blue}>{banner}</Overprint>
          <div style={{
            padding: 18, border: `2px solid ${R.ink}`, background: R.yellow,
            backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
          }}>
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.22em', color: R.muted }}>SONGS SURVIVED</div>
            <div style={{ fontSize: 88, fontWeight: 900, lineHeight: .9, letterSpacing: '-.04em', fontFamily: 'ui-monospace, monospace', marginTop: 4 }}>{score}</div>
            <div style={{ fontSize: 13, fontWeight: 700, color: R.muted, marginTop: 6 }}>
              {d.win} window · best on {d.name.toLowerCase()}: <b style={{ color: R.ink }}>{best || score}</b>
            </div>
          </div>
        </div>
        {nameSign}
        <div style={{ padding: '10px 22px 0', display: 'flex', gap: 8, position: 'relative', zIndex: 3 }}>
          <div style={{ flex: 1 }}>
            <Btn bg={R.green} big={false} onClick={onReplay}>PLAY AGAIN</Btn>
          </div>
          <div style={{ flex: 1 }}>
            <Btn bg={R.paper2} big={false} onClick={onHome}>HOME</Btn>
          </div>
        </div>
      </Screen>
    );
  }

  return (
    <Screen>
      {isNewHi && <RisoConfetti count={60} />}
      <RisoStamp size={360} color={isNewHi ? R.yellow : R.blue} top={-120} left={-100} duration={28} />

      <div style={{ padding: '18px 22px 0', display: 'flex', justifyContent: 'space-between', position: 'relative', zIndex: 3 }}>
        <Tag color={R.ink}>Run end</Tag>
        <Tag color={isNewHi ? R.yellow : R.pink}>{dead ? 'OUT OF LIVES' : (isNewHi ? 'NEW HIGH' : 'COMPLETE')}</Tag>
      </div>

      <div style={{ flex: 1, padding: '20px 22px 0', display: 'flex', flexDirection: 'column', gap: 14, position: 'relative', zIndex: 3, overflowY: 'auto' }}>
        <Overprint sizes={44} color1={R.pink} color2={R.blue}>{banner}</Overprint>

        <div style={{
          padding: 18, border: `2px solid ${R.ink}`, background: isNewHi ? R.yellow : R.paper2,
          backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
        }}>
          <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.22em', color: R.muted }}>FINAL SCORE</div>
          <div style={{ fontSize: 80, fontWeight: 900, lineHeight: .9, letterSpacing: '-.04em', fontFamily: 'ui-monospace, monospace', marginTop: 4 }}>{score}</div>
          <div style={{ fontSize: 13, fontWeight: 700, color: R.muted, marginTop: 6 }}>
            {rounds} {rounds === 1 ? 'track' : 'tracks'} ·
            avg <b style={{ color: R.ink }}>{rounds ? Math.round(score / rounds) : 0}</b> per track
          </div>
        </div>

        <div style={{ display: 'flex', gap: 10 }}>
          <div style={{ flex: 1, padding: '10px 12px', border: `2px solid ${R.ink}`, background: R.paper2 }}>
            {/* Category tracks BEST RUN as hits-of-10, not graded points. */}
            <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>
              {mode === 'category' ? `BEST RUN${categoryName ? ' · ' + categoryName.toUpperCase() : ''}` : `BEST ${mode.toUpperCase()}`}
            </div>
            <div style={{ fontSize: 26, fontWeight: 900, fontFamily: 'ui-monospace, monospace', marginTop: 2 }}>
              {mode === 'category' ? <>{best || 0}<span style={{ fontSize: 13 }}>/10</span></> : (best || score)}
            </div>
          </div>
          {mode === 'daily' ? (
            <div style={{ flex: 1, padding: '10px 12px', border: `2px solid ${R.ink}`, background: R.pink, color: R.ink }}>
              <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.18em' }}>STREAK</div>
              <div style={{ fontSize: 26, fontWeight: 900, fontFamily: 'ui-monospace, monospace', marginTop: 2 }}>{stats.daily.streak}🔥</div>
            </div>
          ) : (
            <div style={{ flex: 1, padding: '10px 12px', border: `2px solid ${R.ink}`, background: R.paper2 }}>
              <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>PLAYS</div>
              <div style={{ fontSize: 26, fontWeight: 900, fontFamily: 'ui-monospace, monospace', marginTop: 2 }}>{stats.plays}</div>
            </div>
          )}
        </div>
      </div>

      {/* Inline name prompt — keeps leaderboard names from being all 'anon'.
          Pushes immediately on blur via /api/solo/name so the latest row,
          and the rename-aware leaderboard subquery, both reflect the change. */}
      {nameSign}

      <div style={{ padding: '10px 22px 0', display: 'flex', flexDirection: 'column', gap: 8, position: 'relative', zIndex: 3 }}>
        {MODES[mode]?.daily && onShare && <Btn bg={R.yellow} onClick={onShare}>SHARE TODAY →</Btn>}
        <div style={{ display: 'flex', gap: 8 }}>
          <div style={{ flex: 1 }}>
            <Btn bg={R.green} big={false} onClick={onReplay}>PLAY AGAIN</Btn>
          </div>
          <div style={{ flex: 1 }}>
            <Btn bg={R.paper2} big={false} onClick={onHome}>HOME</Btn>
          </div>
        </div>
      </div>
    </Screen>
  );
}

// ── LEADERBOARD — tabbed All-time (global social board + your PB) / By day ──
// Replaces the old combined SPHighscores. "All-time" is the global, anonymous,
// device-keyed board (reuses the fetchLeaderboard helper + the SHOW-AS name
// input from the old screen). "By day" is YOUR personal daily history from the
// SoloHistory store (daily mode only).
function SPLeaderboard({ stats, onBack, onStats, onShare }) {
  // Default to the daily "By day" view — your own daily history shows first,
  // the All-time global board sits behind the second tab.
  const [tab, setTab] = React.useState('day');

  // All-time tab — segmented mode toggle drives both the PB hero and the
  // fetched global board. Default DAILY. Endless splits further into a
  // per-difficulty sub-board, category into a per-category one (the server
  // filters by difficulty / playlist_slug respectively).
  const [selMode, setSelMode] = React.useState('daily');
  const [endlessDiff, setEndlessDiff] = React.useState('medium');
  const [selCategory, setSelCategory] = React.useState('hitforhit');
  const [globalRows, setGlobalRows] = React.useState([]);
  const [globalLoading, setGlobalLoading] = React.useState(false);
  const [name, setName] = React.useState(() => getDeviceName());
  const myId = getDeviceId();
  const selCat = SP.categoryByKey(selCategory) || SP.CATEGORIES[0];

  // Fetch on every mode change regardless of tab — the global board now shows
  // on BOTH the All-time and By day tabs.
  React.useEffect(() => {
    let cancel = false;
    setGlobalLoading(true);
    const opts = { limit: 10, me: myId };
    if (selMode === 'endless') opts.difficulty = endlessDiff;
    if (selMode === 'category') opts.playlistSlug = selCat.slug;
    fetchLeaderboard(selMode, opts).then((r) => {
      if (!cancel) { setGlobalRows(r); setGlobalLoading(false); }
    });
    return () => { cancel = true; };
  }, [selMode, endlessDiff, selCategory]);

  // Today's chart — the global DAILY board filtered to the current UTC day,
  // so the By-day tab answers "who's best TODAY?" (ranked, you highlighted).
  const [todayRows, setTodayRows] = React.useState([]);
  const [todayLoading, setTodayLoading] = React.useState(true);
  React.useEffect(() => {
    let cancel = false;
    fetchLeaderboard('daily', { limit: 10, date: todayUTC(), me: myId }).then((r) => {
      if (!cancel) { setTodayRows(r); setTodayLoading(false); }
    });
    return () => { cancel = true; };
  }, []);
  // SHOW-AS rename: persist locally + push to the latest play row (so prior
  // rows pick up the rename via the leaderboard subquery), like the old screen.
  const onNameBlur = () => { const n = (name || '').trim(); setDeviceName(n); if (n) pushName(n); };

  // By day tab — your personal daily history.
  const days = SH ? SH.getDailyDays() : [];
  const today = todayUTC();
  const todayRow = days.find((d) => d.date === today) || null;
  const restDays = days.filter((d) => d.date !== today);

  // Daily personal-best reconciles the local highscore blob with the best
  // recorded daily-day so it matches the home card's number; endless reads
  // the per-difficulty best; category the local best run (hits of 10);
  // drill the local highscore directly.
  const bestDailyDay = SH ? (SH.getStats().bestDailyDay || 0) : 0;
  const personalBest = selMode === 'daily'
    ? Math.max(stats.highscores.daily || 0, bestDailyDay)
    : selMode === 'endless'
      ? ((stats.endless && stats.endless[endlessDiff]) || 0)
      : selMode === 'category'
        ? ((stats.categories && stats.categories[selCategory]) || 0)
        : (stats.highscores[selMode] || 0);
  // Ranked board list — shared by the all-time board and today's chart.
  // Score bars scale to each list's own leader; `me` rows get the pink edge.
  const boardList = (rows, loading, emptyText) => {
    const top = rows.length ? (rows[0].score || 1) : 1;
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
        {loading && (
          <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center' }}>Loading…</div>
        )}
        {!loading && rows.length === 0 && (
          <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center', border: `2px dashed ${R.ink}` }}>
            {emptyText}
          </div>
        )}
        {!loading && rows.map((r, i) => {
          const me = !!r.me;
          return (
            <div key={i} style={{
              position: 'relative', overflow: 'hidden',
              display: 'flex', alignItems: 'center', gap: 11, padding: '10px 12px',
              background: i === 0 ? R.paper2 : R.paper, border: `2px solid ${me ? R.pink : R.ink}`,
              
            }}>
              <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${Math.min(100, (r.score / top) * 100)}%`, background: i === 0 ? 'rgba(255,210,63,.35)' : 'rgba(26,26,26,.05)' }} />
              <RankChip n={i + 1} />
              <div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
                <div style={{ fontSize: 14, fontWeight: 900, letterSpacing: '-.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {r.name || 'anon'}{me && <span style={{ color: R.pink }}> · YOU</span>}
                </div>
              </div>
              <div style={{ position: 'relative', fontSize: 22, fontWeight: 900, fontFamily: 'ui-monospace, monospace' }}>{r.score}</div>
            </div>
          );
        })}
      </div>
    );
  };

  // SHOW AS — global display name (anonymous, device-keyed). One per tab.
  const showAsSign = () => (
    <div style={{
      padding: '10px 12px', border: `2px solid ${R.ink}`, background: R.paper2,
      display: 'flex', alignItems: 'center', gap: 8,
    }}>
      <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.14em', color: R.muted, whiteSpace: 'nowrap' }}>SHOW AS</div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value.slice(0, 24))}
        onBlur={onNameBlur}
        onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
        placeholder="anon"
        maxLength={24}
        style={{
          flex: 1, padding: '4px 8px',
          fontSize: 13, fontWeight: 800,
          border: `2px solid ${R.ink}`, background: R.paper,
          outline: 'none', fontFamily: 'inherit', minWidth: 0,
        }}
      />
    </div>
  );

  // All-time global board (mode follows the toggle).
  const boardQualifier = selMode === 'endless'
    ? ` · ${endlessDiff.toUpperCase()}`
    : selMode === 'category'
      ? ` · ${selCat.name.toUpperCase()}`
      : '';
  const globalBoard = () => (
    <React.Fragment>
      <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>
        GLOBAL · TOP 10 · {selMode.toUpperCase()}{boardQualifier}
      </div>
      {boardList(globalRows, globalLoading, 'No plays yet — be the first.')}
      {showAsSign()}
    </React.Fragment>
  );

  return (
    <Screen>
      <SoloHeader subtitle="Daily" title="LEADERBOARD" back={onBack}
        right={<button onClick={onStats} style={{
          padding: '8px 12px', border: `2px solid ${R.ink}`, background: R.paper2, color: R.ink,
          fontSize: 10, fontWeight: 900, letterSpacing: '.16em', cursor: 'pointer',
          boxShadow: `3px 3px 0 ${R.ink}`, fontFamily: 'inherit', textTransform: 'uppercase',
        }}>STATS</button>} />

      <div style={{ padding: '12px 22px 10px' }}>
        <Segmented value={tab} onChange={setTab}
          options={[{ k: 'day', label: 'By day' }, { k: 'all', label: 'All-time' }]} />
      </div>

      <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 22px 8px', display: 'flex', flexDirection: 'column', gap: 12 }}>
        {tab === 'all' ? (
          <React.Fragment>
            {/* Mode toggle for the global board + PB hero. */}
            <Segmented value={selMode} onChange={setSelMode} options={[
              { k: 'daily', label: 'Daily' }, { k: 'endless', label: 'Endless' },
              { k: 'drill', label: 'Drill' }, { k: 'category', label: 'Category' },
            ]} />

            {/* Endless splits into per-difficulty boards — scores on EASY and
                HARD aren't comparable (the windows differ). */}
            {selMode === 'endless' && (
              <Segmented value={endlessDiff} onChange={setEndlessDiff} options={[
                { k: 'easy', label: 'Easy' }, { k: 'medium', label: 'Medium' }, { k: 'hard', label: 'Hard' },
              ]} />
            )}

            {/* Category picks its board per category — emoji chip row in the
                same inverted-active style as Segmented, wrapping over rows. */}
            {selMode === 'category' && (
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {SP.CATEGORIES.map((c) => {
                  const on = c.key === selCategory;
                  return (
                    <button key={c.key} onClick={() => setSelCategory(c.key)} style={{
                      padding: '6px 9px', border: `2px solid ${R.ink}`,
                      background: on ? R.ink : R.paper2, color: on ? R.paper : R.ink,
                      boxShadow: on ? 'none' : `2px 2px 0 ${R.ink}`,
                      fontFamily: 'inherit', cursor: 'pointer',
                      fontSize: 10, fontWeight: 900, letterSpacing: '.08em', textTransform: 'uppercase',
                      display: 'flex', alignItems: 'center', gap: 5,
                    }}>
                      <span style={{ fontSize: 12, lineHeight: 1 }}>{c.emoji}</span>{c.name}
                    </button>
                  );
                })}
              </div>
            )}

            {/* PERSONAL BEST hero — your local best for the selected mode.
                Note: the category PB is your best run (hits of 10, local),
                while the global category board ranks graded points — both
                metrics already coexist on the end screen. */}
            <div style={{ padding: '14px 16px', background: R.yellow, border: `2px solid ${R.ink}`, backgroundImage: risoTex('rgba(26,26,26,.08)', 3) }}>
              <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em' }}>
                PERSONAL BEST · {selMode === 'category' ? `${selCat.name.toUpperCase()} · BEST RUN` : selMode.toUpperCase() + (selMode === 'endless' ? ` · ${endlessDiff.toUpperCase()}` : '')}
              </div>
              <div style={{ fontSize: 64, fontWeight: 900, lineHeight: .9, letterSpacing: '-.03em', fontFamily: 'ui-monospace, monospace', marginTop: 4 }}>
                {personalBest}{selMode === 'category' && <span style={{ fontSize: 26 }}>/10</span>}
              </div>
            </div>

            {globalBoard()}
          </React.Fragment>
        ) : (
          <React.Fragment>
            {/* TODAY card — green when daily was played today, else muted. */}
            {todayRow ? (
              <div style={{ padding: '14px 16px', background: R.green, color: R.ink, border: `2px solid ${R.ink}`, backgroundImage: risoTex('rgba(26,26,26,.08)', 3) }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
                  <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.ink }}>TODAY · {fmtISODate(todayRow.date).label}</div>
                  <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.14em', opacity: .6 }}>STREAK {stats.daily.streak}🔥</div>
                </div>
                <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginTop: 6 }}>
                  <div style={{ fontSize: 56, fontWeight: 900, lineHeight: .9, letterSpacing: '-.03em', fontFamily: 'ui-monospace, monospace' }}>{todayRow.score}</div>
                  <div style={{ paddingBottom: 6 }}><DayGrid ex={todayRow.ex} cl={todayRow.cl} of={todayRow.of} /></div>
                </div>
                {/* Share today's run again — not only from the end screen. */}
                {onShare && (
                  <button onClick={onShare} style={{
                    marginTop: 10, padding: '7px 12px', background: R.ink, color: R.paper,
                    border: `2px solid ${R.ink}`, boxShadow: `2px 2px 0 rgba(26,26,26,.35)`,
                    fontSize: 11, fontWeight: 900, letterSpacing: '.1em', cursor: 'pointer',
                    fontFamily: 'inherit', textTransform: 'uppercase',
                  }}>Share today →</button>
                )}
              </div>
            ) : (
              <div style={{ padding: '14px 16px', background: R.paper2, border: `2px dashed ${R.ink}`, fontSize: 12, fontWeight: 700, color: R.muted, lineHeight: 1.4 }}>
                No daily run today yet — play the Daily mix to log it here.
              </div>
            )}

            {/* TODAY'S CHART — who's best THIS day. Global daily board filtered
                to today's date, ranked 1..10, you highlighted. Crown banner when
                you're sitting on top. */}
            <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
              <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>TODAY'S CHART · {fmtISODate(today).label}</div>
              <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.12em', color: R.muted }}>TOP 10</div>
            </div>
            {!todayLoading && todayRows.length > 0 && todayRows[0].me && (
              <div style={{
                padding: '11px 12px', background: R.yellow, border: `2px solid ${R.ink}`,
                backgroundImage: risoTex('rgba(26,26,26,.08)', 3),
                fontSize: 13, fontWeight: 900, letterSpacing: '.08em', textAlign: 'center',
              }}>
                👑 YOU'RE THE BEST TODAY!
              </div>
            )}
            {boardList(todayRows, todayLoading, 'No plays yet today — be the first.')}

            {/* DAY BY DAY — newest-first list of your recorded daily days. */}
            <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>DAY BY DAY</div>
            {restDays.length === 0 ? (
              <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center', border: `2px dashed ${R.ink}` }}>
                No days recorded yet — your daily runs will pile up here.
              </div>
            ) : (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
                {restDays.map((d) => {
                  const f = fmtISODate(d.date);
                  return (
                    <div key={d.date} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', background: R.paper, border: `2px solid ${R.ink}` }}>
                      <div style={{ width: 54, flexShrink: 0 }}>
                        <div style={{ fontSize: 13, fontWeight: 900, letterSpacing: '-.01em' }}>{f.label}</div>
                        <div style={{ fontSize: 9, fontWeight: 800, letterSpacing: '.12em', color: R.muted }}>{f.dow}</div>
                      </div>
                      <div style={{ flex: 1 }}><DayGrid ex={d.ex} cl={d.cl} of={d.of} size={11} /></div>
                      <div style={{ fontSize: 18, fontWeight: 900, fontFamily: 'ui-monospace, monospace' }}>{d.score}</div>
                    </div>
                  );
                })}
              </div>
            )}

            {/* Name sign — how you appear on the charts. */}
            {showAsSign()}
          </React.Fragment>
        )}
      </div>

      {/* Footer legend — colour key shared by every accuracy strip. */}
      <div style={{ padding: '8px 22px 6px', display: 'flex', alignItems: 'center', gap: 6, fontSize: 9, fontWeight: 800, letterSpacing: '.12em', color: R.muted }}>
        <span style={{ width: 9, height: 9, background: R.yellow, border: `1.5px solid ${R.ink}` }} /> EXACT
        <span style={{ width: 9, height: 9, background: R.green, border: `1.5px solid ${R.ink}`, marginLeft: 8 }} /> WITHIN 2
        <span style={{ width: 9, height: 9, background: R.paper3, border: `1.5px solid ${R.ink}`, marginLeft: 8 }} /> OFF
      </div>
    </Screen>
  );
}

// One labelled stat row: colour chip (emoji) · label + sub · big monospace
// value. The "Best per mode" groups are built from these (design handoff:
// BestPerMode).
function StatLine({ icon, label, valueLabel, value, color }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 13px', background: R.paper2, border: `2px solid ${R.ink}` }}>
      <div style={{ width: 34, height: 34, flexShrink: 0, background: color, border: `2px solid ${R.ink}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18 }}>{icon}</div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14, fontWeight: 900, letterSpacing: '-.01em', lineHeight: 1 }}>{label}</div>
        <div style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '.12em', color: R.muted, marginTop: 3 }}>{valueLabel}</div>
      </div>
      <div style={{ fontSize: 24, fontWeight: 900, fontFamily: 'ui-monospace, monospace' }}>{value}</div>
    </div>
  );
}

// ── STATS — personal aggregates (streak, games, avg, accuracy, best/mode,
// decade strengths). Streak/games/best-per-mode come from the existing stats
// blob; avg/track + accuracy % + decade strengths from SoloHistory aggregates.
function SPStats({ stats, onBack, onLeaderboard }) {
  const hist = SH ? SH.getStats() : { games: 0, avgPerTrack: 0, acc: { exact: 0, close: 0, off: 0 }, decades: [], bestDailyDay: 0 };
  const games = hist.games || stats.plays || 0;
  const acc = hist.acc;
  const decades = hist.decades || [];
  const accMax = decades.length ? Math.max(...decades.map((d) => d.pct), 1) : 1;
  const sortedByPct = [...decades].sort((a, b) => b.pct - a.pct);
  const strongest = sortedByPct[0] || null;
  const weakest = sortedByPct[sortedByPct.length - 1] || null;
  // Category best runs, highest first; only categories actually played.
  const catBests = SP.CATEGORIES
    .map((c, i) => ({ cat: c, idx: i, best: (stats.categories && stats.categories[c.key]) || 0 }))
    .filter((e) => e.best > 0)
    .sort((a, b) => b.best - a.best);

  return (
    <Screen>
      <SoloHeader subtitle="Personal" title="YOUR STATS" back={onBack}
        right={<button onClick={onLeaderboard} style={{
          padding: '8px 12px', border: `2px solid ${R.ink}`, background: R.yellow, color: R.ink,
          fontSize: 10, fontWeight: 900, letterSpacing: '.16em', cursor: 'pointer',
          boxShadow: `3px 3px 0 ${R.ink}`, fontFamily: 'inherit', textTransform: 'uppercase',
        }}>★ Board</button>} />

      <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '14px 22px 8px', display: 'flex', flexDirection: 'column', gap: 12 }}>
        {/* Streak + GAMES / AVG-PER-TRACK tiles. */}
        <div style={{ display: 'flex', gap: 10 }}>
          <div style={{ flex: 1.2, padding: '14px 14px', background: R.pink, border: `2px solid ${R.ink}`, backgroundImage: risoTex('rgba(26,26,26,.08)', 3) }}>
            <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.18em' }}>DAILY STREAK</div>
            <div style={{ fontSize: 46, fontWeight: 900, lineHeight: .9, fontFamily: 'ui-monospace, monospace', marginTop: 4 }}>{stats.daily.streak}🔥</div>
          </div>
          <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div style={{ flex: 1, padding: '8px 12px', background: R.paper2, border: `2px solid ${R.ink}` }}>
              <div style={{ fontSize: 9, fontWeight: 900, letterSpacing: '.16em', color: R.muted }}>GAMES</div>
              <div style={{ fontSize: 24, fontWeight: 900, fontFamily: 'ui-monospace, monospace', lineHeight: 1.1 }}>{games}</div>
            </div>
            <div style={{ flex: 1, padding: '8px 12px', background: R.paper2, border: `2px solid ${R.ink}` }}>
              <div style={{ fontSize: 9, fontWeight: 900, letterSpacing: '.16em', color: R.muted }}>AVG / TRACK</div>
              <div style={{ fontSize: 24, fontWeight: 900, fontFamily: 'ui-monospace, monospace', lineHeight: 1.1 }}>{hist.avgPerTrack}</div>
            </div>
          </div>
        </div>

        {/* Accuracy stacked bar — exact / within-2 / off as percentages. */}
        <div>
          <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.muted, marginBottom: 7 }}>ACCURACY</div>
          {(acc.exact + acc.close + acc.off) === 0 ? (
            <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center', border: `2px dashed ${R.ink}` }}>
              Play a few rounds to chart your accuracy.
            </div>
          ) : (
            <React.Fragment>
              <div style={{ display: 'flex', height: 38, border: `2px solid ${R.ink}`, overflow: 'hidden' }}>
                {acc.exact > 0 && <div style={{ width: `${acc.exact}%`, background: R.yellow, borderRight: `2px solid ${R.ink}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 900 }}>{acc.exact}%</div>}
                {acc.close > 0 && <div style={{ width: `${acc.close}%`, background: R.green, borderRight: `2px solid ${R.ink}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 900 }}>{acc.close}%</div>}
                {acc.off > 0 && <div style={{ width: `${acc.off}%`, background: R.paper3, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 900 }}>{acc.off}%</div>}
              </div>
              <div style={{ display: 'flex', gap: 14, marginTop: 6, fontSize: 9, fontWeight: 800, letterSpacing: '.1em', color: R.muted }}>
                <span>EXACT</span><span>WITHIN 2</span><span>OFF</span>
              </div>
            </React.Fragment>
          )}
        </div>

        {/* Best per mode — three labelled groups (design handoff: BestPerMode):
            graded modes, endless survived per difficulty, category top runs. */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>DAILY & DRILL</div>
          <StatLine icon="🗓️" label="Daily Mix"
            valueLabel={`HIGH SCORE${stats.daily.streak ? ` · ${stats.daily.streak}🔥 STREAK` : ''}`}
            value={stats.highscores.daily || 0} color={colorAt(2)} />
          <StatLine icon="🎓" label="Decade Drill"
            valueLabel="HIGH SCORE"
            value={stats.highscores.drill || 0} color={colorAt(0)} />
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>ENDLESS · SURVIVED PER DIFFICULTY</div>
          {DIFFS.map((d, i) => (
            <StatLine key={d.key} icon={['🟢', '🟡', '🔴'][i]}
              label={d.name.charAt(0) + d.name.slice(1).toLowerCase()}
              valueLabel={`±${d.tol} YEARS`}
              value={(stats.endless && stats.endless[d.key]) || 0}
              color={d.color} />
          ))}
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '.22em', color: R.muted }}>CATEGORY · TOP RESULTS</div>
          {catBests.length === 0 ? (
            <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center', border: `2px dashed ${R.ink}` }}>
              No category runs yet — pick one from the home screen.
            </div>
          ) : (
            catBests.map((e) => (
              <StatLine key={e.cat.key} icon={e.cat.emoji} label={e.cat.name}
                valueLabel="BEST RUN"
                value={<>{e.best}<span style={{ fontSize: 13 }}>/10</span></>}
                color={colorAt(e.idx)} />
            ))
          )}
        </div>

        {/* Decade strength bar chart — strongest green, weakest pink, else blue. */}
        <div>
          <div style={{ fontSize: 11, fontWeight: 900, letterSpacing: '.22em', color: R.muted, marginBottom: 7 }}>DECADE STRENGTH</div>
          {decades.length === 0 ? (
            <div style={{ padding: '10px 12px', fontSize: 11, fontWeight: 700, color: R.muted, textAlign: 'center', border: `2px dashed ${R.ink}` }}>
              Not enough plays yet to rank decades.
            </div>
          ) : (
            <React.Fragment>
              <div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 92, padding: '0 2px', borderBottom: `2px solid ${R.ink}` }}>
                {decades.map((d) => {
                  const isHi = strongest && d.d === strongest.d;
                  const isLo = weakest && d.d === weakest.d && (!strongest || strongest.d !== weakest.d);
                  return (
                    <div key={d.d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 4, height: '100%' }}>
                      <div style={{ fontSize: 9, fontWeight: 900, fontFamily: 'ui-monospace, monospace' }}>{d.pct}</div>
                      <div style={{ width: '100%', height: `${(d.pct / accMax) * 100}%`, background: isHi ? R.green : isLo ? R.pink : R.blue, border: `2px solid ${R.ink}` }} />
                      <div style={{ fontSize: 10, fontWeight: 900 }}>{d.d}</div>
                    </div>
                  );
                })}
              </div>
              {strongest && weakest && (
                <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
                  <div style={{ flex: 1, padding: '8px 10px', background: R.green, border: `2px solid ${R.ink}` }}>
                    <div style={{ fontSize: 9, fontWeight: 900, letterSpacing: '.14em' }}>STRONGEST</div>
                    <div style={{ fontSize: 16, fontWeight: 900 }}>{strongest.d} · {strongest.pct}%</div>
                  </div>
                  <div style={{ flex: 1, padding: '8px 10px', background: R.pink, border: `2px solid ${R.ink}` }}>
                    <div style={{ fontSize: 9, fontWeight: 900, letterSpacing: '.14em' }}>WEAKEST</div>
                    <div style={{ fontSize: 16, fontWeight: 900 }}>{weakest.d} · {weakest.pct}%</div>
                  </div>
                </div>
              )}
            </React.Fragment>
          )}
        </div>

        <div style={{ padding: '10px 12px', border: `2px dashed ${R.ink}`, fontSize: 11, fontWeight: 700, color: R.muted, lineHeight: 1.4 }}>
          Stats live on this device only. No account, no email.
        </div>
      </div>
    </Screen>
  );
}

// Spoiler-free counts (ex/cl/of) come from the caller — either the live run
// history (end screen) or today's stored daily row (leaderboard share).
function SPShare({ score, ex, cl, of, mode, onBack, onHome }) {
  const [copied, setCopied] = React.useState(false);
  const [linkCopied, setLinkCopied] = React.useState(false);
  // Share only exists for the Daily Mix now — the Eurovision daily became a
  // plain category. Date is the UTC daily anchor; URL is a real click-through.
  const label = 'DAILY';
  const date = todayUTC();
  const url = (typeof location !== 'undefined' ? location.origin : 'https://hitforhit.stens.app');
  // Reads as an invite to someone who's never played — pitch the game first
  // ("hear a song, guess the year"), then the score, then the challenge.
  const mixName = 'Daily Mix';
  const text = `🎵 Hitfothit — hear a song, guess the year it came out!\nI scored ${score} pts on today's ${mixName} (${ex} exact, ${cl} close).\nThink you can beat me? Try it here:\n${url}`;

  const copy = () => {
    try {
      navigator.clipboard?.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 1600);
    } catch (_) {}
  };

  // Native share sheet on iOS — falls back to clipboard.
  const nativeShare = () => {
    if (navigator.share) {
      navigator.share({ text, title: 'Hitfothit · ' + label }).catch(() => {});
    } else {
      copy();
    }
  };

  return (
    <Screen>
      <SoloHeader subtitle="Daily" title="SHARE IT" back={onBack} />

      <div style={{ flex: 1, padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 14, overflowY: 'auto' }}>
        <div style={{
          padding: 18, border: `2px solid ${R.ink}`, background: R.paper2, 
        }}>
          <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.22em', color: R.muted }}>HITFOTHIT · {label}</div>
            <div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.14em', color: R.muted, fontFamily: 'ui-monospace, monospace' }}>{date}</div>
          </div>
          <div style={{ fontSize: 40, fontWeight: 900, fontFamily: 'ui-monospace, monospace', marginTop: 2, lineHeight: 1 }}>{score} pts</div>
          <div style={{ marginTop: 10, fontSize: 13, fontWeight: 800, color: R.ink, letterSpacing: '.02em' }}>
            {ex} exact · {cl} within 2 · {of} off
          </div>
          <div style={{ marginTop: 12, fontSize: 14, fontWeight: 800, color: R.ink, lineHeight: 1.45 }}>
            🎵 Hear a song, guess the year it came out.<br/>
            Think you can beat me?<br/>
            <span style={{ fontWeight: 700, color: R.muted }}>Tap the link to try today's {label.toLowerCase()} mix.</span>
          </div>
        </div>

        <div style={{
          padding: '10px 12px', border: `2px dashed ${R.ink}`,
          fontSize: 11, fontWeight: 700, color: R.muted, lineHeight: 1.5,
        }}>
          No song titles. No years. Spoiler-free. Friends can tap the link to try the same {label.toLowerCase()}.
        </div>
      </div>

      <div style={{ padding: '0 22px', display: 'flex', flexDirection: 'column', gap: 8 }}>
        <Btn bg={copied ? R.green : R.yellow} onClick={typeof navigator !== 'undefined' && navigator.share ? nativeShare : copy}>
          {copied ? 'COPIED ✓' : (typeof navigator !== 'undefined' && navigator.share ? 'SHARE…' : 'COPY TO CLIPBOARD')}
        </Btn>
        <div style={{ display: 'flex', gap: 8 }}>
          {/* Copies ONLY the url — for dropping a bare link in a chat. */}
          <div style={{ flex: 1 }}>
            <Btn bg={linkCopied ? R.green : R.paper2} big={false} onClick={() => {
              try {
                navigator.clipboard?.writeText(url);
                setLinkCopied(true);
                setTimeout(() => setLinkCopied(false), 1600);
              } catch (_) {}
            }}>{linkCopied ? 'LINK COPIED ✓' : '🔗 COPY LINK'}</Btn>
          </div>
          <div style={{ flex: 1 }}>
            <Btn bg={R.paper2} big={false} onClick={onHome}>BACK TO HOME</Btn>
          </div>
        </div>
      </div>
    </Screen>
  );
}

function SPLoading({ progress, error, onRetry, onBack }) {
  return (
    <Screen>
      <SoloHeader subtitle="Solo · Loading" title="WARMING UP" back={onBack} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 22, gap: 14 }}>
        {error ? (
          <>
            <div style={{ fontSize: 13, fontWeight: 700, color: R.muted, textAlign: 'center', maxWidth: 280, lineHeight: 1.45 }}>{error}</div>
            <Btn bg={R.pink} onClick={onRetry}>TRY AGAIN</Btn>
          </>
        ) : (
          <>
            <RisoBars />
            <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.18em', color: R.muted }}>FETCHING THE DECK…</div>
            {progress && <div style={{ fontSize: 12, fontWeight: 700, color: R.muted }}>{progress}</div>}
          </>
        )}
      </div>
    </Screen>
  );
}

// ── Main solo app ──────────────────────────────────────────────
function SoloApp({ onExit }) {
  const [view, setView] = React.useState('loading');
  // Decks keyed by slug — primary deck (Norway) drives daily/endless/drill;
  // category decks load on demand from /api/playlists/<slug>.
  const [decks, setDecks] = React.useState({});
  const [loadError, setLoadError] = React.useState(null);
  const [stats, setStats] = React.useState(loadStats());
  // Playlist index (slug → PLAYABLE track count, i.e. tracks with a verified
  // year) — drives the category picker's "N TRACKS" labels and greys out
  // decks that aren't ready. Falls back to the raw trackCount if the server
  // predates playableCount.
  const [playlistCounts, setPlaylistCounts] = React.useState(null);
  React.useEffect(() => {
    fetch('/api/playlists')
      .then((r) => r.json())
      .then((j) => {
        const m = {};
        (j.presets || []).forEach((p) => {
          m[p.slug] = Number.isFinite(p.playableCount) ? p.playableCount : p.trackCount;
        });
        setPlaylistCounts(m);
      })
      .catch(() => setPlaylistCounts({}));
  }, []);

  // Spotify Premium full-song state — shared with /host's stored token.
  const [fullMode, setFullMode] = React.useState(localStorage.getItem('hf_solo_full') === 'true');
  const [spotifyToken, setSpotifyToken] = React.useState(() => window.Spotify?.loadStoredToken?.() || null);
  const [config, setConfig] = React.useState(null);
  // BUG #1 fix: dedicated namespace for the Spotify-Connect device picker.
  // Falling back to legacy 'hf_device_id' only if the value LOOKS like a
  // Spotify device id (40-char hex), never a solo identity (d_…).
  const [pickedDevice, setPickedDevice] = React.useState(() => {
    const v = localStorage.getItem(SPOTIFY_DEVICE_KEY);
    if (v) return v;
    const legacy = localStorage.getItem('hf_device_id');
    return legacy && /^[a-f0-9]{40}$/.test(legacy) ? legacy : null;
  });
  const tokenRef = React.useRef(spotifyToken);
  React.useEffect(() => { tokenRef.current = spotifyToken; }, [spotifyToken]);

  // Is the connected Spotify user on the FULL_ACCESS_EMAILS allowlist? Drives
  // whether we show the Premium CTA at all. Falls back to false when no token.
  const [fullAccess, setFullAccess] = React.useState(null); // null = unknown, false = no, true = yes
  React.useEffect(() => {
    let cancelled = false;
    if (!spotifyToken) { setFullAccess(false); return; }
    (async () => {
      const r = await window.Spotify.resolveFullAccess(spotifyToken, refreshSpotify);
      if (!cancelled) setFullAccess(!!r.allowed);
    })();
    return () => { cancelled = true; };
  }, [spotifyToken]);

  React.useEffect(() => {
    fetch('/api/config').then(r => r.json()).then(setConfig).catch(() => {});
  }, []);

  const refreshSpotify = React.useCallback(async () => {
    const Sp = window.Spotify;
    if (!Sp || !config?.spotifyClientId || !tokenRef.current?.refresh) {
      throw new Error('Spotify not configured.');
    }
    const t = await Sp.refreshToken(config.spotifyClientId, tokenRef.current.refresh);
    Sp.storeToken(t);
    tokenRef.current = t;
    setSpotifyToken(t);
    return t;
  }, [config]);

  const playFull = React.useCallback(async (track) => {
    const Sp = window.Spotify;
    if (!Sp || !spotifyToken) throw new Error('Spotify not connected. Open the host page first.');
    if (!track?.uri) throw new Error('No Spotify URI for this track.');
    const json = await Sp.spApi(spotifyToken, refreshSpotify, '/me/player/devices');
    const devices = json?.devices || [];
    if (devices.length === 0) {
      throw new Error("No Spotify device active. Open Spotify on your phone, hit play on anything for a moment, then try again.");
    }
    let deviceId = pickedDevice;
    if (!deviceId || !devices.find(d => d.id === deviceId)) {
      deviceId = (devices.find(d => d.is_active) || devices[0]).id;
      setPickedDevice(deviceId);
      localStorage.setItem(SPOTIFY_DEVICE_KEY, deviceId);
    }
    await Sp.transferPlayback(spotifyToken, refreshSpotify, deviceId, false);
    await new Promise(r => setTimeout(r, 300));
    await Sp.playTrack(spotifyToken, refreshSpotify, track.uri, deviceId);
  }, [spotifyToken, pickedDevice, refreshSpotify]);

  const pauseFull = React.useCallback(async () => {
    const Sp = window.Spotify;
    if (!Sp || !spotifyToken || !pickedDevice) return;
    try { await Sp.pausePlayback(spotifyToken, refreshSpotify, pickedDevice); } catch (_) {}
  }, [spotifyToken, pickedDevice, refreshSpotify]);

  const useFullSongs = fullMode && !!spotifyToken;

  const toggleFullMode = () => {
    const next = !fullMode;
    setFullMode(next);
    localStorage.setItem('hf_solo_full', String(next));
  };

  // Direct Spotify auth from solo. Stores a "return to /" hint so /host's
  // auth callback drops us back here instead of the host playlist screen,
  // and pre-enables fullMode since the user clearly wants it.
  const connectSpotify = async () => {
    if (!config?.spotifyClientId) return;
    try {
      localStorage.setItem('hf_post_auth_return', '/');
      localStorage.setItem('hf_solo_full', 'true');
      await window.Spotify.startAuth(config.spotifyClientId, config.redirectUri);
    } catch (_) {
      localStorage.removeItem('hf_post_auth_return');
    }
  };

  // Run state
  const [mode, setMode] = React.useState('daily');
  const [decade, setDecade] = React.useState(null);
  const [queue, setQueue] = React.useState([]); // upcoming songs
  const [round, setRound] = React.useState(0);
  const [total, setTotal] = React.useState(0);
  const [year, setYear] = React.useState(1995);
  const [score, setScore] = React.useState(0);
  const [streak, setStreak] = React.useState(0);
  const [lives, setLives] = React.useState(3);
  const [history, setHistory] = React.useState([]);
  // Endless: chosen tolerance window + how many songs survived this run.
  const [endlessDifficulty, setEndlessDifficulty] = React.useState('medium');
  const [survived, setSurvived] = React.useState(0);
  // Category play: which category the run draws from.
  const [categoryKey, setCategoryKey] = React.useState(null);

  // The deck slug the CURRENT run plays from. Category resolves at runtime
  // (MODES.category.deckSlug is null by design).
  const activeDeckSlug = (m, catKey) => {
    if (m === 'category') {
      const c = SP.categoryByKey(catKey);
      return c ? c.slug : null;
    }
    return MODES[m] ? MODES[m].deckSlug : null;
  };

  // Lazy deck loader. Each MODES entry names a deck slug; only the slug we
  // need is fetched on first use. Filters to tracks with a known year and a
  // preview URL (preview mode) — fullMode skips preview entirely so we don't
  // require previewUrl when the token is set.
  const loadDeck = React.useCallback(async (slug) => {
    if (decks[slug]) return decks[slug];
    const r = await fetch(`/api/playlists/${slug}`);
    if (!r.ok) throw new Error(`Couldn't load deck '${slug}' (${r.status})`);
    const j = await r.json();
    const tracks = (j.tracks || []).filter(t => t.year && (t.previewUrl || true));
    setDecks((prev) => ({ ...prev, [slug]: tracks }));
    return tracks;
  }, [decks]);

  // Read deep-link from URL once at mount. Push notifications include
  // ?mode=daily so tapping the daily-reminder notification opens straight
  // into the Daily Mix instead of the home picker.
  const autostartModeRef = React.useRef(null);
  React.useEffect(() => {
    try {
      const m = new URLSearchParams(window.location.search).get('mode');
      if (m && MODES[m]) autostartModeRef.current = m;
    } catch {}
  }, []);

  // Boot exactly once. loadDeck's identity changes whenever a new deck is
  // cached (it closes over `decks`), so depending on it re-ran this effect
  // after every on-demand deck load — bouncing an in-flight run back to
  // home. The ref guard keeps this a mount-only boot.
  const bootedRef = React.useRef(false);
  React.useEffect(() => {
    if (bootedRef.current) return;
    bootedRef.current = true;
    setLoadError(null);
    loadDeck(MODES.daily.deckSlug).then(() => {
      const auto = autostartModeRef.current;
      autostartModeRef.current = null;
      if (auto) {
        // Strip the param so a reload doesn't re-trigger autostart, then
        // jump into the mode. drill needs the decade picker first.
        try {
          const u = new URL(window.location.href);
          u.searchParams.delete('mode');
          window.history.replaceState({}, '', u.toString());
        } catch {}
        // Modes with their own pre-run picker route there instead of
        // autostarting a run directly.
        if (auto === 'drill') { setView('decade'); return; }
        if (auto === 'endless') { setView('endless-difficulty'); return; }
        if (auto === 'category') { setView('category'); return; }
        pickAndStart(auto, null);
        return;
      }
      setView('home');
    }).catch((e) => {
      setLoadError(e.message || 'Failed to load deck.');
    });
  }, [loadDeck]);

  // Primary deck used for the home-screen count + decade drill picker.
  const primaryDeck = decks[MODES.daily.deckSlug] || [];

  const startMode = async (m) => {
    setMode(m);
    // Modes with a pre-run picker: drill → era, endless → difficulty,
    // category → category grid. Daily starts straight away.
    if (m === 'drill') { setView('decade'); return; }
    if (m === 'endless') { setView('endless-difficulty'); return; }
    if (m === 'category') { setView('category'); return; }
    await pickAndStart(m, null);
  };

  const pickAndStart = async (m, dec) => {
    const cfg = MODES[m];
    if (!cfg) { setLoadError('Unknown mode.'); setView('loading'); return; }

    // Load the deck this mode needs (no-op if cached). Category resolves its
    // slug from the picked category; everything else from the MODES catalog.
    const slug = activeDeckSlug(m, categoryKey);
    if (!slug) { setLoadError('No deck for that selection.'); setView('loading'); return; }
    let pool;
    try {
      pool = await loadDeck(slug);
    } catch (e) {
      setLoadError(e.message || 'Failed to load deck.');
      setView('loading');
      return;
    }

    if (m === 'drill') pool = pool.filter(t => decadeOf(t.year) === dec);

    // Preview mode can't play tracks without a preview URL — drop them so a
    // round never comes up silent (BUG: Breakfast At Tiffany's, June 2026).
    // Full-songs mode (Spotify Connect) doesn't need previews, but the daily
    // must stay the same 10 for everyone, so its pool filters unconditionally.
    if (cfg.daily || !useFullSongs) pool = pool.filter(t => t.previewUrl);

    let q;
    let tot;
    if (cfg.daily) {
      // Same set everywhere, same day (BUG #8 — UTC date).
      const seed = hashStr(todayUTC() + cfg.deckSlug);
      q = shuffled(pool, mulberry32(seed)).slice(0, cfg.size || 10);
      tot = q.length;
    } else if (cfg.lives) {
      q = shuffled(pool, mulberry32(Date.now() & 0xffff));
      tot = q.length; // upper bound; ends on lives = 0
    } else {
      q = shuffled(pool, mulberry32(Date.now() & 0xffff)).slice(0, cfg.size || 10);
      tot = q.length;
    }
    if (q.length === 0) {
      setLoadError('No tracks for that selection.');
      setView('loading');
      return;
    }
    const initialYear = medianYear(pool, 1995);
    setDecade(dec);
    setQueue(q);
    setRound(1);
    setTotal(tot);
    setYear(initialYear);
    setScore(0);
    setStreak(0);
    setLives(cfg.lives || 3);
    setSurvived(0);
    setHistory([]);
    // Category's briefing screen already covered the rules — skip the intro
    // and drop straight into the round counter.
    setView(m === 'category' ? 'round-intro' : 'intro');
  };

  const currentSong = queue[round - 1] || null;

  // Shared guess-commit for lock + timeout. Endless survival: a guess inside
  // the difficulty's tolerance window survives (counter up); outside costs a
  // life. Other modes keep graded scoring untouched.
  const commitGuess = () => {
    if (!currentSong) return;
    const r = scoreGuess(currentSong.year, year);
    if (mode === 'endless') {
      if (!SP.endlessSurvives(currentSong.year, year, endlessDifficulty)) {
        setLives(Math.max(0, lives - 1));
      } else {
        setSurvived(s => s + 1);
      }
    }
    setHistory(h => [...h, { dist: r.dist, points: r.points + r.bonus, song: currentSong, guess: year }]);
    setView('reveal');
  };

  const lockGuess = commitGuess;
  const handleTimeout = commitGuess;

  const finishReveal = ({ points, dist }) => {
    setScore(s => s + points);
    const newStreak = dist <= 2 ? streak + 1 : 0;
    setStreak(newStreak);

    const cfg = MODES[mode];
    const dead = !!cfg?.lives && lives <= 0;
    const isLast = cfg?.lives ? dead : round >= total;

    if (isLast) {
      finalize({ deltaScore: points, dead });
    } else {
      setRound(r => r + 1);
      // Reset the year drum to the deck-median, not always 1995. Keeps the
      // dial near the deck's centre of mass (BUG #15).
      const cfgDeck = decks[activeDeckSlug(mode, categoryKey)] || [];
      setYear(medianYear(cfgDeck, 1995));
      setView('round-intro');
    }
  };

  const [lastIsNewHi, setLastIsNewHi] = React.useState(false);
  const [lastDead, setLastDead] = React.useState(false);

  // Share context — the share screen now has two entry points: straight from
  // the end screen (live run counts) and from the leaderboard (today's stored
  // daily row). Holds { score, ex, cl, of, mode, from }.
  const [shareCtx, setShareCtx] = React.useState(null);
  const openShareFromEnd = () => {
    setShareCtx({
      score,
      ex: history.filter((h) => h.dist === 0).length,
      cl: history.filter((h) => h.dist > 0 && h.dist <= 2).length,
      of: history.filter((h) => h.dist > 2).length,
      mode, from: 'end',
    });
    setView('share');
  };
  const openShareFromLeaderboard = () => {
    const t = SH ? SH.getDailyDays().find((d) => d.date === todayUTC()) : null;
    if (!t) return;
    setShareCtx({ score: t.score, ex: t.ex, cl: t.cl, of: t.of, mode: 'daily', from: 'leaderboard' });
    setView('share');
  };

  // Edge-swipe back (drag from the left edge →) for the standalone PWA, where
  // there's no browser back gesture. Only wired on "safe" navigation views —
  // never mid-run, so a stray swipe can't abandon a game.
  const swipeBack = React.useRef(null);
  swipeBack.current =
    view === 'leaderboard' || view === 'stats' || view === 'decade'
      || view === 'endless-difficulty' || view === 'category' ? () => setView('home')
      : view === 'category-intro' ? () => setView('category')
        : view === 'share' ? () => setView(shareCtx && shareCtx.from === 'leaderboard' ? 'leaderboard' : 'end')
          : view === 'home' ? onExit
            : null;
  React.useEffect(() => {
    let startX = null, startY = null, live = false;
    const onStart = (e) => {
      const t = e.touches && e.touches[0];
      if (t && t.clientX <= 28) { live = true; startX = t.clientX; startY = t.clientY; }
    };
    const onEnd = (e) => {
      if (!live) return;
      live = false;
      const t = e.changedTouches && e.changedTouches[0];
      if (!t) return;
      const dx = t.clientX - startX;
      const dy = Math.abs(t.clientY - startY);
      if (dx > 70 && dy < 60 && typeof swipeBack.current === 'function') swipeBack.current();
    };
    window.addEventListener('touchstart', onStart, { passive: true });
    window.addEventListener('touchend', onEnd, { passive: true });
    return () => {
      window.removeEventListener('touchstart', onStart);
      window.removeEventListener('touchend', onEnd);
    };
  }, []);

  const finalize = ({ deltaScore, dead }) => {
    const cfg = MODES[mode];
    // Endless chases the survived counter, not graded points; category's
    // local best is hits-of-10 (dist ≤ 2), though its graded score still
    // feeds the end screen + server board.
    const finalScore = mode === 'endless' ? survived : score + (deltaScore || 0);
    const hits = history.filter((h) => h.dist <= 2).length;
    const newStats = { ...stats, plays: stats.plays + 1 };
    let isNewHi = false;
    const today = todayUTC();

    if (mode === 'daily') {
      const continuingStreak = stats.daily.lastDate === today
        ? stats.daily.streak
        : (isPriorDay(stats.daily.lastDate, today) ? stats.daily.streak + 1 : 1);
      newStats.daily = {
        lastDate: today,
        streak: continuingStreak,
        bestToday: Math.max(stats.daily.bestToday || 0, finalScore),
        played: true,
      };
    }

    if (mode === 'endless') {
      // Best survived per difficulty.
      const prev = (stats.endless && stats.endless[endlessDifficulty]) || 0;
      if (finalScore > prev) {
        newStats.endless = { ...stats.endless, [endlessDifficulty]: finalScore };
        isNewHi = true;
      }
    } else if (mode === 'category') {
      // Best run per category, as hits-of-10.
      const prev = (stats.categories && stats.categories[categoryKey]) || 0;
      if (hits > prev) {
        newStats.categories = { ...stats.categories, [categoryKey]: hits };
        isNewHi = true;
      }
    } else if (finalScore > (stats.highscores[mode] || 0)) {
      newStats.highscores = { ...stats.highscores, [mode]: finalScore };
      isNewHi = true;
    }
    setStats(newStats);
    saveStats(newStats);
    setLastIsNewHi(isNewHi);
    setLastDead(!!dead);
    // Record the run to the per-device history store (feeds Leaderboard "By day"
    // + Stats). history holds every round at finalize() time. Wrapped so a
    // storage failure can never break the end screen.
    try {
      window.SoloHistory && window.SoloHistory.recordRun({
        mode,
        daily: !!cfg && !!cfg.daily,
        date: todayUTC(),
        score: finalScore,
        tracks: history.map((h) => ({ dist: h.dist, actualYear: h.song && h.song.year })),
      });
    } catch (_) { /* best-effort: stats/leaderboard still work without history */ }
    // Fire-and-forget server save for the global SQLite leaderboard. Endless
    // posts the survived count + its difficulty; category posts its graded
    // score + the chosen category's playlist slug.
    postPlay({
      mode, decade,
      score: finalScore, rounds: history.length + 1,
      playlistSlug: activeDeckSlug(mode, categoryKey),
      difficulty: mode === 'endless' ? endlessDifficulty : undefined,
    });
    setView('end');
  };

  const replay = () => pickAndStart(mode, decade);
  const goHome = () => setView('home');

  // Render
  if (view === 'loading') {
    return (
      <SPLoading
        error={loadError}
        onRetry={loadDeck}
        onBack={onExit}
      />
    );
  }
  if (view === 'home') return (
    <SPHome
      stats={stats}
      deckCount={primaryDeck.length}
      onMode={startMode}
      onStats={() => setView('stats')}
      onLeaderboard={() => setView('leaderboard')}
      onExit={onExit}
      fullMode={fullMode}
      hasSpotifyToken={!!spotifyToken}
      onToggleFull={toggleFullMode}
      onConnectSpotify={connectSpotify}
      configReady={!!config?.spotifyClientId}
      fullAccess={fullAccess}
      deviceId={getDeviceId()}
    />
  );
  if (view === 'decade') return (
    <SPDecade deck={primaryDeck} onPick={(d) => pickAndStart('drill', d)} onBack={() => setView('home')} />
  );
  if (view === 'endless-difficulty') return (
    <SPEndlessDifficulty
      onPick={(k) => { setEndlessDifficulty(k); pickAndStart('endless', null); }}
      onBack={() => setView('home')}
    />
  );
  if (view === 'category') return (
    <SPCategory
      counts={playlistCounts}
      onPick={(k) => {
        setCategoryKey(k);
        setView('category-intro');
        // Kick the deck fetch off now so the briefing shows the real count
        // and START is instant. Errors surface on START via pickAndStart.
        const c = SP.categoryByKey(k);
        if (c) loadDeck(c.slug).catch(() => {});
      }}
      onBack={() => setView('home')}
    />
  );
  if (view === 'category-intro' && categoryKey) return (
    <SPCategoryIntro
      catKey={categoryKey}
      deckCount={(decks[activeDeckSlug('category', categoryKey)] || []).length}
      best={(stats.categories && stats.categories[categoryKey]) || 0}
      onStart={() => pickAndStart('category', null)}
      onBack={() => setView('category')}
    />
  );
  if (view === 'intro') return (
    <SPIntro
      mode={mode} decade={decade}
      endlessDiff={endlessDifficulty}
      best={mode === 'endless'
        ? ((stats.endless && stats.endless[endlessDifficulty]) || 0)
        : (stats.highscores[mode] || 0)}
      onContinue={() => setView('round-intro')}
    />
  );
  if (view === 'round-intro') return (
    <SPRoundIntro round={round} total={total} onContinue={() => setView('guess')} />
  );
  // Abandons the current run: pause audio, go home.
  const exitRun = () => {
    if (useFullSongs && pauseFull) pauseFull().catch(() => {});
    setView('home');
  };

  if (view === 'guess' && currentSong) return (
    <SPGuess
      round={round} total={total} mode={mode}
      diffKey={endlessDifficulty} survived={survived}
      lives={lives} score={score}
      year={year} setYear={setYear}
      song={currentSong}
      useFullSongs={useFullSongs}
      playFull={playFull}
      pauseFull={pauseFull}
      onLock={lockGuess}
      onTimeout={handleTimeout}
      onExit={exitRun}
    />
  );
  if (view === 'reveal' && currentSong) return (
    <SPReveal
      year={year} song={currentSong}
      mode={mode} round={round} total={total}
      diffKey={endlessDifficulty} survived={survived}
      lives={lives} score={score} streak={streak}
      onContinue={finishReveal}
      onExit={exitRun}
    />
  );
  if (view === 'end') return (
    <SPEnd
      mode={mode}
      score={mode === 'endless' ? survived : score}
      rounds={history.length}
      best={mode === 'endless'
        ? ((stats.endless && stats.endless[endlessDifficulty]) || 0)
        : mode === 'category'
          ? ((stats.categories && stats.categories[categoryKey]) || 0)
          : (stats.highscores[mode] || 0)}
      isNewHi={lastIsNewHi}
      dead={lastDead}
      stats={stats}
      diffKey={endlessDifficulty}
      categoryName={mode === 'category' && SP.categoryByKey(categoryKey) ? SP.categoryByKey(categoryKey).name : null}
      onShare={MODES[mode]?.daily ? openShareFromEnd : null}
      onReplay={replay}
      onHome={goHome}
    />
  );
  if (view === 'leaderboard') return (
    <SPLeaderboard
      stats={stats}
      onBack={() => setView('home')}
      onStats={() => setView('stats')}
      onShare={openShareFromLeaderboard}
    />
  );
  if (view === 'stats') return (
    <SPStats
      stats={stats}
      onBack={() => setView('home')}
      onLeaderboard={() => setView('leaderboard')}
    />
  );
  if (view === 'share' && shareCtx) return (
    <SPShare score={shareCtx.score} ex={shareCtx.ex} cl={shareCtx.cl} of={shareCtx.of} mode={shareCtx.mode}
      onBack={() => setView(shareCtx.from === 'leaderboard' ? 'leaderboard' : 'end')}
      onHome={goHome} />
  );

  return <SPLoading error="Something went sideways." onRetry={() => setView('home')} onBack={onExit} />;
}

window.SoloApp = SoloApp;
})();
