// Tessra v3.4 lacuna — tray + holes.
// Screens: Home, Daily, LevelMap, LevelPlay (hole boards), About, Paywall.
// 20 free tutorial levels + 100 chapter levels behind a mock paywall.

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// Shared patch-engine (loaded before this file in Penrose Mosaic.html).
// vertexSignatureAt still powers the Daily figure detector.
const { vertexSignatureAt } = window.TessraFigureFinder;
const HL = window.HoleLevels;

// ─── Test mode ────────────────────────────────────────────────────────────
// While TEST_MODE is on, the paywall is bypassed and every level is open, so
// all 120 boards can be played and QA'd. Flip to false (or ?test=0) to ship.
const TEST_MODE = (function () {
  try {
    const q = new URLSearchParams(window.location.search);
    if (q.get('test') === '0') return false;
    if (q.get('test') === '1') return true;
  } catch (e) {}
  return true; // temporary default — unlock everything for testing
})();
function isPremium(progress) { return TEST_MODE || !!(progress && progress.premium); }
function isUnlocked(progress, id) { return TEST_MODE || id <= (progress ? progress.unlocked : 1); }

// ─── Persistence ──────────────────────────────────────────────────────────
const STORAGE_KEY = 'penrose-v34-progress';
const LEGACY_PROGRESS_KEY = 'penrose-v33-progress';
function loadProgress() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) {
      const p = JSON.parse(raw);
      return {
        unlocked: Math.max(1, Math.min(HL.TOTAL + 1, p.unlocked || 1)),
        dailyByDate: p.dailyByDate || {},
        premium: !!p.premium,
      };
    }
    // Migration from v3.3: the levels are new content on a new mechanic, so
    // level progress restarts at 1 — but daily mosaics carry over.
    const legacyRaw = localStorage.getItem(LEGACY_PROGRESS_KEY);
    if (legacyRaw) {
      const lp = JSON.parse(legacyRaw);
      const next = { unlocked: 1, dailyByDate: lp.dailyByDate || {}, premium: false };
      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (e) {}
      return next;
    }
    return { unlocked: 1, dailyByDate: {}, premium: false };
  } catch (e) {
    return { unlocked: 1, dailyByDate: {}, premium: false };
  }
}
function saveProgress(p) {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch (e) {}
}

function unlockLevelIfCurrent(setProgress, levelId) {
  setProgress(prev => {
    if (prev.unlocked !== levelId) return prev;
    const next = { ...prev, unlocked: Math.min(HL.TOTAL + 1, levelId + 1) };
    saveProgress(next);
    return next;
  });
}

// ─── Theme context (Soft Atlas in production — themes.js) ────────────────
const ColorsContext = React.createContext(window.TessraThemes ? window.TessraThemes.active : {});
function useC() { return React.useContext(ColorsContext); }

const MONO = 'JetBrains Mono, monospace';
const SERIF = 'Fraunces, serif';
const BNB_LOGO_SRC = '../../../assets/bnb-logo-full-black.png';

function BnbStudioLogo({ height = 32, style }) {
  return (
    <a
      href="https://bots-n-bones.com/"
      target="_blank"
      rel="noopener noreferrer"
      aria-label="Bots & Bones"
      style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.82, ...style }}
    >
      <img
        src={BNB_LOGO_SRC}
        alt="Bots & Bones"
        height={height}
        style={{ height, width: 'auto', filter: 'invert(1)', display: 'block' }}
      />
    </a>
  );
}

const REDUCED_MOTION = typeof window !== 'undefined'
  && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function iconBtnStyle(C) {
  return {
    width: 36, height: 36, borderRadius: 12,
    background: C.surfaceElevated, border: `1px solid ${C.outline}`,
    display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
    padding: 0,
  };
}
function btnGhostStyle(C) {
  return {
    height: 40, padding: '0 16px', borderRadius: 20,
    background: 'transparent', border: `1px solid ${C.outline}`,
    color: C.textMuted, fontFamily: MONO,
    fontSize: 10, letterSpacing: '0.22em', fontWeight: 700, cursor: 'pointer',
    textTransform: 'uppercase',
  };
}
function btnDangerStyle(C) {
  return {
    height: 40, padding: '0 16px', borderRadius: 20,
    background: 'transparent', border: `1px solid ${C.disabled}`,
    color: C.text, fontFamily: MONO,
    fontSize: 10, letterSpacing: '0.22em', fontWeight: 700, cursor: 'pointer',
    textTransform: 'uppercase',
  };
}
function btnSolidStyle(C) {
  return {
    height: 48, padding: '0 22px', borderRadius: 20,
    background: C.accent, color: C.bg,
    fontFamily: MONO, fontSize: 11,
    letterSpacing: '0.2em', fontWeight: 700, cursor: 'pointer',
    border: 'none', textTransform: 'uppercase',
  };
}

// ─── UI glyphs (no emoji) ────────────────────────────────────────────────
function LockGlyph({ size = 11, color }) {
  const COLORS = useC();
  const c = color || COLORS.lockedText || COLORS.textDim;
  return (
    <svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden>
      <path d="M6 1.5L9.5 6H2.5L6 1.5Z" stroke={c} strokeWidth="1" />
      <rect x="2" y="6" width="8" height="5" rx="0.5" stroke={c} strokeWidth="1" />
    </svg>
  );
}
function DoneDot({ size = 8, color }) {
  const COLORS = useC();
  const c = color || COLORS.outlineActive;
  return (
    <svg width={size} height={size} viewBox="0 0 8 8" fill="none" aria-hidden>
      <rect x="4" y="0.5" width="5" height="5" transform="rotate(45 4 0.5)" fill={c} opacity="0.9" />
    </svg>
  );
}

function MicrocopyToast({ message }) {
  const COLORS = useC();
  if (!message) return null;
  return (
    <div style={{
      position: 'absolute', left: 0, right: 0, bottom: 48, pointerEvents: 'none',
      display: 'flex', justifyContent: 'center', zIndex: 8,
    }}>
      <div style={{
        fontFamily: MONO, fontSize: 10, letterSpacing: '0.22em', textTransform: 'uppercase',
        color: COLORS.textMuted, padding: '8px 14px', borderRadius: 6,
        border: `1px solid ${COLORS.outline}`, background: COLORS.toastBg,
      }}>{message}</div>
    </div>
  );
}

// ─── Tiny haptic helper ──────────────────────────────────────────────────
function haptic(ms = 8) {
  if (navigator.vibrate) {
    try { navigator.vibrate(ms); } catch (e) {}
  }
}

// ─── Easing ──────────────────────────────────────────────────────────────
function easeOutBack(t) {
  const s = 1.70158;
  return 1 + (s + 1) * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2);
}

function easeInOutCubic(t) {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

function vertexPosAt(tiling, ids, vk) {
  const ref = tiling[ids[0]];
  const idx = ref.vertexKeys.indexOf(vk);
  return ref.verts[idx];
}

function findNewlyCompletedFigure(tiling, vertexMap, revealed, prevRevealed, sig) {
  for (const [vk, ids] of vertexMap) {
    if (ids.length < 3) continue;
    if (!ids.every(id => revealed.has(id))) continue;
    if (ids.every(id => prevRevealed.has(id))) continue;
    if (vertexSignatureAt(tiling, ids, vk) !== sig) continue;
    const vPos = vertexPosAt(tiling, ids, vk);
    return { x: vPos.x, y: vPos.y, tileIds: [...ids] };
  }
  return null;
}

// Canvas fill for a rhomb — flat or soft diagonal gradient (Folio theme).
function fillTileShape(ctx, verts, tileType, COLORS, alpha = 1) {
  const thick = tileType === 'thick' || tileType === 'THICK';
  ctx.beginPath();
  verts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
  ctx.closePath();
  if (COLORS.tileGradient && verts.length >= 4) {
    const g = ctx.createLinearGradient(verts[0].x, verts[0].y, verts[2].x, verts[2].y);
    g.addColorStop(0, thick ? COLORS.thickHi : COLORS.thinHi);
    g.addColorStop(0.5, thick ? COLORS.thick : COLORS.thin);
    g.addColorStop(1, thick ? COLORS.thickLo : COLORS.thinLo);
    ctx.fillStyle = g;
  } else {
    ctx.fillStyle = thick ? COLORS.thick : COLORS.thin;
  }
  const prev = ctx.globalAlpha;
  ctx.globalAlpha = prev * alpha;
  ctx.fill();
  ctx.globalAlpha = prev;
}

function strokeTileGrout(ctx, verts, COLORS, alpha = 1, width = 1.4) {
  ctx.beginPath();
  verts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
  ctx.closePath();
  ctx.lineJoin = 'round';
  ctx.strokeStyle = COLORS.grout;
  const prev = ctx.globalAlpha;
  ctx.globalAlpha = prev * alpha;
  ctx.lineWidth = width;
  ctx.stroke();
  ctx.globalAlpha = prev;
}

function winSnapshotFromPatch(tiling, targetIds, canvasW, canvasH, vertexPos, zoom) {
  const LE = window.LevelEngine;
  if (!LE || !LE.outerBoundaryLoops || !tiling || !targetIds.length || !vertexPos) return null;
  const w = Math.max(1, canvasW || 1);
  const h = Math.max(1, canvasH || 1);
  const VIS_R = 4.5 / (zoom || 1);
  const scale = Math.min(w, h) / (2 * VIS_R);
  const ox = w / 2 - vertexPos.x * scale;
  const oy = h / 2 + vertexPos.y * scale;
  const polys = [];
  for (const id of targetIds) {
    const tile = tiling[id];
    if (!tile) continue;
    polys.push(tile.verts.map(v => ({ x: ox + v.x * scale, y: oy - v.y * scale })));
  }
  if (!polys.length) return null;
  const loops = LE.outerBoundaryLoops(polys);
  if (!loops.length) return null;
  const { cx, cy } = LE.loopsCentroid(loops);
  const d = LE.loopsToSvgD(loops);
  if (!d) return null;
  return { d, cx, cy, width: w, height: h };
}

// ─── DynamicLogo — animated Penrose patch that grows & resets in a loop ──
function DynamicLogo({ size = 120, scale = 16, glow = true, paused = false, animate = true }) {
  const COLORS = useC();
  const canvasRef = useRef(null);
  const colorsRef = useRef(COLORS);
  colorsRef.current = COLORS;
  const pausedRef = useRef(paused);
  pausedRef.current = paused;
  const resumeRef = useRef(() => {});

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !window.PenroseGeo) return;
    canvas.width = size * 2; canvas.height = size * 2;
    canvas.style.width = size + 'px'; canvas.style.height = size + 'px';
    const ctx = canvas.getContext('2d');
    ctx.scale(2, 2);
    const { generateTiling, findCenterTile } = window.PenroseGeo;

    let tiles, revealed, revealedAt, queue, queueSet;
    let phase = 'grow', phaseStart = 0, lastReveal = 0, lastFrame = 0;
    let raf;

    function newTiling() {
      const pattern = 0.15 + Math.random() * 0.7;
      tiles = generateTiling({ radius: 10, pattern });
      if (!tiles || tiles.length === 0) return;
      const cIdx = findCenterTile(tiles);
      if (cIdx < 0) return;
      revealed = new Set([cIdx]);
      revealedAt = new Map([[cIdx, performance.now()]]);
      queue = [];
      queueSet = new Set();
      for (const n of tiles[cIdx].neighbors) { queue.push(n); queueSet.add(n); }
      phase = 'grow';
      phaseStart = performance.now();
      lastReveal = performance.now();
    }

    function drawFrame(now) {
      const C = colorsRef.current;
      if (!tiles || tiles.length === 0) return;
      ctx.clearRect(0, 0, size, size);
      const cx = size / 2, cy = size / 2;
      const toScreen = (x, y) => [cx + x * scale, cy - y * scale];
      const fade = phase === 'fade' ? Math.max(0, 1 - (now - phaseStart) / 500) : 1;

      for (const id of revealed) {
        const t = tiles[id]; if (!t) continue;
        const age = animate ? (now - (revealedAt.get(id) || 0)) / 320 : 1;
        const grow = age >= 1 ? 1 : easeOutBack(Math.min(1, Math.max(0, age)));
        const tcx = t.mean.x, tcy = t.mean.y;
        const logoVerts = t.verts.map((v) => {
          const [sx, sy] = toScreen(tcx + (v.x - tcx) * grow, tcy + (v.y - tcy) * grow);
          return { x: sx, y: sy };
        });
        const thick = t.type === 'thick';
        fillTileShape(ctx, logoVerts, t.type, C, 0.9 * fade);
        if (!C.tileGradient) {
          ctx.beginPath();
          logoVerts.forEach((v, i) => (i === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
          ctx.closePath();
          ctx.lineWidth = 0.8;
          ctx.strokeStyle = thick ? C.thickEdge : C.thinEdge;
          ctx.globalAlpha = 0.55 * fade;
          ctx.stroke();
        } else {
          strokeTileGrout(ctx, logoVerts, C, 0.55 * fade, 0.8);
        }
      }
      ctx.globalAlpha = 1;
    }

    function tick(now) {
      if (pausedRef.current) return;
      if (animate && now - lastFrame < 32) { raf = requestAnimationFrame(tick); return; }
      lastFrame = now;

      if (animate) {
        if (!tiles || tiles.length === 0) { raf = requestAnimationFrame(tick); return; }
        if (phase === 'grow') {
          if (now - lastReveal > 130 && queue.length > 0) {
            const id = queue.shift(); queueSet.delete(id);
            if (!revealed.has(id)) {
              revealed.add(id); revealedAt.set(id, now);
              for (const n of tiles[id].neighbors) {
                if (!revealed.has(n) && !queueSet.has(n)) { queue.push(n); queueSet.add(n); }
              }
            }
            lastReveal = now;
          }
          if (revealed.size >= 28 && queue.length === 0) { phase = 'wait'; phaseStart = now; }
        } else if (phase === 'wait') {
          if (now - phaseStart > 1100) { phase = 'fade'; phaseStart = now; }
        } else if (phase === 'fade') {
          if (now - phaseStart > 500) { newTiling(); }
        }
      }

      drawFrame(now);
      raf = requestAnimationFrame(tick);
    }

    function start() {
      if (raf) cancelAnimationFrame(raf);
      if (!animate) { drawFrame(performance.now()); return; }
      if (pausedRef.current) return;
      raf = requestAnimationFrame(tick);
    }

    resumeRef.current = start;
    newTiling();
    start();

    return () => cancelAnimationFrame(raf);
  }, [size, scale, animate]);

  useEffect(() => {
    pausedRef.current = paused;
    if (!paused && animate) resumeRef.current();
  }, [paused, animate]);

  return (
    <canvas ref={canvasRef}
      style={{
        width: size, height: size, display: 'block',
        filter: glow ? `drop-shadow(0 0 12px ${COLORS.glow})` : 'none',
        willChange: animate && !paused ? 'contents' : 'auto',
      }} />
  );
}

// ─── Top bar + rhomb back ─────────────────────────────────────────────────
const FLUX_DESCRIPTORS = [
  { article: 'an', word: 'aperiodic' },
  { article: 'a', word: 'non-repeating' },
  { article: 'an', word: 'unrepeatable' },
  { article: 'a', word: 'singular' },
  { article: 'a', word: 'unique' },
  { article: 'an', word: 'ever-changing' },
  { article: 'a', word: 'self-similar' },
  { article: 'an', word: 'infinite' },
];

function RhombBackIcon({ color, size = 20 }) {
  const tipX = 7;
  const tipY = 12;
  const arm = 11;
  const half = 36 * Math.PI / 180;
  const x2 = tipX + arm * Math.cos(half);
  const yTop = tipY - arm * Math.sin(half);
  const yBot = tipY + arm * Math.sin(half);
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path
        d={`M ${x2.toFixed(2)} ${yTop.toFixed(2)} L ${tipX} ${tipY} L ${x2.toFixed(2)} ${yBot.toFixed(2)}`}
        stroke={color}
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="miter"
      />
    </svg>
  );
}

function TopBar({ title, onMenu, onBack, right }) {
  const COLORS = useC();
  const stroke = COLORS.text;
  return (
    <div style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
      padding: '62px 16px 10px', position: 'relative', zIndex: 5,
    }}>
      <div style={{ width: 36, display: 'flex', justifyContent: 'flex-start' }}>
        {onBack ? (
          <button onClick={onBack} style={iconBtnStyle(COLORS)} aria-label="Back">
            <RhombBackIcon color={stroke} />
          </button>
        ) : <div style={{ width: 36 }} />}
      </div>
      <div style={{
        fontFamily: SERIF, fontSize: 18,
        letterSpacing: '-0.01em', fontWeight: 600, color: COLORS.text,
      }}>{title}</div>
      <div style={{ width: 36, display: 'flex', justifyContent: 'flex-end' }}>
        {right || (onMenu ? (
          <button onClick={onMenu} style={iconBtnStyle(COLORS)} aria-label="Menu">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke={stroke} strokeWidth="2" strokeLinecap="round"/></svg>
          </button>
        ) : <div style={{ width: 36 }} />)}
      </div>
    </div>
  );
}

function HomeFluxTagline() {
  const COLORS = useC();
  const [index, setIndex] = useState(2);
  const [out, setOut] = useState(false);

  useEffect(() => {
    if (REDUCED_MOTION) return undefined;
    const tick = setInterval(() => setOut(true), 3400);
    return () => clearInterval(tick);
  }, []);

  useEffect(() => {
    if (!out || REDUCED_MOTION) return undefined;
    const t = setTimeout(() => {
      setIndex((i) => (i + 1) % FLUX_DESCRIPTORS.length);
      setOut(false);
    }, 480);
    return () => clearTimeout(t);
  }, [out]);

  const { article, word } = FLUX_DESCRIPTORS[index];

  return (
    <div style={{
      fontFamily: MONO, fontSize: 11, letterSpacing: '0.2em', color: COLORS.textMuted,
      marginTop: 14, textAlign: 'center', lineHeight: 1.65,
    }}>
      <div>Elegant discovery in</div>
      <div style={{ marginTop: 6 }}>
        <span>{article} </span>
        <span style={{
          fontStyle: 'italic',
          fontFamily: SERIF,
          fontSize: 12,
          letterSpacing: '0.05em',
          color: COLORS.accent,
          display: 'inline-block',
          transition: REDUCED_MOTION ? 'none' : 'opacity 0.5s ease, transform 0.5s ease',
          opacity: out ? 0 : 1,
          transform: out ? 'translateY(-0.28em)' : 'none',
        }}>{word}</span>
        <span> mosaic.</span>
      </div>
    </div>
  );
}

// ─── Colour mood picker (menu) ───────────────────────────────────────────
function MoodPicker({ palettes, themeId, onPick }) {
  const COLORS = useC();
  if (!palettes.length) return null;
  const cols = palettes.length;
  return (
    <div>
      <div style={{
        fontFamily: MONO, fontSize: 10, letterSpacing: '0.22em',
        color: COLORS.textDim, textTransform: 'uppercase', marginBottom: 10,
      }}>Colour mood</div>
      <div style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
        gap: 8,
      }}>
        {palettes.map((t) => {
          const active = t.id === themeId;
          const lines = t.name.toUpperCase().split(/\s+/);
          return (
            <button
              key={t.id}
              type="button"
              onClick={() => onPick(t.id)}
              title={t.mood}
              aria-pressed={active}
              style={{
                minWidth: 0,
                display: 'flex',
                flexDirection: 'column',
                alignItems: 'center',
                gap: 8,
                padding: '11px 6px 10px',
                borderRadius: 12,
                border: `1px solid ${active ? COLORS.outlineActive : COLORS.outline}`,
                background: active ? COLORS.surfaceElevated : COLORS.surface,
                cursor: 'pointer',
                transition: 'border-color 180ms, background 180ms',
                boxShadow: 'none',
              }}
            >
              <div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
                <span style={{
                  width: 13, height: 13, borderRadius: '50%', background: t.thick,
                  boxShadow: `inset 0 0 0 1px ${COLORS.outline}`,
                }} />
                <span style={{
                  width: 13, height: 13, borderRadius: '50%', background: t.thin,
                  boxShadow: `inset 0 0 0 1px ${COLORS.outline}`,
                }} />
              </div>
              <span style={{
                fontFamily: MONO,
                fontSize: 8,
                fontWeight: active ? 700 : 500,
                letterSpacing: '0.14em',
                lineHeight: 1.35,
                textAlign: 'center',
                color: active ? COLORS.text : COLORS.textDim,
                textTransform: 'uppercase',
                display: 'block',
                width: '100%',
              }}>
                {lines.map((line, i) => (
                  <span key={line + i} style={{ display: 'block' }}>{line}</span>
                ))}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Side menu ────────────────────────────────────────────────────────────
function SideMenu({ open, onClose, goto, themeId, onPickTheme, premium }) {
  const COLORS = useC();
  if (!open) return null;
  const items = [
    { id: 'home',     label: 'Home' },
    { id: 'daily',    label: 'Mosaic of the Day' },
    { id: 'levelmap', label: 'Levels' },
    { id: 'about',    label: 'About' },
    ...(premium ? [] : [{ id: 'paywall', label: 'Tessra Complete' }]),
  ];
  const palettes = window.TessraThemes.showPicker ? window.TessraThemes.list : [];
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 70,
      background: COLORS.overlayBg,
      display: 'flex', flexDirection: 'column', padding: '98px 24px 24px',
    }} onClick={onClose}>
      {/* Close sits exactly where the TopBar menu button lives (62/16, 36px). */}
      <button
        onClick={onClose}
        style={{ ...iconBtnStyle(COLORS), position: 'absolute', top: 62, right: 16 }}
        aria-label="Close"
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6l-12 12" stroke={COLORS.text} strokeWidth="2" strokeLinecap="round"/></svg>
      </button>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 14 }}>
        {items.map(it => (
          <div key={it.id}
            onClick={(e) => { e.stopPropagation(); goto(it.id); }}
            style={{
              fontFamily: 'JetBrains Mono, monospace', fontSize: 22, fontWeight: 700,
              letterSpacing: '0.1em', textTransform: 'uppercase',
              color: COLORS.text, cursor: 'pointer', padding: '8px 0',
            }}>
            {it.label}
          </div>
        ))}
      </div>
      {palettes.length > 1 && (
        <div style={{ marginBottom: 20 }} onClick={(e) => e.stopPropagation()}>
          <MoodPicker palettes={palettes} themeId={themeId} onPick={onPickTheme} />
        </div>
      )}
      <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12 }}>
        <DynamicLogo size={56} scale={7} glow={false} animate={false} />
        <BnbStudioLogo height={26} style={{ paddingBottom: 4 }} />
      </div>
    </div>
  );
}

// ─── Splash loader ───────────────────────────────────────────────────────
function LetterFadeTitle({ text, startDelay, letterDelay }) {
  const [visible, setVisible] = useState(0);
  const reduced = REDUCED_MOTION;

  useEffect(() => {
    if (reduced) {
      const t = setTimeout(() => setVisible(text.length), Math.min(startDelay, 320));
      return () => clearTimeout(t);
    }
    let interval;
    const start = setTimeout(() => {
      let i = 0;
      interval = setInterval(() => {
        i += 1;
        setVisible(i);
        if (i >= text.length) clearInterval(interval);
      }, letterDelay);
    }, startDelay);
    return () => { clearTimeout(start); if (interval) clearInterval(interval); };
  }, [text, startDelay, letterDelay, reduced]);

  return (
    <>
      {text.split('').map((ch, i) => (
        <span
          key={i + ch}
          style={{
            display: 'inline-block',
            opacity: i < visible ? 1 : 0,
            transform: i < visible ? 'translateY(0)' : 'translateY(6px)',
            transition: reduced
              ? 'opacity 280ms ease'
              : 'opacity 520ms ease, transform 520ms cubic-bezier(0.22, 0.61, 0.36, 1)',
          }}
        >{ch}</span>
      ))}
    </>
  );
}

function SplashScreen({ onDone }) {
  const COLORS = useC();
  const word = 'Tessra';
  const startDelay = REDUCED_MOTION ? 400 : 2000;
  const letterDelay = REDUCED_MOTION ? 0 : 95;
  const holdAfter = REDUCED_MOTION ? 400 : 520;
  const totalMs = startDelay + (REDUCED_MOTION ? 0 : word.length * letterDelay) + holdAfter + 280;

  useEffect(() => {
    const t = setTimeout(onDone, totalMs);
    return () => clearTimeout(t);
  }, [onDone, totalMs]);

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      alignItems: 'center', justifyContent: 'center',
      background: `radial-gradient(ellipse at 50% 38%, ${COLORS.bgPanel}, ${COLORS.bg} 72%)`,
    }}>
      <div style={{ animation: REDUCED_MOTION ? 'none' : 'tessera-float 3.4s ease-in-out infinite' }}>
        <DynamicLogo size={132} scale={17} glow />
      </div>
      <div style={{
        marginTop: 40,
        fontFamily: SERIF,
        fontSize: 46,
        fontWeight: 600,
        letterSpacing: '-0.02em',
        color: COLORS.text,
        textShadow: `0 0 40px ${COLORS.outlineActive}33`,
        minHeight: 56,
      }}>
        <LetterFadeTitle text={word} startDelay={startDelay} letterDelay={letterDelay} />
      </div>
    </div>
  );
}

function homeScreenBackground(COLORS) {
  return [
    `radial-gradient(ellipse 130% 92% at 50% -6%, ${COLORS.accent}88 0%, ${COLORS.accent}44 22%, transparent 56%)`,
    `radial-gradient(ellipse 100% 72% at 92% 104%, ${COLORS.accent2}5C 0%, transparent 52%)`,
    `radial-gradient(ellipse 78% 58% at 8% 76%, ${COLORS.accent}38 0%, transparent 48%)`,
    `linear-gradient(180deg, ${COLORS.bgPanel}, ${COLORS.bg} 78%)`,
  ].join(', ');
}

// ─── HOME ─────────────────────────────────────────────────────────────────
function HomeScreen({ goto, progress, daily, logoPaused }) {
  const COLORS = useC();
  const completed = Math.min(progress.unlocked - 1, HL.TOTAL);
  const unlocked = Math.min(progress.unlocked, HL.TOTAL);
  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: homeScreenBackground(COLORS),
    }}>
      <TopBar title="Tessra" onMenu={() => goto('menu')} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 22px 34px', gap: 20 }}>
        <div style={{ marginBottom: 8, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
          <div style={{ animation: logoPaused || REDUCED_MOTION ? 'none' : 'tessera-float 3.2s ease-in-out infinite' }}>
            <DynamicLogo size={120} scale={16} paused={logoPaused} />
          </div>
          <div style={{
            fontFamily: SERIF, fontSize: 44, fontWeight: 600,
            letterSpacing: '-0.01em', marginTop: 36, color: COLORS.text,
            textShadow: `0 0 36px ${COLORS.outlineActive}44`,
          }}>Tessra</div>
          <HomeFluxTagline />
        </div>

        <Card onClick={() => goto('daily')} accent={COLORS.accent}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <BigTitle color={COLORS.accent}>Mosaic of the Day</BigTitle>
              <Caption style={{ marginTop: 8 }}>{daily.date}</Caption>
              <Caption style={{ color: COLORS.textDim, marginTop: 4 }}>SEED · {daily.patternId}</Caption>
            </div>
            <MiniRhomb type="THICK" color={COLORS.accent} outline />
          </div>
          <div style={{ marginTop: 18, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 }}>
            One shared tiling every UTC day. Tap outward — watch the hidden order emerge.
          </div>
        </Card>

        <Card onClick={() => goto('levelmap')} accent={COLORS.accent2}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <BigTitle color={COLORS.accent2}>Levels</BigTitle>
              <Caption style={{ marginTop: 8 }}>{unlocked}/{HL.TOTAL}</Caption>
              <Caption style={{ color: COLORS.textDim, marginTop: 4 }}>
                {completed >= HL.TOTAL ? 'COMPLETED' : (completed > 0 ? 'CONTINUE' : 'BEGIN')}
              </Caption>
            </div>
            <MiniRhomb type="THIN" color={COLORS.accent2} outline />
          </div>
          <div style={{ marginTop: 18, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 }}>
            Find the hidden figure and build it. Twenty gentle lessons, then the long mosaic.
          </div>
        </Card>
      </div>
    </div>
  );
}

function Card({ children, onClick, accent }) {
  const COLORS = useC();
  return (
    <div onClick={onClick} style={{
      padding: '18px 18px 16px',
      borderRadius: 14,
      background: COLORS.surface,
      border: `1px solid ${accent ? COLORS.outlineActive + '66' : COLORS.outline}`,
      cursor: 'pointer', position: 'relative', overflow: 'hidden',
      transition: 'transform 0.12s ease, border-color 0.2s ease',
    }}
    onPointerDown={(e) => e.currentTarget.style.transform = 'scale(0.985)'}
    onPointerUp={(e) => e.currentTarget.style.transform = ''}
    onPointerLeave={(e) => e.currentTarget.style.transform = ''}>
      {accent && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: accent, opacity: 0.65 }} />}
      {children}
    </div>
  );
}

function Caption({ children, style }) {
  const COLORS = useC();
  return <div style={{ fontFamily: MONO, fontSize: 10, letterSpacing: '0.22em', fontWeight: 500, color: COLORS.textMuted, textTransform: 'uppercase', ...style }}>{children}</div>;
}
function BigTitle({ children, color }) {
  const COLORS = useC();
  return <div style={{ fontFamily: SERIF, fontSize: 30, fontWeight: 600, marginTop: 6, letterSpacing: '-0.01em', color: color || COLORS.text }}>{children}</div>;
}

function MiniRhomb({ type, color, outline = false }) {
  const R = window.LevelEngine.RHOMB[type];
  const long = R.long, short = R.short;
  const verts = [
    { x: long, y: 0 }, { x: 0, y: short }, { x: -long, y: 0 }, { x: 0, y: -short },
  ];
  const path = verts.map((v, i) => (i === 0 ? 'M' : 'L') + (24 + v.x * 20) + ' ' + (24 - v.y * 20)).join(' ') + ' Z';
  return (
    <svg width="48" height="48" viewBox="0 0 48 48">
      <path
        d={path}
        fill={outline ? 'none' : color}
        fillOpacity={outline ? 0 : 0.18}
        stroke={color}
        strokeWidth={outline ? 1.65 : 1.5}
        opacity={outline ? 0.9 : 1}
      />
    </svg>
  );
}

// ─── DAILY ────────────────────────────────────────────────────────────────
// Canonical figure signatures detected in the Daily mosaic.
const DAILY_FIGURES = [
  { key: 'sun',   label: 'Sun',   sig: 'Ta,Ta,Ta,Ta,Ta' },
  { key: 'star',  label: 'Star',  sig: 'ta,ta,ta,ta,ta,ta,ta,ta,ta,ta' },
  { key: 'king',  label: 'King',  sig: 'Ta,Ta,Ta,ta,ta,ta,ta' },
  { key: 'jack',  label: 'Jack',  sig: 'Ta,Ta,Ta,Ta,ta,ta' },
  { key: 'queen', label: 'Queen', sig: 'Ta,To,To,ta,ta' },
  { key: 'ace',   label: 'Ace',   sig: 'Ta,To,ta,to' },
  { key: 'deuce', label: 'Deuce', sig: 'Ta,to,to' },
];

// How many of the focus figure a fresh day asks you to assemble.
// Rarer figures (sun/star) need fewer; common ones (ace/deuce) need a few more.
const DAILY_GOAL_NEED = { sun: 1, star: 1, king: 2, jack: 2, queen: 2, ace: 3, deuce: 3 };

// Per-day accent tints derived from the active palette — stays on-theme in Lagoon, Quartz, etc.
function dailyTintsForTheme(C) {
  return [C.outlineActive, C.accent, C.accent2, C.goldA, C.thick, C.thin, C.goldB];
}

// Deterministic daily goal — must be achievable in that day's mosaic patch.
function countDailyFigureInstances(tiles, sig) {
  if (!tiles || !window.TessraFigureFinder) return 0;
  return window.TessraFigureFinder.findVerticesBySignature(tiles, sig).length;
}

function dailyGoal(seed, C, tiles) {
  const s = (seed >>> 0);
  const palette = dailyTintsForTheme(C);
  const tint = palette[(s >>> 3) % palette.length];
  const start = s % DAILY_FIGURES.length;

  for (let i = 0; i < DAILY_FIGURES.length; i++) {
    const fig = DAILY_FIGURES[(start + i) % DAILY_FIGURES.length];
    const want = DAILY_GOAL_NEED[fig.key] || 2;
    if (!tiles) return { ...fig, need: want, tint };
    const available = countDailyFigureInstances(tiles, fig.sig);
    if (available >= want) return { ...fig, need: want, tint, available };
    if (available >= 1) return { ...fig, need: available, tint, available };
  }

  const fallback = DAILY_FIGURES[start];
  return { ...fallback, need: 1, tint, available: 0 };
}

function dailyFigureLabel(daily) {
  const label = (daily && daily.label) ? daily.label : 'Today';
  return label.toUpperCase() + "'S FIGURE";
}

function DailyScreen({ goto, daily, dailyOptions, dailyIndex, onSelectDaily, dailyByDate = {}, dailyState, updateDailyState }) {
  const COLORS = useC();
  const canvasRef = useRef(null);
  const [tiling, setTiling] = useState(null);
  const [centerId, setCenterId] = useState(-1);
  const [revealed, setRevealed] = useState(() => new Set(dailyState.revealed || []));
  const animRef = useRef(new Map()); // tileId → animation start time
  const ripplesRef = useRef([]); // [{x, y, start}] in world coords
  const figurePulsesRef = useRef([]); // [{x, y, start, tint, tileIds}]
  const prevRevealedRef = useRef(new Set());
  const introRef = useRef(null);   // { start } while the zoom-in intro is playing
  // Latest persisted state, readable without re-running the tiling effect —
  // regenerating on every reveal used to wipe the tile pop-in animation.
  const dailyStateRef = useRef(dailyState);
  dailyStateRef.current = dailyState;
  const updateDailyStateRef = useRef(updateDailyState);
  updateDailyStateRef.current = updateDailyState;

  // Figure of the day + accent tint — deterministic from the daily seed.
  const goal = useMemo(
    () => dailyGoal(daily.seed, COLORS, tiling),
    [daily.seed, COLORS.id, tiling],
  );

  // Vertex → list of tile ids that share it (computed once when tiling loads).
  const vertexMap = useMemo(() => {
    if (!tiling) return null;
    const m = new Map();
    tiling.forEach((t, i) => t.vertexKeys.forEach(vk => {
      if (!m.has(vk)) m.set(vk, []);
      m.get(vk).push(i);
    }));
    return m;
  }, [tiling]);

  // Count canonical figures that are FULLY enclosed by revealed tiles.
  const figureCounts = useMemo(() => {
    const counts = Object.fromEntries(DAILY_FIGURES.map(f => [f.key, 0]));
    if (!tiling || !vertexMap) return counts;
    for (const [vk, ids] of vertexMap) {
      if (ids.length < 3) continue;
      if (!ids.every(id => revealed.has(id))) continue;
      const sig = vertexSignatureAt(tiling, ids, vk);
      for (const f of DAILY_FIGURES) {
        if (sig === f.sig) { counts[f.key]++; break; }
      }
    }
    return counts;
  }, [tiling, vertexMap, revealed]);

  // Detect newly-completed figure for a brief celebrate animation.
  const prevCountsRef = useRef(figureCounts);
  const [celebrating, setCelebrating] = useState(null);
  useEffect(() => {
    const prev = prevCountsRef.current;
    const prevRev = prevRevealedRef.current;
    for (const f of DAILY_FIGURES) {
      if (figureCounts[f.key] > (prev[f.key] || 0)) {
        const found = tiling && vertexMap
          ? findNewlyCompletedFigure(tiling, vertexMap, revealed, prevRev, f.sig)
          : null;
        const tint = f.key === goal.key ? goal.tint : COLORS.accent;
        if (found) {
          figurePulsesRef.current.push({
            start: performance.now(),
            x: found.x,
            y: found.y,
            tint,
            tileIds: found.tileIds,
          });
        }
        setCelebrating({ key: f.key, at: performance.now() });
        haptic(15);
        break;
      }
    }
    prevCountsRef.current = figureCounts;
    prevRevealedRef.current = new Set(revealed);
  }, [figureCounts, tiling, vertexMap, revealed, goal.key, goal.tint, COLORS.accent]);
  useEffect(() => {
    if (!celebrating) return;
    const id = setTimeout(() => setCelebrating(null), 1400);
    return () => clearTimeout(id);
  }, [celebrating]);

  const dailyRevealCount = useCallback((date) => {
    const saved = dailyByDate[date] && dailyByDate[date].revealed;
    if (Array.isArray(saved) && saved.length > 0) return saved.length;
    if (date === daily.date) return revealed.size;
    return 0;
  }, [dailyByDate, daily.date, revealed.size]);

  // Generate tiling for the selected daily seed. Runs only when the day
  // changes — NOT on every reveal — so per-tile pop-in animations survive.
  useEffect(() => {
    const tiles = window.PenroseGeo.generateTiling({ radius: 18, pattern: daily.pattern });
    const c = window.PenroseGeo.findCenterTile(tiles);
    const savedRaw = dailyStateRef.current && dailyStateRef.current.revealed;
    const saved = Array.isArray(savedRaw)
      ? savedRaw.filter(id => tiles[id])
      : [];
    setTiling(tiles);
    setCenterId(c);
    animRef.current.clear();
    ripplesRef.current = [];
    figurePulsesRef.current = [];
    setCelebrating(null);
    prevRevealedRef.current = new Set();
    prevCountsRef.current = Object.fromEntries(DAILY_FIGURES.map(f => [f.key, 0]));
    if (saved.length > 0) {
      setRevealed(new Set(saved));
      prevRevealedRef.current = new Set(saved);
      introRef.current = null;
    } else {
      const initial = new Set([c]);
      setRevealed(initial);
      prevRevealedRef.current = new Set(initial);
      updateDailyStateRef.current({ revealed: [c] });
      // Fresh day → play the zoom-out → zoom-in intro once.
      introRef.current = REDUCED_MOTION ? null : { start: performance.now(), date: daily.date };
    }
  }, [daily.date, daily.pattern, daily.patternId]);

  // Frontier: tiles adjacent to a revealed tile but not yet revealed.
  const frontier = useMemo(() => {
    if (!tiling) return new Set();
    const f = new Set();
    revealed.forEach(id => {
      tiling[id].neighbors.forEach(n => { if (!revealed.has(n)) f.add(n); });
    });
    return f;
  }, [tiling, revealed]);

  // Bounding box of the whole mosaic — the wide camera target for the intro.
  const allBBox = useMemo(() => {
    if (!tiling) return null;
    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
    tiling.forEach(t => t.verts.forEach(v => {
      if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
      if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
    }));
    return { minX, maxX, minY, maxY };
  }, [tiling]);

  // Draw — continuous RAF so tile / figure animations always paint.
  useEffect(() => {
    if (!tiling) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    let alive = true;
    let raf;

    function frame() {
      if (!alive) return;
      const dpr = window.devicePixelRatio || 1;
      const rect = canvas.getBoundingClientRect();
      const pw = Math.round(rect.width * dpr);
      const ph = Math.round(rect.height * dpr);
      if (canvas.width !== pw || canvas.height !== ph) {
        canvas.width = pw;
        canvas.height = ph;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
      const w = rect.width, h = rect.height;
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = COLORS.bg;
      ctx.fillRect(0, 0, w, h);

      const now = performance.now();

      // Close-up fit around revealed + frontier (the play view).
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      const tilesShown = new Set([...revealed, ...frontier]);
      tilesShown.forEach(id => {
        tiling[id].verts.forEach(v => {
          if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
          if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
        });
      });
      const pad = 1.5;
      const bw = (maxX - minX) + pad * 2, bh = (maxY - minY) + pad * 2;
      let scale = Math.min(w / bw, h / bh) * 0.95;
      let cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;

      // Intro: ease the camera in from a wide view of the whole mosaic.
      let introT = 1;
      if (introRef.current && allBBox) {
        const dt = (now - introRef.current.start) / 2600;
        if (dt >= 1) {
          introRef.current = null;
        } else {
          introT = easeInOutCubic(Math.max(0, dt));
          const wpad = 1.0;
          const bwA = (allBBox.maxX - allBBox.minX) + wpad * 2;
          const bhA = (allBBox.maxY - allBBox.minY) + wpad * 2;
          const wScale = Math.min(w / bwA, h / bhA) * 0.9;
          const wcx = (allBBox.minX + allBBox.maxX) / 2;
          const wcy = (allBBox.minY + allBBox.maxY) / 2;
          scale = wScale + (scale - wScale) * introT;
          cx = wcx + (cx - wcx) * introT;
          cy = wcy + (cy - wcy) * introT;
        }
      }
      const ox = w / 2 - cx * scale, oy = h / 2 + cy * scale;
      const project = (p) => ({ x: ox + p.x * scale, y: oy - p.y * scale });

      // Intro field: the whole mosaic, fading out as the camera zooms in.
      if (introT < 1) {
        const fa = 1 - introT;
        for (let i = 0; i < tiling.length; i++) {
          const t = tiling[i];
          ctx.beginPath();
          t.verts.forEach((v, j) => {
            const p = project(v);
            if (j === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
          });
          ctx.closePath();
          const introVerts = t.verts.map((v) => project(v));
          fillTileShape(ctx, introVerts, t.type, COLORS, 0.5 * fa);
          strokeTileGrout(ctx, introVerts, COLORS, 0.7 * fa, 1);
        }
        ctx.globalAlpha = 1;
      }

      // Draw frontier (pulsing outline) BEHIND revealed tiles.
      const pulse = REDUCED_MOTION ? 0.65 : (0.5 + 0.5 * Math.sin(now / 520));
      frontier.forEach(id => {
        const t = tiling[id];
        ctx.beginPath();
        t.verts.forEach((v, i) => {
          const p = project(v);
          if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
        });
        ctx.closePath();
        ctx.fillStyle = COLORS.tappable;
        ctx.globalAlpha = 0.18 + 0.12 * pulse;
        ctx.fill();
        ctx.globalAlpha = 1;
        ctx.strokeStyle = COLORS.tappableEdge;
        ctx.setLineDash([4, 4]);
        ctx.lineWidth = 1;
        ctx.globalAlpha = 0.5 + 0.35 * pulse;
        ctx.stroke();
        ctx.globalAlpha = 1;
        ctx.setLineDash([]);
      });

      // Draw revealed tiles with easeOutBack pop-in.
      revealed.forEach(id => {
        const t = tiling[id];
        const anim = animRef.current.get(id);
        let s = 1;
        let popT = 1;
        if (anim) {
          const dt = (now - anim) / 720;
          popT = Math.min(1, Math.max(0, dt));
          if (dt < 1) {
            s = easeOutBack(popT);
          } else {
            animRef.current.delete(id);
          }
        }
        const tcx = t.verts.reduce((a, v) => a + v.x, 0) / 4;
        const tcy = t.verts.reduce((a, v) => a + v.y, 0) / 4;
        const tileVerts = t.verts.map((v) => {
          const sx = ox + (tcx + (v.x - tcx) * s) * scale;
          const sy = oy - (tcy + (v.y - tcy) * s) * scale;
          return { x: sx, y: sy };
        });
        const tileAlpha = 0.92 * Math.min(1, Math.max(0, s));
        fillTileShape(ctx, tileVerts, t.type, COLORS, tileAlpha);
        strokeTileGrout(ctx, tileVerts, COLORS, 0.9 * Math.min(1, Math.max(0, s)));
        if (anim && popT < 0.72 && !REDUCED_MOTION) {
          const flash = 1 - popT / 0.72;
          ctx.beginPath();
          tileVerts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
          ctx.closePath();
          ctx.fillStyle = goal.tint;
          ctx.globalAlpha = flash * 0.5;
          ctx.fill();
          ctx.strokeStyle = goal.tint;
          ctx.lineWidth = 2.4;
          ctx.globalAlpha = flash * 0.78;
          ctx.stroke();
          ctx.globalAlpha = 1;
        }
      });

      // Soft warm glow around revealed cluster.
      if (revealed.size > 0 && !REDUCED_MOTION) {
        let gx = 0, gy = 0;
        revealed.forEach(id => {
          const t = tiling[id];
          gx += t.verts.reduce((a, v) => a + v.x, 0) / 4;
          gy += t.verts.reduce((a, v) => a + v.y, 0) / 4;
        });
        gx /= revealed.size; gy /= revealed.size;
        const gp = project({ x: gx, y: gy });
        const breathe = 0.12 + 0.06 * Math.sin(now / 1400);
        const gr = scale * 2.8;
        const grad = ctx.createRadialGradient(gp.x, gp.y, 0, gp.x, gp.y, gr);
        grad.addColorStop(0, goal.tint + '44');
        grad.addColorStop(1, 'transparent');
        ctx.globalCompositeOperation = 'lighter';
        ctx.fillStyle = grad;
        ctx.globalAlpha = breathe;
        ctx.beginPath();
        ctx.arc(gp.x, gp.y, gr, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
      }

      // Draw ripples from new tiles.
      for (let i = ripplesRef.current.length - 1; i >= 0; i--) {
        const r = ripplesRef.current[i];
        const dt = (now - r.start) / 980;
        if (dt > 1) { ripplesRef.current.splice(i, 1); continue; }
        const ease = 1 - Math.pow(1 - dt, 3);
        const rad = ease * 11 * scale;
        const sx = ox + r.x * scale, sy = oy - r.y * scale;
        ctx.beginPath();
        ctx.arc(sx, sy, rad, 0, Math.PI * 2);
        ctx.strokeStyle = goal.tint;
        ctx.globalAlpha = (1 - dt) * 0.82;
        ctx.lineWidth = 2.4;
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      // Figure found — soft rings + tile wash at the vertex.
      for (let i = figurePulsesRef.current.length - 1; i >= 0; i--) {
        const fp = figurePulsesRef.current[i];
        const dt = (now - fp.start) / 1400;
        if (dt > 1) { figurePulsesRef.current.splice(i, 1); continue; }
        const fade = 1 - easeInOutCubic(Math.min(1, dt));
        const p = project({ x: fp.x, y: fp.y });
        const ease = 1 - Math.pow(1 - Math.min(1, dt * 1.15), 2);

        if (dt < 0.5) {
          const wash = (1 - dt / 0.5) * 0.48;
          fp.tileIds.forEach((tid) => {
            const tile = tiling[tid];
            if (!tile) return;
            const verts = tile.verts.map((v) => project(v));
            ctx.beginPath();
            verts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
            ctx.closePath();
            ctx.fillStyle = fp.tint;
            ctx.globalAlpha = wash;
            ctx.fill();
            ctx.globalAlpha = 1;
          });
        }

        [2.4, 4.2, 6].forEach((mul, idx) => {
          const rad = ease * scale * mul;
          ctx.beginPath();
          ctx.arc(p.x, p.y, rad, 0, Math.PI * 2);
          ctx.strokeStyle = fp.tint;
          ctx.globalAlpha = fade * (idx === 0 ? 0.62 : idx === 1 ? 0.34 : 0.18);
          ctx.lineWidth = idx === 0 ? 2.2 : idx === 1 ? 1.5 : 1;
          ctx.stroke();
        });
        ctx.globalAlpha = 1;
      }

      raf = requestAnimationFrame(frame);
    }
    frame();
    return () => { alive = false; if (raf) cancelAnimationFrame(raf); };
  }, [tiling, revealed, frontier, COLORS, allBBox, goal.tint]);

  // Tap.
  const onTap = useCallback((e) => {
    if (!tiling) return;
    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    const cx = (e.clientX !== undefined ? e.clientX : e.touches[0].clientX) - rect.left;
    const cy = (e.clientY !== undefined ? e.clientY : e.touches[0].clientY) - rect.top;
    const w = rect.width, h = rect.height;
    // Same fit logic as draw.
    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
    const tilesShown = new Set([...revealed, ...frontier]);
    tilesShown.forEach(id => {
      tiling[id].verts.forEach(v => {
        if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;
        if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;
      });
    });
    const pad = 1.5;
    const bw = (maxX - minX) + pad * 2, bh = (maxY - minY) + pad * 2;
    const scale = Math.min(w / bw, h / bh) * 0.95;
    const ccx = (minX + maxX) / 2, ccy = (minY + maxY) / 2;
    const ox = w / 2 - ccx * scale, oy = h / 2 + ccy * scale;
    const wx = (cx - ox) / scale;
    const wy = (oy - cy) / scale;

    // Hit-test frontier first, then revealed (no-op).
    for (const id of frontier) {
      if (window.PenroseGeo.pointInPoly(wx, wy, tiling[id].verts)) {
        const next = new Set(revealed); next.add(id);
        const now = performance.now();
        animRef.current.set(id, now);
        const tcx = tiling[id].verts.reduce((a, v) => a + v.x, 0) / 4;
        const tcy = tiling[id].verts.reduce((a, v) => a + v.y, 0) / 4;
        ripplesRef.current.push({ x: tcx, y: tcy, start: now });
        setRevealed(next);
        updateDailyState({ revealed: [...next] });
        haptic(10);
        return;
      }
    }
  }, [tiling, revealed, frontier, updateDailyState]);

  const goalProgress = Math.min(figureCounts[goal.key] || 0, goal.need);
  const goalDone = (figureCounts[goal.key] || 0) >= goal.need;

  const renderFigureChip = (f) => {
    const count = figureCounts[f.key];
    const isCelebrating = celebrating && celebrating.key === f.key;
    const active = count > 0;
    const isGoal = f.key === goal.key;
    const borderColor = isCelebrating || active
      ? (isGoal ? goal.tint : COLORS.outlineActive)
      : (isGoal ? `${goal.tint}88` : COLORS.outline);
    return (
      <div key={f.key} style={{
        display: 'flex', alignItems: 'center', gap: 5,
        padding: '5px 9px', borderRadius: 6,
        border: `1px solid ${borderColor}`,
        background: isCelebrating ? `${COLORS.accent}24` : (active ? `${COLORS.outlineActive}14` : (isGoal ? `${goal.tint}0E` : 'transparent')),
        boxShadow: isCelebrating ? `0 0 12px ${COLORS.glow}` : 'none',
        transition: 'border-color 360ms, background 360ms, box-shadow 360ms',
      }}>
        {active && <DoneDot size={6} />}
        <span style={{
          fontSize: 9, letterSpacing: '0.16em',
          color: active ? COLORS.text : (isGoal ? COLORS.textMuted : COLORS.textDim),
          textTransform: 'uppercase',
        }}>{f.label}</span>
        <span style={{
          fontSize: 11, fontWeight: 700,
          color: active ? COLORS.text : COLORS.textDim,
          fontVariantNumeric: 'tabular-nums',
        }}>{count}</span>
      </div>
    );
  };

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar title="Mosaic of the Day" onMenu={() => goto('menu')} onBack={() => goto('home')} />
      <div style={{
        padding: '0 20px 12px',
        display: 'grid',
        gridTemplateColumns: '1fr 1fr 1fr',
        gap: 6,
        fontFamily: MONO,
      }}>
        {dailyOptions.map((item, index) => {
          const active = index === dailyIndex;
          const savedCount = dailyRevealCount(item.date);
          return (
            <button key={item.date} onClick={() => onSelectDaily(index)} style={{
              minWidth: 0,
              height: 46,
              borderRadius: 14,
              border: `1px solid ${active ? COLORS.goldA : COLORS.outline}`,
              background: active ? `${COLORS.goldA}18` : COLORS.lockedSurface,
              color: active ? COLORS.text : COLORS.textDim,
              cursor: 'pointer',
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
              justifyContent: 'center',
              gap: 3,
              padding: '0 4px',
            }}>
              <span style={{
                fontSize: 8.5,
                letterSpacing: '0.12em',
                textTransform: 'uppercase',
                whiteSpace: 'nowrap',
              }}>{item.label}</span>
              <span style={{
                fontSize: 10,
                letterSpacing: '0.06em',
                color: active ? COLORS.goldA : COLORS.textDim,
                fontVariantNumeric: 'tabular-nums',
              }}>{item.date.slice(5)} · {savedCount}</span>
            </button>
          );
        })}
      </div>
      {/* Figure of the day — the goal that makes each day distinct. */}
      <div style={{
        margin: '0 20px 8px', padding: '9px 14px', borderRadius: 14,
        display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
        border: `1px solid ${goalDone ? goal.tint : COLORS.outline}`,
        background: goalDone ? `${goal.tint}1E` : `${goal.tint}10`,
        fontFamily: MONO,
        transition: 'border-color 360ms, background 360ms',
      }}>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 8.5, letterSpacing: '0.2em', color: COLORS.textMuted, textTransform: 'uppercase' }}>
            {dailyFigureLabel(daily)}
          </div>
          <div style={{ marginTop: 3, display: 'flex', alignItems: 'baseline', gap: 8 }}>
            <span style={{ fontFamily: SERIF, fontSize: 19, fontWeight: 600, color: COLORS.accent, letterSpacing: '-0.01em' }}>
              {goal.label}
            </span>
            <span style={{ fontSize: 9.5, letterSpacing: '0.14em', color: COLORS.textDim, textTransform: 'uppercase' }}>
              {goalDone ? 'SOLVED' : `FIND ${goal.need}`}
            </span>
          </div>
        </div>
        <div style={{ display: 'flex', gap: 5, alignItems: 'center', flexShrink: 0 }}>
          {Array.from({ length: goal.need }, (_, i) => (
            <span key={i} style={{
              width: 9, height: 9, borderRadius: '50%',
              background: i < goalProgress ? goal.tint : 'transparent',
              border: `1.5px solid ${i < goalProgress ? goal.tint : COLORS.outline}`,
              boxShadow: goalDone && i < goalProgress ? `0 0 8px ${goal.tint}` : 'none',
              transition: 'background 300ms, box-shadow 300ms',
            }} />
          ))}
        </div>
      </div>
      <div style={{ flex: 1, position: 'relative' }}>
        <canvas ref={canvasRef} onClick={onTap}
          style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%',
            display: 'block', touchAction: 'manipulation',
          }} />
      </div>
      {/* Figure counter — two tidy rows (4 + 3); pulses whichever was just completed. */}
      <div style={{
        padding: '10px 20px 8px', display: 'flex', flexDirection: 'column',
        gap: 6, alignItems: 'center', fontFamily: MONO,
      }}>
        <div style={{ display: 'flex', gap: 6, justifyContent: 'center' }}>
          {DAILY_FIGURES.slice(0, 4).map(renderFigureChip)}
        </div>
        <div style={{ display: 'flex', gap: 6, justifyContent: 'center' }}>
          {DAILY_FIGURES.slice(4).map(renderFigureChip)}
        </div>
      </div>
      <div style={{
        padding: '6px 22px 26px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        fontFamily: MONO,
      }}>
        <div>
          <div style={{ fontSize: 9, letterSpacing: '0.22em', color: COLORS.textMuted, textTransform: 'uppercase' }}>REVEALED</div>
          <div style={{ fontSize: 26, fontWeight: 700, marginTop: 4, color: COLORS.text, fontVariantNumeric: 'tabular-nums' }}>{revealed.size}</div>
        </div>
        <button onClick={() => {
          setRevealed(new Set([centerId]));
          animRef.current.clear();
          ripplesRef.current = [];
          figurePulsesRef.current = [];
          prevRevealedRef.current = new Set([centerId]);
          updateDailyState({ revealed: [centerId] });
        }} style={btnDangerStyle(COLORS)}>RESET</button>
      </div>
    </div>
  );
}

function AtlasMetric({ label, value, color }) {
  const COLORS = useC();
  return (
    <div style={{ minWidth: 0, paddingTop: 2 }}>
      <div style={{
        fontFamily: MONO, fontSize: 9, letterSpacing: '0.18em',
        color: COLORS.textDim, textTransform: 'uppercase',
      }}>{label}</div>
      <div style={{
        marginTop: 5, fontFamily: SERIF, fontSize: 25, fontWeight: 600,
        color: color || COLORS.text, letterSpacing: '-0.01em',
      }}>{value}</div>
    </div>
  );
}

function atlasBranchColor(C, branchKey) {
  if (branchKey === 'gold') return C.goldA;
  if (branchKey === 'accent2') return C.accent2;
  if (branchKey === 'outlineActive') return C.outlineActive;
  return C.accent;
}

function LevelStation({ level, locked, done, current, sectionColor, onOpen, stationRef }) {
  const COLORS = useC();
  const thumbState = locked ? 'locked' : (current ? 'current' : 'done');

  return (
    <div
      ref={stationRef}
      onClick={() => !locked && onOpen(level.id)}
      style={{
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        textAlign: 'center',
        padding: '16px 12px 14px',
        cursor: locked ? 'default' : 'pointer',
      }}>
      <LevelThumb
        levelId={level.id}
        size={58}
        state={thumbState}
        sectionColor={sectionColor}
      />
      <div style={{
        marginTop: 11,
        fontFamily: MONO,
        fontSize: 9,
        letterSpacing: '0.16em',
        color: locked ? COLORS.lockedText : (current ? sectionColor : COLORS.textDim),
        textTransform: 'uppercase',
      }}>
        {String(level.id).padStart(2, '0')}
        {!locked && current && <span style={{ marginLeft: 8, color: COLORS.textMuted }}>· open</span>}
      </div>
      <div style={{
        marginTop: 6,
        fontFamily: SERIF,
        fontSize: 16,
        fontWeight: 600,
        lineHeight: 1.1,
        color: locked ? COLORS.lockedText : COLORS.text,
        maxWidth: 260,
      }}>{level.title}</div>
      <div style={{
        marginTop: 5,
        fontFamily: MONO,
        fontSize: 8.5,
        lineHeight: 1.35,
        letterSpacing: '0.06em',
        color: locked ? COLORS.lockedText : COLORS.textDim,
        maxWidth: 280,
      }}>{level.figure || level.caption}</div>
    </div>
  );
}

function ChapterHeader({ section, branchColor, premiumSection, hasPremium, doneCount, count, expanded, onToggle }) {
  const COLORS = useC();
  const locked = premiumSection && !hasPremium;
  return (
    <div onClick={onToggle} style={{
      display: 'flex', alignItems: 'center', gap: 12,
      padding: '14px 4px 12px', cursor: 'pointer',
      borderTop: `1px solid ${COLORS.outline}`,
      opacity: locked ? 0.72 : 1,
    }}>
      <div style={{ width: 18, height: 2, background: branchColor, opacity: 0.9, flexShrink: 0 }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
          <span style={{ fontFamily: SERIF, fontSize: 19, fontWeight: 600, color: locked ? COLORS.lockedText : COLORS.text }}>
            {section.label}
          </span>
          <span style={{ fontFamily: MONO, fontSize: 9, letterSpacing: '0.16em', color: COLORS.textDim }}>
            {String(section.from).padStart(2, '0')}–{String(section.to).padStart(2, '0')}
          </span>
        </div>
        <div style={{ marginTop: 3, fontFamily: MONO, fontSize: 8.5, letterSpacing: '0.14em', color: COLORS.textDim, textTransform: 'uppercase' }}>
          {locked ? `${count} levels · locked` : `${count} levels · ${doneCount} done`}
        </div>
      </div>
      {locked ? (
        <div style={{
          display: 'flex', alignItems: 'center', gap: 6,
          padding: '5px 10px', borderRadius: 8,
          border: `1px solid ${COLORS.goldA}66`, color: COLORS.goldA,
          fontFamily: MONO, fontSize: 8.5, letterSpacing: '0.18em', fontWeight: 700,
        }}>
          <LockGlyph size={10} color={COLORS.goldA} />
          UNLOCK
        </div>
      ) : (
        <span style={{ fontFamily: MONO, fontSize: 12, color: COLORS.textDim }}>{expanded ? '−' : '+'}</span>
      )}
    </div>
  );
}

function LevelMapScreen({ goto, progress, startLevel }) {
  const COLORS = useC();
  const sections = HL.SECTIONS;
  const total = HL.TOTAL;
  const completed = Math.max(0, Math.min(progress.unlocked - 1, total));
  const unlocked = Math.min(progress.unlocked, total);
  const currentId = Math.max(1, Math.min(progress.unlocked, total));
  const percent = Math.round((completed / total) * 100);
  const hasPremium = isPremium(progress);

  const [expandedId, setExpandedId] = useState(() => HL.sectionForLevel(currentId).id);
  const scrollRef = useRef(null);
  const stationRefs = useRef({});

  const orderedSections = useMemo(
    () => sections.map((section) => ({
      ...section,
      branchColor: atlasBranchColor(COLORS, section.branch),
      levels: Array.from(
        { length: section.to - section.from + 1 },
        (_, i) => HL.configForLevel(section.from + i),
      ),
    })),
    [sections, COLORS.id],
  );

  useEffect(() => {
    const el = stationRefs.current[currentId];
    const scroller = scrollRef.current;
    if (!el || !scroller) return;
    const top = el.offsetTop - scroller.offsetTop - scroller.clientHeight * 0.38;
    scroller.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
  }, [currentId, expandedId]);

  const toggleSection = (section) => {
    if (section.premium && !hasPremium) { goto('paywall'); return; }
    setExpandedId((prev) => (prev === section.id ? '' : section.id));
  };

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: `radial-gradient(circle at 50% 12%, ${COLORS.surfaceElevated}, transparent 34%), linear-gradient(180deg, ${COLORS.bgPanel}, ${COLORS.bg} 62%)`,
    }}>
      <TopBar title="Levels" onMenu={() => goto('menu')} onBack={() => goto('home')} />
      <div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', padding: '0 0 30px' }}>
        {/* Title + subtitle scroll away with the chain… */}
        <div style={{ maxWidth: 340, margin: '0 auto', padding: '12px 20px 4px', textAlign: 'center' }}>
          <div style={{
            fontFamily: SERIF, fontSize: 31, fontWeight: 600,
            letterSpacing: '-0.01em', lineHeight: 1.05, color: COLORS.text,
          }}>The long mosaic</div>
          <div style={{
            marginTop: 10, fontSize: 11.5, lineHeight: 1.55,
            color: COLORS.textMuted,
          }}>
            Twenty gentle lessons, then ten chapters of Penrose figures.
          </div>
        </div>

        {/* …while the stats stay pinned as the header. */}
        <div style={{
          position: 'sticky', top: 0, zIndex: 6,
          padding: '12px 20px 12px',
          background: `${COLORS.bgPanel}E8`,
          backdropFilter: 'blur(12px)',
          WebkitBackdropFilter: 'blur(12px)',
          borderBottom: `1px solid ${COLORS.outline}`,
        }}>
          <div style={{
            maxWidth: 340,
            margin: '0 auto',
            display: 'grid',
            gridTemplateColumns: '1fr 1fr 1fr',
            gap: 12,
            textAlign: 'center',
          }}>
            <AtlasMetric label="Done" value={`${completed}/${total}`} color={COLORS.goldA} />
            <AtlasMetric label="Open" value={String(unlocked).padStart(2, '0')} color={COLORS.accent} />
            <AtlasMetric label="Map" value={`${percent}%`} />
          </div>
          <div style={{ maxWidth: 340, margin: '12px auto 0' }}>
            <div style={{
              height: 5,
              borderRadius: 999,
              background: COLORS.lockedSurface,
              overflow: 'hidden',
            }}>
              <div style={{
                width: `${percent}%`,
                height: '100%',
                background: `linear-gradient(90deg, ${COLORS.accent}, ${COLORS.goldA})`,
              }} />
            </div>
          </div>
        </div>

        <div style={{ maxWidth: 340, margin: '0 auto', padding: '12px 20px 10px' }}>
          {orderedSections.map((section) => {
            const expanded = expandedId === section.id && !(section.premium && !hasPremium);
            const doneCount = Math.max(0, Math.min(completed, section.to) - section.from + 1);
            return (
              <div key={section.id}>
                <ChapterHeader
                  section={section}
                  branchColor={section.branchColor}
                  premiumSection={section.premium}
                  hasPremium={hasPremium}
                  doneCount={doneCount}
                  count={section.to - section.from + 1}
                  expanded={expanded}
                  onToggle={() => toggleSection(section)}
                />
                {expanded && section.levels.map((lvl) => {
                  const gated = lvl.id > HL.FREE_MAX && !hasPremium;
                  const locked = gated || !isUnlocked(progress, lvl.id);
                  const done = lvl.id < progress.unlocked;
                  const current = lvl.id === progress.unlocked
                    || (progress.unlocked > total && lvl.id === total);
                  return (
                    <LevelStation
                      key={lvl.id}
                      level={lvl}
                      locked={locked}
                      done={done}
                      current={current}
                      sectionColor={section.branchColor}
                      stationRef={(el) => { stationRefs.current[lvl.id] = el; }}
                      onOpen={(id) => {
                        if (id > HL.FREE_MAX && !hasPremium) { goto('paywall'); return; }
                        if (!isUnlocked(progress, id)) return;
                        startLevel(id);
                      }}
                    />
                  );
                })}
              </div>
            );
          })}
        </div>

      </div>
    </div>
  );
}

// Chapter levels share one anchor glyph — cache so 120 stations never rebuild
// tilings on every scroll/render.
const GLYPH_CACHE = new Map();
function LevelThumb({ levelId, size = 70, state = 'current', sectionColor, faded }) {
  const COLORS = useC();
  // Chapter levels (21+) all share one figure per chapter, so their glyphs
  // would look identical — skip the figure and show a neutral numbered tile
  // until per-level chapter art exists. Tutorial levels keep distinct glyphs.
  const isChapterLevel = HL && levelId > HL.FREE_MAX;
  const glyph = useMemo(() => {
    if (!window.TessraGlyphs || !HL || isChapterLevel) return null;
    const gs = HL.glyphSpec(levelId);
    const key = gs.key + ':' + size;
    if (GLYPH_CACHE.has(key)) return GLYPH_CACHE.get(key);
    const g = window.TessraGlyphs.resolveSpec(gs.spec, gs.pattern, { size, pad: 0.38 });
    GLYPH_CACHE.set(key, g);
    return g;
  }, [levelId, size, isChapterLevel]);
  const branch = sectionColor || COLORS.outline;
  const dim = faded ? 0.5 : 1;

  if (isChapterLevel) {
    const numColor = state === 'locked' ? branch : (state === 'current' ? branch : COLORS.textDim);
    return (
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block', opacity: dim }}>
        <rect x={size * 0.5 - size * 0.34} y={size * 0.5 - size * 0.34}
          width={size * 0.68} height={size * 0.68} rx="10"
          transform={`rotate(45 ${size / 2} ${size / 2})`}
          fill={state === 'current' ? `${branch}1F` : 'none'}
          stroke={state === 'locked' ? COLORS.outline : branch}
          strokeWidth="1.2" opacity={state === 'locked' ? 0.8 : 1} />
        <text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
          fontFamily="JetBrains Mono, monospace" fontSize={size * 0.26} fontWeight="700"
          letterSpacing="0.02em" fill={numColor}>{levelId}</text>
      </svg>
    );
  }

  if (!glyph || !glyph.ok) {
    return (
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block', opacity: dim }}>
        <rect x="10" y="10" width={size - 20} height={size - 20} rx="6"
          fill="none"
          stroke={state === 'locked' ? branch : COLORS.outline}
          strokeWidth="1.2" />
      </svg>
    );
  }

  return (
    <svg
      width={size}
      height={size}
      viewBox={`0 0 ${size} ${size}`}
      style={{ display: 'block', margin: '0 auto' }}
    >
      {glyph.paths.map((p, i) => {
        const thick = p.type === 'thick';
        const fillColor = thick ? COLORS.accent : COLORS.accent2;
        if (state === 'locked') {
          return (
            <path key={i} d={p.d}
              fill="none"
              stroke={branch}
              strokeWidth="1.15"
              strokeLinejoin="round"
              opacity={0.72 * dim} />
          );
        }
        if (state === 'done') {
          return (
            <path key={i} d={p.d}
              fill={thick ? branch : COLORS.accent2}
              opacity={0.36 * dim}
              stroke="none" />
          );
        }
        return (
          <path key={i} d={p.d}
            fill={fillColor}
            opacity={0.94}
            stroke="rgba(0,0,0,0.22)"
            strokeWidth="0.45" />
        );
      })}
    </svg>
  );
}

function AboutSection({ kicker, title, children, accent }) {
  const COLORS = useC();
  return (
    <section style={{
      padding: '18px 16px 17px',
      borderRadius: 16,
      border: `1px solid ${accent || COLORS.outline}`,
      background: `linear-gradient(180deg, ${COLORS.surface}, ${COLORS.bgPanel})`,
    }}>
      <div style={{
        fontFamily: MONO,
        fontSize: 9.5,
        letterSpacing: '0.22em',
        color: accent || COLORS.textDim,
        textTransform: 'uppercase',
      }}>{kicker}</div>
      <h2 style={{
        margin: '8px 0 0',
        fontFamily: SERIF,
        fontSize: 25,
        lineHeight: 1,
        fontWeight: 600,
        letterSpacing: '-0.01em',
        color: COLORS.text,
      }}>{title}</h2>
      <div style={{
        marginTop: 12,
        fontSize: 12.5,
        lineHeight: 1.62,
        color: COLORS.textMuted,
      }}>{children}</div>
    </section>
  );
}

function AboutScreen({ goto }) {
  const COLORS = useC();
  return (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      flexDirection: 'column',
      background: `radial-gradient(circle at 50% 8%, ${COLORS.surfaceElevated}, transparent 34%), ${COLORS.bg}`,
    }}>
      <TopBar title="About" onMenu={() => goto('menu')} onBack={() => goto('home')} />
      <div style={{
        flex: 1,
        overflowY: 'auto',
        padding: '6px 22px 30px',
        display: 'flex',
        flexDirection: 'column',
        gap: 12,
      }}>
        <div style={{
          minHeight: 214,
          borderRadius: 22,
          border: `1px solid ${COLORS.outlineStrong}`,
          background: `linear-gradient(145deg, ${COLORS.bgPanel}, ${COLORS.surface})`,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          padding: '22px 20px',
          textAlign: 'center',
          boxShadow: '0 22px 48px rgba(0,0,0,0.22)',
        }}>
          <div style={{ animation: 'tessera-float 3.6s ease-in-out infinite' }}>
            <DynamicLogo size={96} scale={13} glow={true} />
          </div>
          <div style={{
            marginTop: 16,
            fontFamily: SERIF,
            fontSize: 34,
            lineHeight: 0.95,
            fontWeight: 600,
            color: COLORS.text,
            letterSpacing: '-0.01em',
          }}>Tessra</div>
          <div style={{
            marginTop: 10,
            fontFamily: MONO,
            fontSize: 10.5,
            lineHeight: 1.55,
            letterSpacing: '0.12em',
            color: COLORS.textMuted,
            textTransform: 'uppercase',
          }}>A game of attention inside an aperiodic mosaic.</div>
        </div>

        <AboutSection kicker="The game" title="Find & build">
          Tessra turns a Penrose field into a quiet search. Each level hides one
          named figure — Wings, Sun, Ace, Decagon, Star — in an empty grid.
          Scroll to find it, then rebuild it tile by tile from the tray.
        </AboutSection>

        <AboutSection kicker="The mosaic" title="Never quite repeats" accent={COLORS.goldA}>
          The board is built from two rhombs: thick 72 degree tiles and thin
          36 degree tiles. They can cover the plane forever without settling
          into a repeating wallpaper. Local symmetries keep appearing, then
          slipping away.
        </AboutSection>

        <div style={{
          display: 'grid',
          gridTemplateColumns: '1fr 1fr',
          gap: 10,
        }}>
          <div style={{
            minHeight: 118,
            borderRadius: 16,
            border: `1px solid ${COLORS.outline}`,
            background: COLORS.lockedSurface,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}>
            <RhombIcon type="thick" size={82} />
          </div>
          <div style={{
            minHeight: 118,
            borderRadius: 16,
            border: `1px solid ${COLORS.outline}`,
            background: COLORS.lockedSurface,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}>
            <RhombIcon type="thin" size={82} />
          </div>
        </div>

        <AboutSection kicker="Origin" title="Penrose, 1974" accent={COLORS.accent}>
          The geometry comes from Sir Roger Penrose's aperiodic tilings. Tessra
          is not a lesson or a diagram; it is a tactile way to notice the same
          hidden order by playing with it.
        </AboutSection>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 4 }}>
          <button onClick={() => goto('daily')} style={{
            ...btnGhostStyle(COLORS),
            height: 48,
            borderColor: COLORS.outlineStrong,
            color: COLORS.text,
          }}>DAILY</button>
          <button onClick={() => goto('levelmap')} style={{
            ...btnSolidStyle(COLORS),
            height: 48,
            background: COLORS.goldA,
            color: COLORS.bg,
          }}>ATLAS</button>
        </div>

        <div style={{
          marginTop: 16,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          gap: 8,
          paddingBottom: 8,
        }}>
          <span style={{
            fontFamily: MONO,
            fontSize: 9,
            letterSpacing: '0.2em',
            textTransform: 'uppercase',
            color: COLORS.textDim,
          }}>Crafted by</span>
          <BnbStudioLogo height={36} />
        </div>
      </div>
    </div>
  );
}

// ─── LEVEL PLAY ───────────────────────────────────────────────────────────
function LevelPlayScreen({ goto, levelId, progress, setProgress }) {
  // Levels beyond the free tutorial require the (mock) Tessra Complete unlock.
  if (levelId > HL.FREE_MAX && !isPremium(progress)) {
    return <PaywallScreen goto={goto} progress={progress} setProgress={setProgress} />;
  }
  // Every level is now find-&-build-the-figure in a large empty field.
  return <LevelFigureScreen
    key={levelId}
    levelId={levelId}
    goto={goto} progress={progress} setProgress={setProgress} />;
}


// Accent wash on level completion — expands along the assembled figure silhouette.
function VictoryFigureFill({ snapshot, startedAt }) {
  const COLORS = useC();
  if (REDUCED_MOTION || !snapshot || !snapshot.d) return null;
  const gradId = 'vf-grad-' + String(startedAt).replace('.', '');
  return (
    <div key={startedAt} style={{
      position: 'absolute', inset: 0, overflow: 'hidden',
      pointerEvents: 'none', zIndex: 40,
    }}>
      <svg
        width="100%" height="100%"
        viewBox={'0 0 ' + snapshot.width + ' ' + snapshot.height}
        preserveAspectRatio="xMidYMid slice"
        style={{ position: 'absolute', inset: 0, display: 'block' }}
        aria-hidden="true"
      >
        <defs>
          <linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
            <stop offset="0%" stopColor={COLORS.accent} />
            <stop offset="52%" stopColor={COLORS.accent} />
            <stop offset="100%" stopColor={COLORS.goldA} />
          </linearGradient>
        </defs>
        <g style={{
          transformOrigin: snapshot.cx + 'px ' + snapshot.cy + 'px',
          animation: 'tessera-victory-figure-fill 1400ms cubic-bezier(0.22, 0.61, 0.36, 1) both',
        }}>
          <path d={snapshot.d} fill={'url(#' + gradId + ')'} />
        </g>
      </svg>
    </div>
  );
}

function FigureRevealOverlay({ level, startedAt, headline, snapshot }) {
  const COLORS = useC();
  const title = (level.title || level.figure || level.caption || 'Figure').toUpperCase();
  const label = (headline || 'Complete').toUpperCase();
  const maskId = 'vm-' + String(startedAt).replace('.', '');
  const hasShape = snapshot && snapshot.d;
  const sparks = [
    { left: '24%', top: '60%', delay: '0ms', color: COLORS.accent },
    { left: '34%', top: '31%', delay: '120ms', color: COLORS.goldA },
    { left: '51%', top: '24%', delay: '60ms', color: COLORS.outlineActive },
    { left: '68%', top: '35%', delay: '190ms', color: COLORS.accent },
    { left: '78%', top: '62%', delay: '90ms', color: COLORS.goldA },
  ];

  return (
    <div key={startedAt} style={{
      position: 'absolute',
      inset: 0,
      left: 0,
      right: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      pointerEvents: 'none',
      opacity: 1,
      zIndex: 8,
    }}>
      {hasShape ? (
        <svg
          width="100%" height="100%"
          viewBox={'0 0 ' + snapshot.width + ' ' + snapshot.height}
          preserveAspectRatio="xMidYMid slice"
          style={{ position: 'absolute', inset: 0, display: 'block', pointerEvents: 'none' }}
          aria-hidden="true"
        >
          <defs>
            <mask id={maskId}>
              <rect width={snapshot.width} height={snapshot.height} fill="white" />
              {!REDUCED_MOTION && (
                <g style={{
                  transformOrigin: snapshot.cx + 'px ' + snapshot.cy + 'px',
                  animation: 'tessera-victory-figure-dim 520ms ease-out both',
                }}>
                  <path d={snapshot.d} fill="black" />
                </g>
              )}
              {REDUCED_MOTION && <path d={snapshot.d} fill="black" />}
            </mask>
          </defs>
          <rect
            width={snapshot.width}
            height={snapshot.height}
            fill={'rgba(10,10,10,0.62)'}
            mask={'url(#' + maskId + ')'}
            style={{
              animation: REDUCED_MOTION ? 'none' : 'tessera-victory-dim 520ms ease-out both',
            }}
          />
        </svg>
      ) : (
        <div style={{
          position: 'absolute', inset: 0,
          background: `radial-gradient(circle at 50% 48%, ${COLORS.goldA}24, transparent 31%), rgba(10,10,10,0.62)`,
          animation: REDUCED_MOTION ? 'none' : 'tessera-victory-dim 520ms ease-out both',
        }} />
      )}
      <div style={{
        position: 'relative',
        width: 'min(310px, calc(100% - 44px))',
        minHeight: 210,
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        textAlign: 'center',
        animation: REDUCED_MOTION
          ? 'none'
          : 'tessera-victory-copy 720ms cubic-bezier(0.18, 0.89, 0.32, 1.22) 100ms both',
      }}>
        {!REDUCED_MOTION && (
          <div style={{
            position: 'absolute',
            width: 184,
            height: 184,
            borderRadius: 999,
            border: `1px solid ${COLORS.goldA}88`,
            boxShadow: `0 0 42px ${COLORS.goldA}33`,
            animation: 'tessera-victory-ring 1150ms ease-out 120ms both',
          }} />
        )}
        {!REDUCED_MOTION && sparks.map((spark, i) => (
          <span key={i} style={{
            position: 'absolute',
            left: spark.left,
            top: spark.top,
            width: 7,
            height: 7,
            borderRadius: 1,
            background: spark.color,
            opacity: 0,
            boxShadow: `0 0 14px ${spark.color}`,
            animation: `tessera-spark-rise 1120ms ease-out ${spark.delay} 1`,
          }} />
        ))}
        <div style={{
          position: 'relative',
          fontFamily: MONO,
          fontSize: 13,
          letterSpacing: '0.34em',
          textTransform: 'uppercase',
          fontWeight: 700,
          color: COLORS.goldA,
          textShadow: `0 0 18px ${COLORS.goldA}88`,
          whiteSpace: 'nowrap',
        }}>{label}</div>
        <div style={{
          position: 'relative',
          marginTop: 10,
          fontFamily: SERIF,
          fontSize: title.length > 12 ? 36 : 46,
          lineHeight: 0.9,
          fontWeight: 600,
          letterSpacing: '0',
          color: COLORS.text,
          textShadow: `0 0 30px ${COLORS.accent}66`,
          maxWidth: '100%',
          overflowWrap: 'break-word',
        }}>{title}</div>
      </div>
    </div>
  );
}

// ─── LEVEL PLAY · TRAY — shared thick/thin tile picker ─────────────────────

function TrayRhomb({ type, size = 34 }) {
  const COLORS = useC();
  const long = type === 'thick' ? 0.809 : 0.951;
  const short = type === 'thick' ? 0.588 : 0.309;
  const s = size * 0.48;
  const pts = [
    [long * s, 0], [0, short * s], [-long * s, 0], [0, -short * s],
  ];
  return (
    <svg width={size} height={size * 0.72} viewBox={`${-size / 2} ${-size * 0.36} ${size} ${size * 0.72}`} aria-hidden>
      <polygon
        points={pts.map((p) => p.join(',')).join(' ')}
        fill={type === 'thick' ? COLORS.thick : COLORS.thin}
        stroke={type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge}
        strokeWidth="1.1"
        opacity="0.95"
      />
    </svg>
  );
}

function TrayBar({ selected, counts, onSelect }) {
  const COLORS = useC();
  return (
    <div style={{ display: 'flex', justifyContent: 'center', gap: 12, padding: '10px 22px 0' }}>
      {['thick', 'thin'].map((type) => {
        const remaining = counts[type] || 0;
        const active = selected === type;
        const exhausted = remaining === 0;
        return (
          <button
            key={type}
            onClick={() => !exhausted && onSelect(type)}
            aria-pressed={active}
            aria-label={`${type} rhomb, ${remaining} remaining`}
            style={{
              minWidth: 124, height: 56, borderRadius: 16, padding: '0 14px',
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
              border: `1.5px solid ${active && !exhausted ? COLORS.outlineActive : COLORS.outline}`,
              background: active && !exhausted ? COLORS.surfaceElevated : COLORS.surface,
              boxShadow: active && !exhausted ? `0 0 14px ${COLORS.glow}` : 'none',
              opacity: exhausted ? 0.35 : 1,
              cursor: exhausted ? 'default' : 'pointer',
              transition: 'border-color 160ms, box-shadow 160ms, opacity 200ms',
            }}
          >
            <TrayRhomb type={type} size={36} />
            <span style={{
              fontFamily: MONO, fontSize: 12, fontWeight: 700, letterSpacing: '0.1em',
              color: active && !exhausted ? COLORS.text : COLORS.textMuted,
              fontVariantNumeric: 'tabular-nums',
            }}>×{remaining}</span>
          </button>
        );
      })}
    </div>
  );
}

// ─── LEVEL PLAY · FIGURE FIELD — find & build one figure in an empty field ──
// A large empty ~3×3 Penrose field of faint outlines. One target figure glows
// somewhere off-screen; pan to find it, then place its tiles from the tray.

function figRemaining(board, filled) {
  const rem = { thick: 0, thin: 0 };
  if (!board) return rem;
  for (const id of board.figureIds) {
    if (!filled.has(id)) rem[board.tiles[id].type]++;
  }
  return rem;
}

const FIELD_VIEW_R = 4.6; // fallback world radius visible at zoom 1

// Clamp a camera point into a disc of radius `r` around the world origin.
function clampToDisc(cam, r) {
  const d = Math.hypot(cam.x, cam.y);
  if (d <= r || d === 0) return cam;
  const k = r / d;
  return { x: cam.x * k, y: cam.y * k };
}

function LevelFigureScreen({ levelId, goto, progress, setProgress }) {
  const COLORS = useC();
  const canvasRef = useRef(null);
  const config = useMemo(() => HL.configForLevel(levelId), [levelId]);
  const [board, setBoard] = useState(null);
  const [loadError, setLoadError] = useState(false);
  const [retrySeed, setRetrySeed] = useState(0);
  const [filled, setFilled] = useState(() => new Set());
  const [selectedType, setSelectedType] = useState('thick');
  const [wonAt, setWonAt] = useState(0);
  const [winSnapshot, setWinSnapshot] = useState(null);
  const [zoom, setZoom] = useState(1.0);
  const [hintOn, setHintOn] = useState(false); // default: no hint — find by shape
  const [toast, setToast] = useState('');
  const toastTimerRef = useRef(null);
  const animRef = useRef(new Map());
  const ripplesRef = useRef([]);
  const wrongShakesRef = useRef([]);
  const winHandledRef = useRef(false);
  const camRef = useRef({ x: 0, y: 0 });     // world position of the view centre
  const scaleRef = useRef(1);                 // px per world unit (updated each frame)
  const dragRef = useRef(null);               // { sx, sy, cx, cy, moved }
  const hintOnRef = useRef(hintOn);           hintOnRef.current = hintOn;

  const showToast = useCallback((msg) => {
    setToast(msg);
    if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
    toastTimerRef.current = setTimeout(() => setToast(''), 2000);
  }, []);

  const filledRef = useRef(filled);             filledRef.current = filled;
  const selectedTypeRef = useRef(selectedType); selectedTypeRef.current = selectedType;
  const wonAtRef = useRef(wonAt);               wonAtRef.current = wonAt;
  const zoomRef = useRef(zoom);                 zoomRef.current = zoom;

  // Build the field (deterministic per level id).
  useEffect(() => {
    if (!window.HoleEngine || !window.PenroseGeo) return;
    const cfg = retrySeed > 0
      ? { ...config, pattern: ((config.pattern + retrySeed * 0.17) % 0.9) + 0.05 }
      : config;
    const b = window.HoleEngine.buildFigureField(cfg);
    if (!b || !b.figureIds || b.figureIds.length === 0) {
      setBoard(null); setLoadError(true); return;
    }
    setLoadError(false);
    setBoard(b);
    setFilled(new Set());
    setWonAt(0);
    setWinSnapshot(null);
    winHandledRef.current = false;
    animRef.current.clear();
    ripplesRef.current = [];
    wrongShakesRef.current = [];
    // Tutorial levels open with the hint already on; later levels start blind.
    setHintOn(!!config.autoHint);
    camRef.current = clampToDisc({ x: b.center.x + b.initialPan.x, y: b.center.y + b.initialPan.y }, b.panLimit);
    const rem = figRemaining(b, new Set());
    setSelectedType(rem.thick >= rem.thin ? 'thick' : 'thin');
    if (config.hint) showToast(config.hint);
  }, [config, retrySeed, showToast]);

  // Keep the camera inside the patch (disc around origin) so the grid always
  // fills the whole screen — no empty void at the pattern's edge.
  const clampCam = useCallback((cam) => {
    if (!board) return cam;
    return clampToDisc(cam, board.panLimit);
  }, [board]);

  const allFilled = board && board.figureIds.length > 0 && filled.size === board.figureIds.length;
  useEffect(() => {
    if (!allFilled || wonAt || winHandledRef.current) return;
    winHandledRef.current = true;
    // Snap the camera onto the figure so the victory silhouette lines up.
    camRef.current = { x: board.center.x, y: board.center.y };
    setWonAt(performance.now());
    haptic(20);
    unlockLevelIfCurrent(setProgress, levelId);
  }, [allFilled, wonAt, levelId, setProgress, board]);

  // Snapshot AFTER the win re-render: the tray leaves the layout and the play
  // area grows, so measuring earlier would misalign the overlay silhouette.
  useEffect(() => {
    if (!wonAt || !board || winSnapshot) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const raf = requestAnimationFrame(() => {
      const rect = canvas.getBoundingClientRect();
      const effZoom = (4.5 * zoomRef.current) / (board.viewR || FIELD_VIEW_R);
      setWinSnapshot(winSnapshotFromPatch(
        board.tiles, board.figureIds,
        Math.max(1, rect.width), Math.max(1, rect.height),
        board.center, effZoom,
      ));
    });
    return () => cancelAnimationFrame(raf);
  }, [wonAt, board, winSnapshot]);

  // Draw loop.
  useEffect(() => {
    if (!board) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const ctx = canvas.getContext('2d');
    const { tiles } = board;
    const figureSet = new Set(board.figureIds);
    const viewR = board.viewR || FIELD_VIEW_R;

    let raf;
    function frame() {
      // Re-measure every frame — the play area grows when the tray leaves on
      // win, and a stale buffer size skews the canvas against the overlays.
      const rect = canvas.getBoundingClientRect();
      const pw = Math.round(rect.width * dpr), ph = Math.round(rect.height * dpr);
      if (canvas.width !== pw || canvas.height !== ph) {
        canvas.width = pw; canvas.height = ph;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
      const W = rect.width, H = rect.height;
      const now = performance.now();
      const z = zoomRef.current;
      const VIS_R = viewR / z;
      const scale = Math.min(W, H) / (2 * VIS_R);
      scaleRef.current = scale;
      const cam = camRef.current;
      const ox = W / 2 - cam.x * scale;
      const oy = H / 2 + cam.y * scale;
      // Cull to the viewport (portrait reaches further vertically).
      const cullX = (W / scale) / 2 + 1.5;
      const cullY = (H / scale) / 2 + 1.5;
      const hint = hintOnRef.current;

      ctx.clearRect(0, 0, W, H);

      // 1. The whole mosaic as faint empty outlines — fills the entire screen.
      //    With the hint off, figure cells blend into this grid.
      for (let i = 0; i < tiles.length; i++) {
        const t = tiles[i];
        const dx = t.mean.x - cam.x, dy = t.mean.y - cam.y;
        if (Math.abs(dx) > cullX || Math.abs(dy) > cullY) continue;
        if (filledRef.current.has(i)) continue;       // filled → drawn later
        if (figureSet.has(i) && hint) continue;        // highlighted separately
        ctx.beginPath();
        t.verts.forEach((v, j) => {
          const sx = ox + v.x * scale, sy = oy - v.y * scale;
          if (j === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
        });
        ctx.closePath();
        ctx.strokeStyle = COLORS.textDim;
        ctx.globalAlpha = 0.5;
        ctx.lineWidth = 0.9;
        ctx.stroke();
      }
      ctx.globalAlpha = 1;

      // 2. Target figure — only revealed when the player asks for a hint.
      if (hint) {
        const pulse = REDUCED_MOTION ? 0.7 : (0.6 + 0.4 * Math.sin(now / 520));
        board.figureIds.forEach((id) => {
          if (filledRef.current.has(id)) return;
          const t = tiles[id];
          const path = new Path2D();
          t.verts.forEach((v, j) => {
            const sx = ox + v.x * scale, sy = oy - v.y * scale;
            if (j === 0) path.moveTo(sx, sy); else path.lineTo(sx, sy);
          });
          path.closePath();
          ctx.setLineDash([]);
          ctx.fillStyle = COLORS.outlineActive;
          ctx.globalAlpha = 0.06 + 0.05 * pulse;
          ctx.fill(path);
          ctx.lineWidth = 4.5;
          ctx.strokeStyle = COLORS.outlineActive;
          ctx.globalAlpha = 0.12 + 0.08 * pulse;
          ctx.stroke(path);
          ctx.setLineDash([5, 4]);
          ctx.lineWidth = 1.7;
          ctx.strokeStyle = COLORS.outlineActive;
          ctx.globalAlpha = 0.75 + 0.2 * pulse;
          ctx.stroke(path);
        });
        ctx.setLineDash([]);
        ctx.globalAlpha = 1;
      }

      // 3. Wrong-type feedback — a brief shiver in the error tone.
      for (let i = wrongShakesRef.current.length - 1; i >= 0; i--) {
        const s = wrongShakesRef.current[i];
        const dt = (now - s.start) / 420;
        if (dt > 1) { wrongShakesRef.current.splice(i, 1); continue; }
        const t = tiles[s.id];
        const wobble = REDUCED_MOTION ? 0 : Math.sin(dt * 26) * 2.4 * (1 - dt);
        ctx.beginPath();
        t.verts.forEach((v, j) => {
          const sx = ox + v.x * scale + wobble, sy = oy - v.y * scale;
          if (j === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
        });
        ctx.closePath();
        ctx.strokeStyle = '#c45c5c';
        ctx.lineWidth = 1.8;
        ctx.globalAlpha = 0.85 * (1 - dt * 0.5);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      // 4. Filled figure tiles — easeOutBack pop.
      filledRef.current.forEach((id) => {
        const t = tiles[id];
        const anim = animRef.current.get(id);
        let sc = 1;
        if (anim) {
          const dt = (now - anim) / 520;
          if (dt < 1) sc = easeOutBack(Math.max(0, dt));
          else { animRef.current.delete(id); sc = 1; }
        }
        const tcx = t.mean.x, tcy = t.mean.y;
        const tileVerts = t.verts.map((v) => {
          const lx = tcx + (v.x - tcx) * sc, ly = tcy + (v.y - tcy) * sc;
          return { x: ox + lx * scale, y: oy - ly * scale };
        });
        const fa = 0.92 * Math.min(1, Math.max(0, sc));
        fillTileShape(ctx, tileVerts, t.type, COLORS, fa);
        strokeTileGrout(ctx, tileVerts, COLORS, 0.9 * fa, 0.9);
      });

      // 5. Ripples.
      for (let i = ripplesRef.current.length - 1; i >= 0; i--) {
        const r = ripplesRef.current[i];
        const dt = (now - r.start) / 900;
        if (dt > 1) { ripplesRef.current.splice(i, 1); continue; }
        const ease = 1 - Math.pow(1 - dt, 3);
        const rad = ease * 2.5 * scale;
        const sx = ox + r.x * scale, sy = oy - r.y * scale;
        ctx.beginPath();
        ctx.arc(sx, sy, rad, 0, Math.PI * 2);
        ctx.strokeStyle = COLORS.glow;
        ctx.globalAlpha = (1 - dt) * 0.5;
        ctx.lineWidth = 1.5;
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      raf = requestAnimationFrame(frame);
    }
    frame();
    return () => raf && cancelAnimationFrame(raf);
  }, [board, COLORS]);

  // ── Pointer: drag to pan, tap (no drag) to place a tile ──────────────────
  const onPointerDown = useCallback((e) => {
    if (!board || wonAtRef.current) return;
    const cam = camRef.current;
    dragRef.current = { sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
  }, [board]);

  const onPointerMove = useCallback((e) => {
    const d = dragRef.current;
    if (!d) return;
    const dx = e.clientX - d.sx, dy = e.clientY - d.sy;
    if (!d.moved && Math.hypot(dx, dy) > 6) d.moved = true;
    if (d.moved) {
      const scale = scaleRef.current || 1;
      camRef.current = clampCam({ x: d.cx - dx / scale, y: d.cy + dy / scale });
    }
  }, [clampCam]);

  const onPointerUp = useCallback((e) => {
    const d = dragRef.current;
    dragRef.current = null;
    if (!board || wonAtRef.current || !d || d.moved) return;
    const canvas = canvasRef.current;
    const rect = canvas.getBoundingClientRect();
    const px = e.clientX - rect.left, py = e.clientY - rect.top;
    const W = rect.width, H = rect.height;
    const VIS_R = (board.viewR || FIELD_VIEW_R) / zoomRef.current;
    const scale = Math.min(W, H) / (2 * VIS_R);
    const cam = camRef.current;
    const ox = W / 2 - cam.x * scale;
    const oy = H / 2 + cam.y * scale;
    const wx = (px - ox) / scale, wy = (oy - py) / scale;
    for (const id of board.figureIds) {
      if (filledRef.current.has(id)) continue;
      if (!window.PenroseGeo.pointInPoly(wx, wy, board.tiles[id].verts)) continue;
      const now = performance.now();
      if (board.tiles[id].type !== selectedTypeRef.current) {
        wrongShakesRef.current.push({ id, start: now });
        haptic(30);
        showToast('The other rhomb fits here');
        return;
      }
      const next = new Set(filledRef.current); next.add(id);
      setFilled(next);
      animRef.current.set(id, now);
      ripplesRef.current.push({ x: board.tiles[id].mean.x, y: board.tiles[id].mean.y, start: now });
      haptic(10);
      const rem = figRemaining(board, next);
      const cur = selectedTypeRef.current;
      const other = cur === 'thick' ? 'thin' : 'thick';
      if (next.size >= board.figureIds.length) setToast('');
      else if (rem[cur] === 0 && rem[other] > 0) {
        setSelectedType(other);
        showToast(other === 'thin' ? 'Only thin tiles remain' : 'Only thick tiles remain');
      } else showToast('Tile snapped in place');
      return;
    }
  }, [board, showToast]);

  const remaining = useMemo(() => figRemaining(board, filled), [board, filled]);
  const preview = useMemo(() => {
    if (!window.TessraGlyphs || !config.anchorSpec) return null;
    return window.TessraGlyphs.resolveSpec(config.anchorSpec, config.pattern, { size: 60, pad: 0.32 });
  }, [config]);
  const nextId = levelId + 1;
  const nextIsOpen = nextId <= HL.FREE_MAX || isPremium(progress);
  const hasNext = nextId <= HL.TOTAL && nextIsOpen;

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar
        title={`${String(levelId).padStart(2, '0')} · ${config.title}`}
        onMenu={() => goto('menu')}
        onBack={() => goto('levelmap')}
      />
      <div style={{ flex: 1, position: 'relative' }}>
        {loadError ? (
          <div style={{
            position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
            alignItems: 'center', justifyContent: 'center', padding: '0 28px', gap: 14,
          }}>
            <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: '0.2em', color: COLORS.textMuted, textAlign: 'center', lineHeight: 1.6 }}>
              Field not ready — tap to reshuffle the tiling.
            </div>
            <button onClick={() => setRetrySeed((s) => s + 1)} style={btnSolidStyle(COLORS)}>TRY AGAIN</button>
          </div>
        ) : (
          <canvas ref={canvasRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            onPointerCancel={() => { dragRef.current = null; }}
            style={{
              position: 'absolute', inset: 0, width: '100%', height: '100%',
              display: 'block', touchAction: 'none', cursor: 'grab',
              animation: wonAt > 0 && !REDUCED_MOTION ? 'tessera-canvas-lift 880ms cubic-bezier(0.18, 0.89, 0.32, 1.18) both' : 'none',
              transformOrigin: '50% 50%',
            }} />
        )}
        {!loadError && (
          <>
            {/* Reference preview — "build this" */}
            {wonAt <= 0 && preview && preview.ok && (
              <div style={{
                position: 'absolute', top: 10, left: 10, padding: '8px 10px 6px',
                borderRadius: 12, border: `1px solid ${COLORS.outline}`, background: COLORS.toastBg,
                display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
                pointerEvents: 'none',
              }}>
                <svg width="58" height="58" viewBox="0 0 60 60" style={{ display: 'block' }}>
                  {preview.paths.map((p, i) => (
                    <path key={i} d={p.d}
                      fill={p.type === 'thick' ? COLORS.accent : COLORS.accent2}
                      opacity="0.92" stroke="rgba(0,0,0,0.25)" strokeWidth="0.4" />
                  ))}
                </svg>
                <span style={{ fontFamily: MONO, fontSize: 7.5, letterSpacing: '0.18em', color: COLORS.textDim, textTransform: 'uppercase' }}>Find &amp; build</span>
              </div>
            )}
            {wonAt <= 0 && <div style={{
              position: 'absolute', bottom: 10, right: 10, display: 'flex',
              flexDirection: 'column', gap: 6,
            }}>
              {[['+', () => setZoom((v) => Math.min(2.5, +(v + 0.25).toFixed(2)))],
                ['−', () => setZoom((v) => Math.max(0.5, +(v - 0.25).toFixed(2)))]].map(([sym, fn]) => (
                <button key={sym} onClick={fn} style={{
                  width: 32, height: 32, borderRadius: 16,
                  border: `1px solid ${COLORS.outline}`, background: COLORS.zoomBtnBg,
                  color: COLORS.text, fontFamily: MONO, fontSize: 16,
                  cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
                  padding: 0,
                }}>{sym}</button>
              ))}
            </div>}
            {/* Hint toggle — off by default; reveals & recentres the figure. */}
            {wonAt <= 0 && (
              <button onClick={() => {
                setHintOn((on) => {
                  const next = !on;
                  if (next && board) camRef.current = clampCam({ x: board.center.x, y: board.center.y });
                  return next;
                });
                haptic(6);
              }} style={{
                position: 'absolute', bottom: 10, left: 10, height: 32, padding: '0 14px',
                borderRadius: 16, cursor: 'pointer', fontFamily: MONO, fontSize: 10,
                letterSpacing: '0.18em', fontWeight: 700, textTransform: 'uppercase',
                border: `1px solid ${hintOn ? COLORS.outlineActive : COLORS.outline}`,
                background: hintOn ? COLORS.surfaceElevated : COLORS.zoomBtnBg,
                color: hintOn ? COLORS.text : COLORS.textMuted,
                boxShadow: hintOn ? `0 0 12px ${COLORS.glow}` : 'none',
              }}>{hintOn ? 'Hint on' : 'Hint'}</button>
            )}
            {wonAt <= 0 && <MicrocopyToast message={toast} />}
            {wonAt > 0 && winSnapshot && <VictoryFigureFill snapshot={winSnapshot} startedAt={wonAt} />}
            {wonAt > 0 && winSnapshot && <FigureRevealOverlay level={config} startedAt={wonAt} headline="Complete" snapshot={winSnapshot} />}
          </>
        )}
      </div>
      {!loadError && wonAt <= 0 && (
        <TrayBar selected={selectedType} counts={remaining} onSelect={setSelectedType} />
      )}
      <div style={{ padding: '12px 22px 26px', display: 'flex', justifyContent: 'space-between', gap: 10 }}>
        <button onClick={() => {
          setFilled(new Set()); setWonAt(0); setWinSnapshot(null);
          winHandledRef.current = false;
          animRef.current.clear(); ripplesRef.current = []; wrongShakesRef.current = [];
          setHintOn(!!config.autoHint);
          if (board) {
            camRef.current = clampCam({ x: board.center.x + board.initialPan.x, y: board.center.y + board.initialPan.y });
            const rem = figRemaining(board, new Set());
            setSelectedType(rem.thick >= rem.thin ? 'thick' : 'thin');
          }
        }} style={btnDangerStyle(COLORS)}>RESET</button>
        {wonAt > 0 ? (
          <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
            {hasNext
              ? <button onClick={() => goto('level:' + nextId)} style={btnSolidStyle(COLORS)}>NEXT →</button>
              : (nextId <= HL.TOTAL
                  ? <button onClick={() => goto('paywall')} style={{ ...btnSolidStyle(COLORS), background: COLORS.goldA }}>UNLOCK 100 MORE</button>
                  : <div style={{
                      fontFamily: MONO, fontSize: 10, letterSpacing: '0.24em',
                      color: COLORS.goldA, display: 'flex', alignItems: 'center',
                    }}>END OF THE ATLAS</div>)}
          </div>
        ) : (
          <div style={{
            fontFamily: MONO, fontSize: 11, color: COLORS.textMuted,
            letterSpacing: '0.18em', display: 'flex', alignItems: 'center',
          }}>{filled.size} / {board ? board.figureIds.length : '?'}</div>
        )}
      </div>
    </div>
  );
}

// ─── Static rhomb illustration (used in the About screen) ─────────────────
function RhombIcon({ type, size = 80 }) {
  const COLORS = useC();
  const acuteDeg = type === 'thick' ? 72 : 36;
  const long = type === 'thick' ? 0.809 : 0.951;
  const short = type === 'thick' ? 0.588 : 0.309;
  const s = size * 0.45;
  const pts = [
    [ long * s, 0],
    [0,  short * s],
    [-long * s, 0],
    [0, -short * s],
  ];
  const fill = type === 'thick' ? COLORS.thick : COLORS.thin;
  const stroke = type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
  return (
    <svg width={size} height={size} viewBox={`${-size/2} ${-size/2} ${size} ${size}`}>
      <polygon points={pts.map(p => p.join(',')).join(' ')} fill={fill} stroke={stroke} strokeWidth="1.2" opacity="0.95" />
      <text x="0" y={size * 0.4} textAnchor="middle"
        fontFamily="JetBrains Mono, monospace" fontSize="9" letterSpacing="0.2em" fill={COLORS.textDim}>
        {acuteDeg}°
      </text>
    </svg>
  );
}

// ─── PAYWALL (mock — real IAP lands in the Expo build) ───────────────────
function PaywallScreen({ goto, progress, setProgress }) {
  const COLORS = useC();
  const [done, setDone] = useState(false);

  const unlock = (restore) => {
    const next = { ...progress, premium: true };
    setProgress(next); saveProgress(next);
    setDone(true);
    haptic(20);
  };

  const benefits = [
    '100 LEVELS · TEN CHAPTERS',
    'EVERY FUTURE FIGURE',
    'ONE PURCHASE · YOURS FOREVER',
  ];

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: `radial-gradient(ellipse at 50% 12%, ${COLORS.bgPanel}, ${COLORS.bg} 68%)`,
    }}>
      <TopBar title="Tessra Complete" onMenu={() => goto('menu')} onBack={() => goto('levelmap')} />
      <div style={{ flex: 1, padding: '0 22px 14px', display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
        <div style={{
          marginTop: 4, marginBottom: 18, borderRadius: 22,
          border: `1px solid ${COLORS.outline}`,
          background: `linear-gradient(155deg, ${COLORS.bgPanel}, ${COLORS.surface})`,
          padding: '26px 20px 22px',
          display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center',
          boxShadow: '0 18px 40px rgba(0,0,0,0.18)',
        }}>
          <div style={{ animation: REDUCED_MOTION ? 'none' : 'tessera-float 3.4s ease-in-out infinite' }}>
            <DynamicLogo size={88} scale={12} glow />
          </div>
          <div style={{
            marginTop: 20, fontFamily: SERIF, fontSize: 40, fontWeight: 600,
            letterSpacing: '-0.02em', lineHeight: 1, color: COLORS.text,
            textShadow: `0 0 32px ${COLORS.outlineActive}33`,
          }}>Tessra</div>
          <div style={{
            marginTop: 10, fontFamily: MONO, fontSize: 10, fontWeight: 700,
            letterSpacing: '0.28em', textTransform: 'uppercase', color: COLORS.goldA,
          }}>Complete</div>
          <div style={{
            marginTop: 16, maxWidth: 300, fontFamily: MONO, fontSize: 12,
            color: COLORS.textMuted, lineHeight: 1.6, letterSpacing: '0.02em',
          }}>
            One hundred more mosaics across ten chapters. Suns, stars, cartwheels —
            each level a small figure to restore.
          </div>
        </div>

        {done ? (
          <div style={{
            marginTop: 4, padding: '20px 16px', borderRadius: 14,
            border: `1px solid ${COLORS.accent}55`, background: `${COLORS.accent}0F`, textAlign: 'center',
          }}>
            <div style={{ fontFamily: MONO, fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', color: COLORS.text }}>
              Chapters unlocked
            </div>
            <div style={{ fontSize: 12, color: COLORS.textMuted, marginTop: 8, lineHeight: 1.5 }}>
              All ten chapters are open. Enjoy the long mosaic.
            </div>
            <button onClick={() => goto('levelmap')} style={{ ...btnSolidStyle(COLORS), background: COLORS.goldA, color: COLORS.bg, marginTop: 16 }}>
              TO THE LEVELS
            </button>
          </div>
        ) : (
          <>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18 }}>
              {benefits.map((b) => (
                <div key={b} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                  <DoneDot size={7} color={COLORS.goldA} />
                  <span style={{ fontFamily: MONO, fontSize: 10, letterSpacing: '0.16em', color: COLORS.textMuted, textTransform: 'uppercase' }}>{b}</span>
                </div>
              ))}
            </div>

            <div style={{ textAlign: 'center', marginBottom: 6 }}>
              <div style={{ fontFamily: SERIF, fontSize: 44, fontWeight: 600, color: COLORS.text, letterSpacing: '-0.01em' }}>$4.99</div>
              <div style={{ marginTop: 4, fontFamily: MONO, fontSize: 9.5, letterSpacing: '0.2em', color: COLORS.textDim, textTransform: 'uppercase' }}>
                One-time unlock · no subscription
              </div>
            </div>

            <div style={{ flex: 1 }} />

            <button onClick={() => unlock(false)} style={{
              ...btnSolidStyle(COLORS), background: COLORS.goldA, color: COLORS.bg, height: 54, marginTop: 16,
            }}>UNLOCK 100 LEVELS</button>
            <button onClick={() => unlock(true)} style={{
              ...btnGhostStyle(COLORS), marginTop: 10, height: 44, alignSelf: 'center',
              border: 'none', background: 'transparent', color: COLORS.textMuted,
            }}>RESTORE PURCHASE</button>
            <button onClick={() => goto('levelmap')} style={{
              ...btnGhostStyle(COLORS), marginTop: 2, height: 40, alignSelf: 'center',
              border: 'none', background: 'transparent', color: COLORS.textDim,
            }}>MAYBE LATER</button>
          </>
        )}
      </div>
    </div>
  );
}

// ─── Theme switcher (5 presets) ───────────────────────────────────────────
function ThemeStrip({ themeId, onSelect }) {
  const COLORS = useC();
  const themes = window.TessraThemes.list;
  return (
    <div style={{
      position: 'absolute', left: 8, right: 8, bottom: 78, zIndex: 85,
      display: 'flex', gap: 5, justifyContent: 'center', flexWrap: 'wrap',
      pointerEvents: 'auto',
    }}>
      {themes.map(t => {
        const active = t.id === themeId;
        return (
          <button
            key={t.id}
            onClick={() => onSelect(t.id)}
            title={t.mood}
            style={{
              height: 28, padding: '0 8px', borderRadius: 6,
              border: `1px solid ${active ? COLORS.outlineActive : COLORS.outline}`,
              background: active ? COLORS.surfaceElevated : COLORS.surface,
              color: active ? COLORS.text : COLORS.textDim,
              fontFamily: MONO, fontSize: 8, letterSpacing: '0.14em',
              fontWeight: active ? 700 : 500, cursor: 'pointer',
              textTransform: 'uppercase',
              boxShadow: active ? `0 0 10px ${COLORS.glow}` : 'none',
            }}>
            {t.short} {t.label}
          </button>
        );
      })}
    </div>
  );
}

// ─── ROOT ─────────────────────────────────────────────────────────────────
function PenroseApp() {
  const [themeId, setThemeId] = useState(() => window.TessraThemes.loadThemeId());
  const colors = useMemo(() => window.TessraThemes.build(themeId), [themeId]);
  const pickTheme = useCallback((id) => {
    setThemeId(id);
    window.TessraThemes.saveThemeId(id);
    window.TessraTokens = window.TessraThemes.build(id);
  }, []);

  const [progress, setProgress] = useState(loadProgress);
  const [splashDone, setSplashDone] = useState(false);
  const [screen, setScreen] = useState('home');
  const [levelId, setLevelId] = useState(1);
  const [menuOpen, setMenuOpen] = useState(false);

  const dailyOptions = useMemo(() => (
    window.PenroseDaily.getRecent
      ? window.PenroseDaily.getRecent(3)
      : [window.PenroseDaily.getDaily()]
  ), []);
  const [dailyIndex, setDailyIndex] = useState(0);
  const daily = dailyOptions[dailyIndex] || dailyOptions[0] || window.PenroseDaily.getDaily();
  const todayDaily = dailyOptions[0] || daily;

  const dailyState = progress.dailyByDate[daily.date] || { revealed: [] };
  const updateDailyState = useCallback((patch) => {
    const next = {
      ...progress,
      dailyByDate: { ...progress.dailyByDate, [daily.date]: { ...dailyState, ...patch } },
    };
    setProgress(next);
    saveProgress(next);
  }, [progress, daily.date, dailyState]);

  const goto = useCallback((target) => {
    if (target === 'menu') { setMenuOpen(true); return; }
    if (target.startsWith && target.startsWith('level:')) {
      const id = parseInt(target.slice(6), 10);
      setLevelId(id); setScreen('levelplay'); setMenuOpen(false); return;
    }
    setScreen(target);
    setMenuOpen(false);
  }, []);

  const startLevel = useCallback((id) => {
    setLevelId(id); setScreen('levelplay');
  }, []);

  const finishSplash = useCallback(() => setSplashDone(true), []);

  const shell = (body) => (
    <ColorsContext.Provider value={colors}>
      <div style={{ position: 'absolute', inset: 0, background: colors.bg, color: colors.text, overflow: 'hidden' }}>
        {body}
      </div>
    </ColorsContext.Provider>
  );

  // Splash → straight into the app (no onboarding; the levels teach the game).
  if (!splashDone) {
    return shell(<SplashScreen onDone={finishSplash} />);
  }

  let content;
  if (screen === 'home') content = <HomeScreen goto={goto} progress={progress} daily={todayDaily} logoPaused={menuOpen} />;
  else if (screen === 'daily') content = <DailyScreen
    goto={goto}
    daily={daily}
    dailyOptions={dailyOptions}
    dailyIndex={dailyIndex}
    onSelectDaily={setDailyIndex}
    dailyByDate={progress.dailyByDate || {}}
    dailyState={dailyState}
    updateDailyState={updateDailyState}
  />;
  else if (screen === 'levelmap') content = <LevelMapScreen goto={goto} progress={progress} startLevel={startLevel} />;
  else if (screen === 'levelplay') content = <LevelPlayScreen goto={goto} levelId={levelId} progress={progress} setProgress={setProgress} />;
  else if (screen === 'about') content = <AboutScreen goto={goto} />;
  else if (screen === 'paywall') content = <PaywallScreen goto={goto} progress={progress} setProgress={setProgress} />;

  return shell(
    <>
      {content}
      <SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} goto={goto} themeId={themeId} onPickTheme={pickTheme} premium={progress.premium} />
    </>
  );
}

window.PenroseApp = PenroseApp;
