// Tessra v3.1 — Hidden Order · Coral (store production).
// Screens: Home, Daily, LevelMap, LevelPlay, Waitlist.
// Persists progress in localStorage. Mechanics unchanged from v3.

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

// Shared patch-engine (loaded before this file in Penrose Mosaic.html).
const { findFigureInTiling, vertexSignatureAt } = window.TessraFigureFinder;
const FIGURES_V2 = window.FIGURES_V2;
const patternForLevel = window.patternForLevel;

// ─── Persistence ──────────────────────────────────────────────────────────
const STORAGE_KEY = 'penrose-v3-progress';
function loadProgress() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return { unlocked: 1, dailyByDate: {}, onboarded: false };
    const p = JSON.parse(raw);
    return {
      unlocked: Math.max(1, Math.min(21, p.unlocked || 1)),
      dailyByDate: p.dailyByDate || {},
      onboarded: !!p.onboarded,
    };
  } catch (e) {
    return { unlocked: 1, dailyByDate: {}, onboarded: false };
  }
}
function saveProgress(p) {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch (e) {}
}

const WAITLIST_KEY = 'penrose-v3-waitlist';
function loadWaitlist() {
  try {
    const raw = localStorage.getItem(WAITLIST_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch (e) { return []; }
}
function saveWaitlistEntry(email) {
  const list = loadWaitlist();
  const entry = { email, at: new Date().toISOString() };
  if (!list.some(x => x.email === email)) list.push(entry);
  try { localStorage.setItem(WAITLIST_KEY, JSON.stringify(list)); } catch (e) {}
  return entry;
}

// ─── Theme context (Coral 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 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);
}

// ─── DynamicLogo — animated Penrose patch that grows & resets in a loop ──
function DynamicLogo({ size = 120, scale = 16, glow = true }) {
  const COLORS = useC();
  const canvasRef = useRef(null);

  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;
    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 tick(now) {
      if (!tiles || tiles.length === 0) { raf = requestAnimationFrame(tick); return; }
      ctx.clearRect(0, 0, size, size);
      const cx = size / 2, cy = size / 2;
      const toScreen = (x, y) => [cx + x * scale, cy - y * scale];

      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(); }
      }
      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 = (now - (revealedAt.get(id) || 0)) / 320;
        const grow = age >= 1 ? 1 : easeOutBack(Math.min(1, Math.max(0, age)));
        const tcx = t.mean.x, tcy = t.mean.y;
        ctx.beginPath();
        t.verts.forEach((v, i) => {
          const [sx, sy] = toScreen(tcx + (v.x - tcx) * grow, tcy + (v.y - tcy) * grow);
          if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
        });
        ctx.closePath();
        ctx.fillStyle = t.type === 'thick' ? COLORS.thick : COLORS.thin;
        ctx.globalAlpha = 0.9 * fade;
        ctx.fill();
        ctx.lineWidth = 0.8;
        ctx.strokeStyle = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        ctx.globalAlpha = 0.55 * fade;
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
      raf = requestAnimationFrame(tick);
    }

    newTiling();
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [size, scale, COLORS]);

  return (
    <canvas ref={canvasRef}
      style={{ width: size, height: size, filter: glow ? `drop-shadow(0 0 18px ${COLORS.glow})` : 'none' }} />
  );
}

// ─── Top burger bar (in-frame) ───────────────────────────────────────────
function TopBar({ title, onBack, onMenu, 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,
    }}>
      <button onClick={onBack || onMenu} style={iconBtnStyle(COLORS)} aria-label={onBack ? 'Back' : 'Menu'}>
        {onBack
          ? <svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M15 6l-6 6 6 6" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
          : <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={{
        fontFamily: SERIF, fontSize: 18,
        letterSpacing: '-0.01em', fontWeight: 600, color: COLORS.text,
      }}>{title}</div>
      <div style={{ width: 36, display: 'flex', justifyContent: 'flex-end' }}>{right || <div style={{ width: 36 }} />}</div>
    </div>
  );
}

// ─── Side menu ────────────────────────────────────────────────────────────
function SideMenu({ open, onClose, goto }) {
  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: 'waitlist', label: 'Full version' },
    ...(window.TessraThemes.showPicker ? [{ id: 'proto-sun', label: 'Proto · Sun' }] : []),
  ];
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 70,
      background: COLORS.overlayBg, backdropFilter: 'blur(10px)',
      display: 'flex', flexDirection: 'column', padding: '56px 24px 24px',
    }} onClick={onClose}>
      <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
        <button onClick={onClose} style={iconBtnStyle(COLORS)} aria-label="Close">
          <svg width="18" height="18" 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>
      <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>
      <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12 }}>
        <div style={{ animation: 'tessera-float 3.6s ease-in-out infinite' }}>
          <DynamicLogo size={56} scale={7} glow={true} />
        </div>
        <div style={{
          color: COLORS.textDim, fontSize: 10, fontFamily: 'JetBrains Mono, monospace',
          letterSpacing: '0.18em', textAlign: 'right', paddingBottom: 6,
        }}>
          CRAFTED BY<br/>BOTS & BONES
        </div>
      </div>
    </div>
  );
}

// ─── HOME ─────────────────────────────────────────────────────────────────
function HomeScreen({ goto, progress, daily }) {
  const COLORS = useC();
  const completed = Math.min(progress.unlocked - 1, 20);
  const unlocked = Math.min(progress.unlocked, 20);
  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: `radial-gradient(ellipse at 50% 22%, ${COLORS.bgPanel}, ${COLORS.bg} 70%)`,
    }}>
      <TopBar title="Tessra" onMenu={() => goto('menu')} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 22px 30px', gap: 16 }}>
        <div style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
          <div style={{ animation: 'tessera-float 3.2s ease-in-out infinite' }}>
            <DynamicLogo size={120} scale={16} />
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 20 }}>
            <span style={{ width: 24, height: 1, background: COLORS.textDim, opacity: 0.4 }} />
            <span style={{
              fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
              letterSpacing: '0.44em', color: COLORS.textDim, textTransform: 'uppercase',
            }}>DISCOVER · REVEAL</span>
            <span style={{ width: 24, height: 1, background: COLORS.textDim, opacity: 0.4 }} />
          </div>
          <div style={{
            fontFamily: 'Fraunces, serif', fontSize: 44, fontWeight: 600,
            letterSpacing: '-0.01em', marginTop: 8, color: COLORS.text,
            textShadow: `0 0 36px ${COLORS.outlineActive}44`,
          }}>Tessra</div>
          <div style={{
            fontFamily: MONO, fontSize: 11, letterSpacing: '0.2em', color: COLORS.textMuted,
            marginTop: 10, textAlign: 'center', lineHeight: 1.5,
          }}>
            Elegant discovery in an aperiodic mosaic.
          </div>
        </div>

        <Card onClick={() => goto('daily')} accent={COLORS.accent}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <Caption>MOSAIC OF THE DAY</Caption>
              <BigTitle>{daily.date}</BigTitle>
              <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>SEED · {daily.patternId}</Caption>
            </div>
            <MiniRhomb type="THICK" color={COLORS.accent} />
          </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>
              <Caption>LEVELS</Caption>
              <BigTitle>{unlocked}/20</BigTitle>
              <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>{completed >= 20 ? 'COMPLETED' : (completed > 0 ? 'CONTINUE' : 'BEGIN')}</Caption>
            </div>
            <MiniRhomb type="THIN" color={COLORS.accent2} />
          </div>
          <div style={{ marginTop: 18, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 }}>
            Twenty named Penrose figures — from Twin Rhombs to Twin Suns.
          </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 }) {
  const COLORS = useC();
  return <div style={{ fontFamily: SERIF, fontSize: 30, fontWeight: 600, marginTop: 6, letterSpacing: '-0.01em', color: COLORS.text }}>{children}</div>;
}

function MiniRhomb({ type, color }) {
  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={color} opacity="0.18" stroke={color} strokeWidth="1.5" />
    </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' },
];

function DailyScreen({ goto, daily, 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

  // 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;
    for (const f of DAILY_FIGURES) {
      if (figureCounts[f.key] > (prev[f.key] || 0)) {
        setCelebrating({ key: f.key, at: performance.now() });
        haptic(15);
        break;
      }
    }
    prevCountsRef.current = figureCounts;
  }, [figureCounts]);
  useEffect(() => {
    if (!celebrating) return;
    const id = setTimeout(() => setCelebrating(null), 1200);
    return () => clearTimeout(id);
  }, [celebrating]);

  // Generate tiling for today's seed.
  useEffect(() => {
    const tiles = window.PenroseGeo.generateTiling({ radius: 18, pattern: daily.pattern });
    const c = window.PenroseGeo.findCenterTile(tiles);
    setTiling(tiles);
    setCenterId(c);
    if (!dailyState.revealed || dailyState.revealed.length === 0) {
      const initial = new Set([c]);
      setRevealed(initial);
      updateDailyState({ revealed: [c] });
    }
  }, [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]);

  // Draw.
  useEffect(() => {
    if (!tiling) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr; canvas.height = rect.height * dpr;
    const ctx = canvas.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    let raf;
    function frame() {
      const w = rect.width, h = rect.height;
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = COLORS.bg;
      ctx.fillRect(0, 0, w, h);

      // Fit camera around revealed tiles + small buffer.
      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 cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
      const ox = w / 2 - cx * scale, oy = h / 2 + cy * scale;
      const project = (p) => ({ x: ox + p.x * scale, y: oy - p.y * scale });

      const now = performance.now();
      let stillAnimating = false;

      // 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;
        if (anim) {
          const dt = (now - anim) / 420;
          if (dt < 1) {
            stillAnimating = true;
            s = easeOutBack(Math.max(0, dt));
          } 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;
        ctx.beginPath();
        t.verts.forEach((v, i) => {
          const sx = ox + (tcx + (v.x - tcx) * s) * scale;
          const sy = oy - (tcy + (v.y - tcy) * s) * scale;
          if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
        });
        ctx.closePath();
        ctx.fillStyle = t.type === 'thick' ? COLORS.thick : COLORS.thin;
        ctx.globalAlpha = 0.92 * Math.min(1, Math.max(0, s));
        ctx.fill();
        ctx.globalAlpha = 1;
        ctx.strokeStyle = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        ctx.lineWidth = 0.8;
        ctx.globalAlpha = 0.55;
        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, COLORS.outlineActive + '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.
      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; }
        stillAnimating = true;
        const ease = 1 - Math.pow(1 - dt, 3);
        const rad = ease * 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.55;
        ctx.lineWidth = 1.5;
        ctx.stroke();
        ctx.globalAlpha = 1;
      }

      stillAnimating = stillAnimating || frontier.size > 0;
      if (stillAnimating) raf = requestAnimationFrame(frame);
    }
    frame();
    return () => raf && cancelAnimationFrame(raf);
  }, [tiling, revealed, frontier, COLORS]);

  // 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 [countdown, setCountdown] = useState('');
  useEffect(() => {
    const tick = () => {
      const ms = window.PenroseDaily.msUntilTomorrowUTC();
      const h = Math.floor(ms / 3600000);
      const m = Math.floor((ms % 3600000) / 60000);
      const s = Math.floor((ms % 60000) / 1000);
      setCountdown(String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'));
    };
    tick();
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar title={`Daily · ${daily.patternId}`} onBack={() => goto('home')} />
      <div style={{
        padding: '6px 20px 14px', display: 'flex', justifyContent: 'space-between',
        fontFamily: MONO, fontSize: 11, color: COLORS.textMuted, letterSpacing: '0.18em',
        textTransform: 'uppercase',
      }}>
        <span>{daily.date}</span>
        <span>NEXT IN {countdown} UTC</span>
      </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 — pulses whichever was just completed. */}
      <div style={{
        padding: '10px 20px 8px', display: 'flex', gap: 6, justifyContent: 'center',
        flexWrap: 'wrap', fontFamily: MONO,
      }}>
        {DAILY_FIGURES.map(f => {
          const count = figureCounts[f.key];
          const isCelebrating = celebrating && celebrating.key === f.key;
          const active = count > 0;
          return (
            <div key={f.key} style={{
              display: 'flex', alignItems: 'center', gap: 5,
              padding: '5px 9px', borderRadius: 6,
              border: `1px solid ${isCelebrating || active ? COLORS.outlineActive : COLORS.outline}`,
              background: isCelebrating ? `${COLORS.accent}24` : (active ? `${COLORS.outlineActive}14` : '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 : 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>
          );
        })}
      </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 = [];
          updateDailyState({ revealed: [centerId] });
        }} style={btnDangerStyle(COLORS)}>RESET</button>
      </div>
    </div>
  );
}

function LevelMapScreen({ goto, progress, startLevel }) {
  const COLORS = useC();
  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
      <TopBar title="Levels" onBack={() => goto('home')} />
      <div style={{ flex: 1, overflowY: 'auto', padding: '4px 16px 28px' }}>
        <div style={{
          padding: '6px 6px 16px', fontFamily: 'JetBrains Mono, monospace',
          fontSize: 10, letterSpacing: '0.22em', color: COLORS.textMuted,
          display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8,
        }}>
          <span>{Math.min(progress.unlocked, 20)} / 20 UNLOCKED · {Math.min(progress.unlocked - 1, 20)} COMPLETED</span>
          <span style={{ fontSize: 9, color: COLORS.textDim, letterSpacing: '0.16em' }}>STORE · CORAL</span>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          {window.Levels.map((lvl) => {
            const locked = lvl.id > progress.unlocked;
            const done   = lvl.id <  progress.unlocked;
            const current = lvl.id === progress.unlocked;
            return (
              <div key={lvl.id} onClick={() => !locked && startLevel(lvl.id)}
                style={{
                  position: 'relative', padding: '14px 12px 16px',
                  borderRadius: 10,
                  border: `1px solid ${done ? COLORS.outlineActive : (current ? COLORS.outlineActive + 'aa' : COLORS.outline)}`,
                  background: locked ? COLORS.lockedSurface : COLORS.surface,
                  cursor: locked ? 'default' : 'pointer',
                  opacity: locked ? 0.4 : 1,
                  boxShadow: current ? `0 0 14px ${COLORS.glow}` : 'none',
                  aspectRatio: '1',
                  display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
                }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                  <span style={{
                    fontFamily: MONO, fontSize: 11, letterSpacing: '0.2em',
                    color: locked ? COLORS.lockedText : COLORS.textMuted,
                  }}>
                    {String(lvl.id).padStart(2, '0')}
                  </span>
                  {done && <DoneDot size={8} />}
                  {locked && <LockGlyph />}
                </div>
                <LevelThumb levelId={lvl.id} faded={locked} />
                <div style={{
                  fontFamily: SERIF, fontSize: 13, fontWeight: 600,
                  letterSpacing: '0.02em', color: COLORS.text,
                }}>{lvl.title}</div>
              </div>
            );
          })}
          <div onClick={() => goto('waitlist')} style={{
            gridColumn: '1 / span 2',
            marginTop: 4, padding: '18px 16px',
            borderRadius: 14, border: '1px dashed ' + COLORS.outlineStrong,
            cursor: 'pointer',
            display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start',
          }}>
            <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.22em', color: COLORS.goldA }}>
              COMING SOON
            </div>
            <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 16, fontWeight: 700, letterSpacing: '0.05em' }}>
              FULL VERSION · NOTIFY ME
            </div>
            <div style={{ fontSize: 11, color: COLORS.textMuted, marginTop: 2 }}>
              Sound, infinite canvas, new figures — leave your email for launch day.
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function LevelThumb({ levelId, faded }) {
  const COLORS = useC();
  const glyph = useMemo(() => {
    if (!window.TessraGlyphs) return null;
    return window.TessraGlyphs.resolve(levelId, { size: 70, pad: 0.38 });
  }, [levelId]);
  const size = 70;
  if (!glyph || !glyph.ok) {
    return (
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ opacity: faded ? 0.5 : 1 }}>
        <rect x="10" y="10" width={size - 20} height={size - 20} rx="6"
          fill={COLORS.lockedSurface || 'rgba(255,255,255,0.06)'}
          stroke={COLORS.outline || 'rgba(255,255,255,0.12)'} strokeWidth="1" />
      </svg>
    );
  }
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ opacity: faded ? 0.5 : 1 }}>
      {glyph.paths.map((p, i) => (
        <path key={i} d={p.d}
          fill={p.type === 'thick' ? COLORS.accent : COLORS.accent2}
          opacity="0.8" stroke="rgba(0,0,0,0.35)" strokeWidth="0.5" />
      ))}
    </svg>
  );
}

// ─── LEVEL PLAY ───────────────────────────────────────────────────────────
function LevelPlayScreen({ goto, levelId, progress, setProgress }) {
  // Dispatch migrated levels to the patch-based engine (separate component
  // so React keeps hook order stable inside each branch).
  if (FIGURES_V2[levelId]) {
    const fig = FIGURES_V2[levelId];
    return <LevelPatchScreen
      key={levelId}
      levelId={levelId}
      title={fig.title} figure={fig.figure} caption={fig.caption}
      hint={fig.hint} spec={fig.spec}
      goto={goto} progress={progress} setProgress={setProgress} />;
  }
  return <LevelPlayLegacy goto={goto} levelId={levelId} progress={progress} setProgress={setProgress} />;
}

function LevelPlayLegacy({ goto, levelId, progress, setProgress }) {
  const COLORS = useC();
  const level = window.Levels[levelId - 1];
  const [filled, setFilled] = useState(() => level.slots.map(() => false));
  const [wonAt, setWonAt] = useState(0); // 0 = not won
  const animRef = useRef(new Map());
  const ripplesRef = useRef([]);
  const canvasRef = useRef(null);

  useEffect(() => {
    setFilled(level.slots.map(() => false));
    setWonAt(0);
    animRef.current.clear();
    ripplesRef.current = [];
  }, [levelId]);

  const allFilled = filled.every(Boolean);
  useEffect(() => {
    if (allFilled && !wonAt) {
      setWonAt(performance.now());
      haptic(20);
      // Unlock next level.
      if (progress.unlocked === levelId) {
        const next = { ...progress, unlocked: Math.min(21, levelId + 1) };
        setProgress(next);
        saveProgress(next);
      }
    }
  }, [allFilled, wonAt, levelId, progress, setProgress]);

  // Draw.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr; canvas.height = rect.height * dpr;
    const ctx = canvas.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    let raf;
    function frame() {
      const w = rect.width, h = rect.height;
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = COLORS.bg;
      ctx.fillRect(0, 0, w, h);
      const t = window.LevelEngine.fitTransform(level.slots, w, h, 0.6);
      const now = performance.now();

      // Win glow background.
      if (wonAt) {
        const dt = Math.min(1, (now - wonAt) / 800);
        const k = 1 - Math.pow(1 - dt, 3);
        ctx.fillStyle = 'rgba(255,213,107,' + (0.06 * k) + ')';
        ctx.fillRect(0, 0, w, h);
      }

      // Dashed silhouettes for unfilled slots.
      const pulse = 0.55 + 0.45 * Math.sin(now / 400);
      level.slots.forEach((s, i) => {
        if (filled[i]) return;
        const verts = window.LevelEngine.rhombVerts(s).map(p => window.LevelEngine.worldToScreen(p, t));
        ctx.beginPath();
        verts.forEach((v, j) => j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y));
        ctx.closePath();
        ctx.strokeStyle = 'rgba(255,255,255,' + (0.20 + 0.18 * pulse) + ')';
        ctx.setLineDash([6, 5]);
        ctx.lineWidth = 1.2;
        ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle = 'rgba(255,255,255,0.025)';
        ctx.fill();
      });

      // Filled rhombi with easeOutBack pop-in.
      let animating = false;
      level.slots.forEach((s, i) => {
        if (!filled[i]) return;
        const anim = animRef.current.get(i);
        let sc = 1;
        if (anim) {
          const dt = (now - anim) / 460;
          if (dt < 1) { animating = true; sc = easeOutBack(Math.max(0, dt)); }
          else { animRef.current.delete(i); sc = 1; }
        }
        const cx = s.cx * t.scale + t.ox;
        const cy = -s.cy * t.scale + t.oy;
        const verts = window.LevelEngine.rhombVerts(s).map(p => {
          const px = t.ox + p.x * t.scale;
          const py = t.oy - p.y * t.scale;
          return { x: cx + (px - cx) * sc, y: cy + (py - cy) * sc };
        });
        ctx.beginPath();
        verts.forEach((v, j) => j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y));
        ctx.closePath();
        ctx.fillStyle = s.type === 'THICK' ? COLORS.thick : COLORS.thin;
        ctx.globalAlpha = 0.92 * Math.min(1, Math.max(0, sc));
        ctx.fill();
        ctx.globalAlpha = 1;
        ctx.strokeStyle = s.type === 'THICK' ? COLORS.thickEdge : COLORS.thinEdge;
        ctx.lineWidth = 0.8;
        ctx.globalAlpha = 0.55;
        ctx.stroke();
        ctx.globalAlpha = 1;
      });

      // 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; }
        animating = true;
        const ease = 1 - Math.pow(1 - dt, 3);
        const rad = ease * 2.2 * t.scale;
        const sx = t.ox + r.x * t.scale, sy = t.oy - r.y * t.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;
      }

      if (animating || (wonAt && now - wonAt < 1500) || !allFilled) {
        raf = requestAnimationFrame(frame);
      }
    }
    frame();
    return () => raf && cancelAnimationFrame(raf);
  }, [level, filled, wonAt, allFilled, COLORS]);

  const onTap = useCallback((e) => {
    if (wonAt) 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;
    const t = window.LevelEngine.fitTransform(level.slots, w, h, 0.6);
    const world = window.LevelEngine.screenToWorld({ x: cx, y: cy }, t);
    const i = window.LevelEngine.pickSlot(level.slots, filled, world.x, world.y);
    if (i >= 0) {
      const next = filled.slice(); next[i] = true; setFilled(next);
      const now = performance.now();
      animRef.current.set(i, now);
      ripplesRef.current.push({ x: level.slots[i].cx, y: level.slots[i].cy, start: now });
      haptic(10);
    }
  }, [level, filled, wonAt]);

  const nextId = level.id + 1;
  const hasNext = nextId <= 20;

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar title={`${String(level.id).padStart(2,'0')} · ${level.title}`} onBack={() => goto('levelmap')} />
      <div style={{ flex: 1, position: 'relative' }}>
        <canvas ref={canvasRef} onClick={onTap}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block', touchAction: 'manipulation' }} />
        {wonAt > 0 && <FigureRevealOverlay level={level} startedAt={wonAt} headline="Figure revealed" />}
      </div>
      <div style={{
        padding: '12px 22px 26px', display: 'flex', justifyContent: 'space-between', gap: 10,
      }}>
        <button onClick={() => { setFilled(level.slots.map(() => false)); setWonAt(0); animRef.current.clear(); }}
          style={btnDangerStyle(COLORS)}>RESET</button>
        {wonAt > 0 ? (
          hasNext
            ? <button onClick={() => goto('level:' + nextId)} style={btnSolidStyle(COLORS)}>NEXT →</button>
            : <button onClick={() => goto('waitlist')} style={{...btnSolidStyle(COLORS), background: COLORS.goldA}}>GET NOTIFIED</button>
        ) : (
          <div style={{
            fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: COLORS.textMuted,
            letterSpacing: '0.18em', display: 'flex', alignItems: 'center',
          }}>
            {filled.filter(Boolean).length} / {level.slots.length}
          </div>
        )}
      </div>
    </div>
  );
}

// ─── Figure reveal — Variant A (dry/scientific) ──────────────────────────
function FigureRevealOverlay({ level, startedAt, headline }) {
  const COLORS = useC();
  const [opacity, setOpacity] = useState(0);
  useEffect(() => {
    let raf;
    function tick() {
      const dt = (performance.now() - startedAt) / 600;
      const v = Math.max(0, Math.min(1, dt));
      setOpacity(1 - Math.pow(1 - v, 3));
      if (dt < 1) raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    return () => raf && cancelAnimationFrame(raf);
  }, [startedAt]);

  const label = level.caption || level.figure || level.title || '';
  if (!label && !headline) return null;

  return (
    <div style={{
      position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 22px 22px',
      pointerEvents: 'none', opacity,
      background: `linear-gradient(transparent, ${COLORS.bg}dd 45%)`,
    }}>
      {headline && (
        <div style={{
          fontFamily: MONO, fontSize: 9, letterSpacing: '0.28em', textTransform: 'uppercase',
          color: COLORS.outlineActive, textAlign: 'center', marginBottom: 8,
        }}>{headline}</div>
      )}
      <div style={{
        fontFamily: MONO, fontSize: 12,
        letterSpacing: '0.14em', color: COLORS.textMuted, lineHeight: 1.55,
        textAlign: 'center',
      }}>{label}</div>
    </div>
  );
}

// Patch finder + FIGURES_V2: figure-finder.js, figures-registry.js, glyph-engine.js

// Generic patch-based level screen. Used by all migrated levels AND by the
// Proto · Star menu link (with isProto=true for fresh tiling on every load).
function LevelPatchScreen({ levelId, title, figure, caption, hint, spec, goto, progress, setProgress, isProto = false }) {
  const COLORS = useC();
  const canvasRef = useRef(null);
  const [tiling, setTiling] = useState(null);
  const [pattern, setPattern] = useState(() => isProto ? 0.10 + Math.random() * 0.80 : patternForLevel(levelId));
  const [targetIds, setTargetIds] = useState([]);
  const [vertexPos, setVertexPos] = useState(null);
  const [filled, setFilled] = useState(() => new Set());
  const [wonAt, setWonAt] = useState(0);
  const [zoom, setZoom] = useState(1.0);
  const [loadError, setLoadError] = useState(false);
  const [toast, setToast] = useState('');
  const toastTimerRef = useRef(null);
  const animRef = useRef(new Map());
  const ripplesRef = useRef([]);

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

  const targetIdsRef = useRef(targetIds); targetIdsRef.current = targetIds;
  const filledRef = useRef(filled);       filledRef.current = filled;
  const wonAtRef = useRef(wonAt);         wonAtRef.current = wonAt;
  const zoomRef = useRef(zoom);           zoomRef.current = zoom;

  // Generate tiling + locate figure each time `pattern` changes.
  useEffect(() => {
    if (!window.PenroseGeo) return;
    // Rare figures (Jack et al.) appear in ~10% of patches at radius 18 —
    // bump attempts + radius when the spec is hard to find.
    const maxAttempts = spec.maxAttempts || 14;
    const radius = spec.radius || 18;
    let tiles = null, fig = null;
    for (let i = 0; i < maxAttempts && !fig; i++) {
      const p = (pattern + i * 0.0731) % 0.95 + 0.025;
      tiles = window.PenroseGeo.generateTiling({ radius, pattern: p });
      fig = findFigureInTiling(tiles, spec);
    }
    if (!tiles || !fig) {
      setTiling(null);
      setTargetIds([]);
      setVertexPos(null);
      setLoadError(true);
      return;
    }
    setLoadError(false);
    setTiling(tiles);
    setTargetIds(fig.tileIds);
    setVertexPos(fig.vertexPos || tiles[fig.tileIds[0]].mean);
    setFilled(new Set());
    setWonAt(0);
    animRef.current.clear();
    ripplesRef.current = [];
  }, [pattern, spec]);

  const allFilled = targetIds.length > 0 && filled.size === targetIds.length;
  useEffect(() => {
    if (allFilled && !wonAt) {
      setWonAt(performance.now());
      haptic(20);
      // Unlock next level (skip for Proto).
      if (!isProto && progress && progress.unlocked === levelId) {
        const next = { ...progress, unlocked: Math.min(21, levelId + 1) };
        setProgress(next); saveProgress(next);
      }
    }
  }, [allFilled, wonAt, levelId, progress, setProgress, isProto]);

  const transform = useMemo(() => {
    if (!tiling || !vertexPos) return null;
    return { cx: vertexPos.x, cy: vertexPos.y };
  }, [tiling, vertexPos]);

  // Draw loop.
  useEffect(() => {
    if (!tiling || !transform) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr; canvas.height = rect.height * dpr;
    const ctx = canvas.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const W = rect.width, H = rect.height;
    const targetSet = new Set(targetIds);

    let raf;
    function frame() {
      const z = zoomRef.current;
      const VIS_R = 4.5 / z;
      const scale = Math.min(W, H) / (2 * VIS_R);
      const ox = W / 2 - transform.cx * scale;
      const oy = H / 2 + transform.cy * scale;

      ctx.clearRect(0, 0, W, H);
      const now = performance.now();
      let stillAnimating = true;

      // 1. Ghost outlines with edge-vignette fade.
      tiling.forEach((t, i) => {
        if (targetSet.has(i) || filledRef.current.has(i)) return;
        const dx = t.mean.x - transform.cx, dy = t.mean.y - transform.cy;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist > VIS_R + 1.5) return;
        // Radial fade: full near center, ~0 at edge.
        const rd = dist / VIS_R;
        const fade = Math.max(0, 1 - Math.pow(rd, 2.3));
        if (fade < 0.02) return;
        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.fillStyle = t.type === 'thick' ? COLORS.thick : COLORS.thin;
        ctx.globalAlpha = 0.045 * fade;
        ctx.fill();
        ctx.strokeStyle = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        ctx.globalAlpha = 0.22 * fade;
        ctx.lineWidth = 0.7;
        ctx.stroke();
      });
      ctx.globalAlpha = 1;

      // 2. Target silhouettes — pulsing halo + crisp dashed outline.
      const pulse = REDUCED_MOTION ? 0.7 : (0.6 + 0.4 * Math.sin(now / 520));
      targetIds.forEach(id => {
        if (filledRef.current.has(id)) return;
        const t = tiling[id];
        const edge = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        const accentEdge = COLORS.outlineActive;
        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.lineWidth = 4.5;
        ctx.strokeStyle = accentEdge;
        ctx.globalAlpha = 0.10 + 0.06 * pulse;
        ctx.stroke(path);
        ctx.setLineDash([5, 4]);
        ctx.lineWidth = 1.6;
        ctx.strokeStyle = accentEdge;
        ctx.globalAlpha = 0.75 + 0.18 * pulse;
        ctx.stroke(path);
      });
      ctx.setLineDash([]);
      ctx.globalAlpha = 1;

      // 3. Filled targets — easeOutBack pop.
      filledRef.current.forEach(id => {
        const t = tiling[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;
        ctx.beginPath();
        t.verts.forEach((v, j) => {
          const lx = tcx + (v.x - tcx) * sc, ly = tcy + (v.y - tcy) * sc;
          const sx = ox + lx * scale, sy = oy - ly * scale;
          if (j === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy);
        });
        ctx.closePath();
        ctx.fillStyle = t.type === 'thick' ? COLORS.thick : COLORS.thin;
        ctx.globalAlpha = 0.92 * Math.min(1, Math.max(0, sc));
        ctx.fill();
        ctx.strokeStyle = COLORS.bg;
        ctx.lineWidth = 0.5;
        ctx.globalAlpha = 0.35;
        ctx.stroke();
        ctx.strokeStyle = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        ctx.lineWidth = 0.8;
        ctx.globalAlpha = 0.55;
        ctx.stroke();
        ctx.globalAlpha = 1;
      });

      // 4. (vertex glow removed — was distracting)

      // 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);
  }, [tiling, targetIds, vertexPos, transform, COLORS]);

  const onTap = useCallback((e) => {
    if (!tiling || !transform || wonAtRef.current) 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 = 4.5 / zoomRef.current;
    const scale = Math.min(W, H) / (2 * VIS_R);
    const ox = W / 2 - transform.cx * scale;
    const oy = H / 2 + transform.cy * scale;
    const wx = (px - ox) / scale;
    const wy = (oy - py) / scale;
    for (const id of targetIdsRef.current) {
      if (filledRef.current.has(id)) continue;
      if (window.PenroseGeo.pointInPoly(wx, wy, tiling[id].verts)) {
        const next = new Set(filledRef.current); next.add(id);
        setFilled(next);
        const now = performance.now();
        animRef.current.set(id, now);
        ripplesRef.current.push({ x: tiling[id].mean.x, y: tiling[id].mean.y, start: now });
        haptic(10);
        const total = targetIdsRef.current.length;
        if (next.size >= total) {
          showToast('Structure complete');
        } else if (next.size % 3 === 0) {
          showToast('Pattern extended');
        } else {
          showToast('Tile snapped in place');
        }
        return;
      }
    }
  }, [tiling, transform, showToast]);

  const nextId = (levelId || 0) + 1;
  const hasNext = !isProto && nextId <= 20;

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar
        title={isProto ? `PROTO · ${title}` : `${String(levelId).padStart(2,'0')} · ${title}`}
        onBack={() => goto(isProto ? 'home' : '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: 'JetBrains Mono, monospace', fontSize: 11, letterSpacing: '0.2em', color: COLORS.textMuted, textAlign: 'center', lineHeight: 1.6 }}>
              Patch not ready — tap to reshuffle the tiling.
            </div>
            <button onClick={() => setPattern(p => (p + 0.17) % 0.9 + 0.05)} style={btnSolidStyle(COLORS)}>TRY AGAIN</button>
          </div>
        ) : (
        <canvas ref={canvasRef} onClick={onTap}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block', touchAction: 'manipulation' }} />
        )}
        {!loadError && (
        <>
        {/* Zoom controls — bottom-right of canvas */}
        <div style={{
          position: 'absolute', bottom: 10, right: 10, display: 'flex',
          flexDirection: 'column', gap: 6,
        }}>
          {[['+', () => setZoom(z => Math.min(2.5, +(z + 0.25).toFixed(2)))],
            ['−', () => setZoom(z => Math.max(0.5, +(z - 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: 'JetBrains Mono, monospace', fontSize: 16,
              cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
              padding: 0,
            }}>{sym}</button>
          ))}
        </div>
        <MicrocopyToast message={toast} />
        {wonAt > 0 && <FigureRevealOverlay level={{ figure, caption }} startedAt={wonAt} headline="Figure revealed" />}
        </>
        )}
      </div>
      <div style={{ padding: '12px 22px 26px', display: 'flex', justifyContent: 'space-between', gap: 10 }}>
        <button onClick={() => {
          setFilled(new Set()); setWonAt(0);
          animRef.current.clear(); ripplesRef.current = [];
          if (isProto) setPattern(0.10 + Math.random() * 0.80);
        }} style={btnDangerStyle(COLORS)}>RESET</button>
        {wonAt > 0 ? (
          hasNext
            ? <button onClick={() => goto('level:' + nextId)} style={btnSolidStyle(COLORS)}>NEXT →</button>
            : (isProto
                ? null
                : <button onClick={() => goto('waitlist')} style={{...btnSolidStyle(COLORS), background: COLORS.goldA}}>GET NOTIFIED</button>)
        ) : (
          <div style={{
            fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: COLORS.textMuted,
            letterSpacing: '0.18em', display: 'flex', alignItems: 'center',
          }}>{filled.size} / {targetIds.length || '?'}</div>
        )}
      </div>
    </div>
  );
}

function ProtoSunScreen({ goto }) {
  // Thin wrapper — patch-based engine in isProto mode regenerates on RESET.
  // Showcases canonical Sun (5 thicks at one acute vertex).
  return <LevelPatchScreen
    isProto={true}
    title="SUN" figure="Sun (D₅T)" caption="5 thicks · 360° at vertex."
    hint="Five thicks meet at one acute vertex inside a real Penrose patch."
    spec={{ kind: 'vertexSig', signature: 'Ta,Ta,Ta,Ta,Ta', maxAttempts: 24, radius: 22 }}
    goto={goto} />;
}

// ─── ONBOARDING ───────────────────────────────────────────────────────────

// Static rhomb illustration (used in onboarding slide 1).
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>
  );
}

function OnboardingScreen({ onDone }) {
  const COLORS = useC();
  const [idx, setIdx] = useState(0);
  const last = 2;

  const slides = [
    {
      visual: (
        <div style={{ display: 'flex', alignItems: 'center', gap: 28 }}>
          <RhombIcon type="thick" size={110} />
          <RhombIcon type="thin"  size={110} />
        </div>
      ),
      title: 'TWIN RHOMBS',
      lines: ['Thick · 72° acute.', 'Thin · 36° acute.', 'That is all.'],
    },
    {
      visual: (
        <div style={{ animation: 'tessera-float 3.2s ease-in-out infinite' }}>
          <DynamicLogo size={140} scale={18} />
        </div>
      ),
      title: 'HIDDEN ORDER',
      lines: [
        'Sir Roger Penrose, 1974.',
        'Patterns that never repeat —',
        'symmetry that always returns.',
      ],
    },
    {
      visual: (
        <div style={{ animation: 'tessera-float 3.4s ease-in-out infinite' }}>
          <DynamicLogo size={120} scale={14} />
        </div>
      ),
      title: 'DISCOVER',
      lines: [
        'Find Sun, Star, Ace — and seventeen more.',
        'Reveal each figure inside the patch.',
        'Return daily for a new mosaic.',
      ],
      link: 'https://en.wikipedia.org/wiki/Penrose_tiling',
    },
  ];

  const cur = slides[idx];

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: `radial-gradient(ellipse at 50% 30%, ${COLORS.bgPanel}, ${COLORS.bg} 70%)`,
    }}>
      {/* Skip — top right, below status bar */}
      <div style={{ padding: '62px 22px 0', display: 'flex', justifyContent: 'flex-end' }}>
        <button onClick={onDone} style={{
          background: 'transparent', border: 'none', cursor: 'pointer',
          fontFamily: 'JetBrains Mono, monospace', fontSize: 11, letterSpacing: '0.3em',
          color: COLORS.textDim, padding: 8,
        }}>SKIP</button>
      </div>

      {/* Visual */}
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 24px' }}>
        <div style={{ marginBottom: 36 }}>{cur.visual}</div>

        <div style={{
          fontFamily: 'Fraunces, serif', fontSize: 34, fontWeight: 600,
          letterSpacing: '-0.01em', color: COLORS.text,
          textShadow: `0 0 28px ${COLORS.outlineActive}44`, marginBottom: 16,
        }}>{cur.title}</div>

        <div style={{
          fontFamily: 'JetBrains Mono, monospace', fontSize: 12,
          color: COLORS.textMuted, letterSpacing: '0.08em', textAlign: 'center',
          lineHeight: 1.7,
        }}>
          {cur.lines.map((l, i) => <div key={i}>{l}</div>)}
        </div>

        {cur.link && (
          <a href={cur.link} target="_blank" rel="noopener noreferrer" style={{
            marginTop: 22, fontFamily: 'JetBrains Mono, monospace', fontSize: 10,
            letterSpacing: '0.3em', color: COLORS.glow,
            textDecoration: 'none', borderBottom: `1px solid ${COLORS.glow}55`,
            paddingBottom: 2,
          }}>LEARN MORE →</a>
        )}
      </div>

      {/* Dots */}
      <div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginBottom: 22 }}>
        {slides.map((_, i) => (
          <div key={i} style={{
            width: 6, height: 6, borderRadius: 3,
            background: i === idx ? COLORS.text : COLORS.outline,
            transition: 'background 200ms',
          }} />
        ))}
      </div>

      {/* Next / Start */}
      <div style={{ padding: '0 22px 38px' }}>
        <button
          onClick={() => idx < last ? setIdx(idx + 1) : onDone()}
          style={{
            width: '100%', height: 56, borderRadius: 22,
            border: idx === last ? 'none' : `1px solid ${COLORS.outline}`,
            background: idx === last ? COLORS.accent : 'transparent',
            color: idx === last ? COLORS.bg : COLORS.textMuted,
            fontFamily: MONO, fontSize: 12, fontWeight: 700,
            letterSpacing: '0.3em', cursor: 'pointer', textTransform: 'uppercase',
          }}>
          {idx === last ? 'START' : 'NEXT →'}
        </button>
      </div>
    </div>
  );
}

// ─── WAITLIST (replaces paywall) ───────────────────────────────────────────
function WaitlistScreen({ goto }) {
  const COLORS = useC();
  const [email, setEmail] = useState('');
  const [done, setDone] = useState(false);
  const [error, setError] = useState('');

  const submit = () => {
    const trimmed = email.trim().toLowerCase();
    if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      setError('Enter a valid email.');
      return;
    }
    saveWaitlistEntry(trimmed);
    setError('');
    setDone(true);
    haptic(12);
  };

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar title="Full version" onBack={() => goto('levelmap')} />
      <div style={{ flex: 1, padding: '4px 22px 14px', display: 'flex', flexDirection: 'column' }}>
        <div style={{ padding: '10px 0 18px', fontFamily: 'JetBrains Mono, monospace' }}>
          <div style={{ fontSize: 11, color: COLORS.goldA, letterSpacing: '0.3em' }}>TESSRA · CORAL</div>
          <div style={{ fontSize: 26, fontWeight: 700, marginTop: 6, letterSpacing: '0.02em' }}>Full Tessra</div>
          <div style={{ fontSize: 13, color: COLORS.textMuted, marginTop: 10, lineHeight: 1.55 }}>
            You finished the store preview. The full app adds sound, infinite canvas,
            and dozens more figures — we will email you on launch day.
          </div>
        </div>

        {done ? (
          <div style={{
            marginTop: 8, padding: '20px 16px', borderRadius: 14,
            border: `1px solid ${COLORS.accent}55`, background: `${COLORS.accent}0F`,
          }}>
            <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em' }}>
              You are on the list
            </div>
            <div style={{ fontSize: 12, color: COLORS.textMuted, marginTop: 8, lineHeight: 1.5 }}>
              We saved <span style={{ color: COLORS.text }}>{email.trim().toLowerCase()}</span>.
              No spam — one note when the full build ships.
            </div>
          </div>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
            <label style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.22em', color: COLORS.textDim }}>
              EMAIL
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => { setEmail(e.target.value); setError(''); }}
              onKeyDown={(e) => e.key === 'Enter' && submit()}
              placeholder="you@example.com"
              autoComplete="email"
              style={{
                height: 48, padding: '0 14px', borderRadius: 12,
                border: `1px solid ${error ? '#c45c5c' : COLORS.outline}`,
                background: COLORS.surface, color: COLORS.text,
                fontFamily: 'Inter, system-ui, sans-serif', fontSize: 15,
                outline: 'none',
              }}
            />
            {error && <div style={{ fontSize: 11, color: '#e53e3e' }}>{error}</div>}
            <div style={{ fontSize: 11, color: COLORS.textDim, lineHeight: 1.5 }}>
              One email on launch day. No spam.
            </div>
          </div>
        )}

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

        {!done && (
          <button onClick={submit} style={{
            ...btnSolidStyle(COLORS), background: COLORS.goldA, color: COLORS.bg, height: 52, marginTop: 16,
          }}>NOTIFY ME</button>
        )}
        <button onClick={() => goto('levelmap')} style={{
          ...btnGhostStyle(COLORS), marginTop: 8, height: 44, alignSelf: 'center',
          border: 'none', background: 'transparent', color: COLORS.textMuted,
        }}>BACK TO LEVELS</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 [screen, setScreen] = useState('home');
  const [levelId, setLevelId] = useState(1);
  const [menuOpen, setMenuOpen] = useState(false);

  const daily = useMemo(() => window.PenroseDaily.getDaily(), []);

  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 shell = (body) => (
    <ColorsContext.Provider value={colors}>
      <div style={{ position: 'absolute', inset: 0, background: colors.bg, color: colors.text, overflow: 'hidden' }}>
        {body}
        {window.TessraThemes.showPicker && (
          <ThemeStrip themeId={themeId} onSelect={pickTheme} />
        )}
      </div>
    </ColorsContext.Provider>
  );

  // First-launch onboarding: shown only until user finishes / skips it.
  if (!progress.onboarded) {
    return shell(
      <OnboardingScreen onDone={() => {
        const next = { ...progress, onboarded: true };
        setProgress(next); saveProgress(next);
      }} />
    );
  }

  let content;
  if (screen === 'home') content = <HomeScreen goto={goto} progress={progress} daily={daily} />;
  else if (screen === 'daily') content = <DailyScreen goto={goto} daily={daily} 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 === 'waitlist') content = <WaitlistScreen goto={goto} />;
  else if (screen === 'proto-sun') content = <ProtoSunScreen goto={goto} />;

  return shell(
    <>
      {content}
      <SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} goto={goto} />
    </>
  );
}

window.PenroseApp = PenroseApp;
