// Penrose v4 — Story of the Day + Levels + Canvas.
// Screens: Home, Story (daily), LevelMap, LevelPlay, Canvas, Paywall.
// Persists progress in localStorage. Active palette is read via
// window.PenrosePalettes (shared, mutable COLORS).

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

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

function progressAfterLevelComplete(progress, levelId) {
  if (!progress) return progress;
  const intro = new Set(progress.introLevelsCompleted || []);
  if (levelId <= 3) intro.add(levelId);
  let unlocked = progress.unlocked || 1;
  if (unlocked === levelId) unlocked = Math.min(21, levelId + 1);
  const tutorialCompleted = progress.tutorialCompleted || levelId === 3 || [1, 2, 3].every(id => intro.has(id));
  if (tutorialCompleted) {
    [1, 2, 3].forEach(id => intro.add(id));
    unlocked = Math.max(unlocked, 4);
  }
  return {
    ...progress,
    unlocked,
    tutorialCompleted,
    introLevelsCompleted: Array.from(intro).sort((a, b) => a - b),
  };
}

// ─── Colors — read from active palette (palettes.js owns this object) ────
// COLORS is a live mutable reference; switching the palette updates every
// keyed value in place, so existing `style={{ background: COLORS.bg }}`
// usages re-render with the new colour on the next render pass.
const COLORS = window.PenrosePalettes.COLORS;

// ─── 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 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]);

  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 }) {
  return (
    <div style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
      padding: '62px 16px 10px', position: 'relative', zIndex: 5,
    }}>
      <button onClick={onBack || onMenu} style={iconBtn} 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="#fff" 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="#fff" strokeWidth="2" strokeLinecap="round"/></svg>}
      </button>
      <div style={{
        fontFamily: 'JetBrains Mono, monospace', fontSize: 12,
        letterSpacing: '0.22em', fontWeight: 700, color: '#fff', textTransform: 'uppercase',
      }}>{title}</div>
      <div style={{ width: 36, display: 'flex', justifyContent: 'flex-end' }}>{right || <div style={{ width: 36 }} />}</div>
    </div>
  );
}

const iconBtn = {
  width: 36, height: 36, borderRadius: 12,
  background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
  display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
  padding: 0,
};

// ─── Side menu ────────────────────────────────────────────────────────────
function SideMenu({ open, onClose, goto }) {
  if (!open) return null;
  const items = [
    { id: 'home',     label: 'Home' },
    { id: 'object',   label: 'Daily Reset' },
    { id: 'daily',    label: 'Mosaic of the Day' },
    { id: 'levelmap', label: 'Levels' },
    { id: 'favorites', label: 'Favorites' },
    { id: 'canvas',   label: 'Canvas Lab' },
    { id: 'paywall',  label: 'Unlock all' },
    { id: 'proto-sun', label: 'Proto · Sun' },
  ];
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 70,
      background: 'rgba(5,6,16,0.92)', backdropFilter: 'blur(10px)',
      display: 'flex', flexDirection: 'column', padding: '56px 24px 24px',
    }} onClick={onClose}>
      <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
        <button onClick={onClose} style={iconBtn} aria-label="Close">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6l-12 12" stroke="#fff" 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,
        }}>
          v3 · DAILY<br/>+ LEVELS
        </div>
      </div>
    </div>
  );
}

// ─── HOME ─────────────────────────────────────────────────────────────────
function HomeScreen({ goto, progress, daily }) {
  const completed = Math.min(progress.unlocked - 1, 20);
  const unlocked = Math.min(progress.unlocked, 20);
  const objectState = window.PenroseObject ? window.PenroseObject.loadObjectState() : { favorites: [] };
  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="Penrose" onMenu={() => goto('menu')} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', padding: '0 22px 30px', gap: 12, overflowY: 'auto' }}>
        <div style={{ marginBottom: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
          <div style={{ animation: 'tessera-float 3.2s ease-in-out infinite' }}>
            <DynamicLogo size={88} scale={12} />
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 12 }}>
            <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',
            }}>APERIODIC · MOSAIC</span>
            <span style={{ width: 24, height: 1, background: COLORS.textDim, opacity: 0.4 }} />
          </div>
          <div style={{
            fontFamily: 'JetBrains Mono, monospace', fontSize: 28, fontWeight: 700,
            letterSpacing: '0.22em', marginTop: 8, color: COLORS.text,
            textShadow: `0 0 36px ${COLORS.glow}55`,
          }}>PENROSE</div>
        </div>

        <Card onClick={() => goto('object')} accent={COLORS.glow}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <Caption>DAILY RESET</Caption>
              <BigTitle>{daily.patternId}</BigTitle>
              <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>TOUCH · BREATHE · REBUILD</Caption>
            </div>
            <MiniRhomb type="THICK" color={COLORS.glow} />
          </div>
          <div style={{ marginTop: 12, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.45 }}>
            Today plus four previous objects. Tap, drag, hold, save the state.
          </div>
        </Card>

        <Card onClick={() => goto('levelmap')} accent={COLORS.accent2}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <Caption>CONTINUE LEVELS</Caption>
              <BigTitle>{unlocked}/20</BigTitle>
              <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>{progress.tutorialCompleted ? 'L4 SUN UNLOCKED' : 'L1-L3 INTRO'}</Caption>
            </div>
            <MiniRhomb type="THIN" color={COLORS.accent2} />
          </div>
          <div style={{ marginTop: 12, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.45 }}>
            Intro levels teach the pieces. The main game starts at Sun.
          </div>
        </Card>

        <Card onClick={() => goto('story')} accent={COLORS.accent}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <Caption>STORY OF THE DAY</Caption>
              <BigTitle>{daily.date}</BigTitle>
              <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>
                {progress.storiesSeen[daily.date] ? 'WATCHED · TAP TO REPLAY' : 'NEW · TAP TO WATCH'}
              </Caption>
            </div>
            <MiniRhomb type="THICK" color={COLORS.accent} />
          </div>
          <div style={{ marginTop: 12, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.45 }}>
            A short multi-frame story about today's tiling. New each UTC day.
          </div>
        </Card>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          <Card onClick={() => goto('canvas')} accent={COLORS.textDim}>
            <Caption>CANVAS LAB</Caption>
            <BigTitle>∞</BigTitle>
            <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>SANDBOX</Caption>
          </Card>
          <Card onClick={() => goto('favorites')} accent={COLORS.goldA}>
            <Caption>FAVORITES</Caption>
            <BigTitle>{objectState.favorites.length}/3</BigTitle>
            <Caption style={{ color: COLORS.textMuted, marginTop: 6 }}>SAVED OBJECTS</Caption>
          </Card>
        </div>

        <PaletteChip />
      </div>
    </div>
  );
}

function PaletteChip() {
  const [_, force] = useState(0);
  useEffect(() => window.PenrosePalettes.subscribe(() => force(n => n + 1)), []);
  const cur = window.PenrosePalettes.active();
  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                  padding: '8px 14px', marginTop: 6,
                  border: '1px solid ' + COLORS.outline, borderRadius: 999 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ width: 10, height: 10, borderRadius: 2, background: COLORS.thick,
                       boxShadow: '0 0 8px ' + COLORS.thick + '88' }} />
        <span style={{ width: 10, height: 10, borderRadius: 2, background: COLORS.thin }} />
        <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
                       letterSpacing: '0.22em', color: COLORS.textMuted, marginLeft: 6 }}>
          {cur.name.toUpperCase()}
        </span>
      </div>
      <div onClick={cyclePalette} style={{
        fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.22em',
        color: COLORS.text, padding: '4px 10px',
        border: '1px solid ' + COLORS.outlineStrong, borderRadius: 999, cursor: 'pointer',
      }}>NEXT</div>
    </div>
  );
}

function cyclePalette() {
  const list = window.PenrosePalettes.list;
  const cur = window.PenrosePalettes.active();
  const idx = list.findIndex(p => p.id === cur.id);
  const next = list[(idx + 1) % list.length];
  window.PenrosePalettes.setActive(next.id);
  haptic(4);
}

function Card({ children, onClick, accent }) {
  return (
    <div onClick={onClick} style={{
      padding: '18px 18px 16px',
      borderRadius: 18,
      background: 'linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.015))',
      border: '1px solid ' + (accent ? accent + '33' : COLORS.outline),
      cursor: 'pointer', position: 'relative', overflow: 'hidden',
      transition: 'transform 0.1s 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.5 }} />}
      {children}
    </div>
  );
}

function Caption({ children, style }) {
  return <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.22em', fontWeight: 500, color: COLORS.text, ...style }}>{children}</div>;
}
function BigTitle({ children }) {
  return <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 28, fontWeight: 700, marginTop: 6, letterSpacing: '0.01em' }}>{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 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), 1500);
    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 = 0.5 + 0.5 * Math.sin(now / 350);
      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;
      });

      // Draw ripples.
      for (let i = ripplesRef.current.length - 1; i >= 0; i--) {
        const r = ripplesRef.current[i];
        const dt = (now - r.start) / 700;
        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]);

  // 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: '4px 20px 12px', display: 'flex', justifyContent: 'space-between',
        fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: COLORS.textMuted, letterSpacing: '0.18em',
      }}>
        <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: '8px 20px 6px', display: 'flex', gap: 6, justifyContent: 'center',
        flexWrap: 'wrap', fontFamily: 'JetBrains Mono, monospace',
      }}>
        {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: 10,
              border: `1px solid ${active ? COLORS.thickEdge : COLORS.outline}`,
              background: isCelebrating ? COLORS.thick : (active ? 'rgba(232,121,74,0.10)' : 'transparent'),
              transform: isCelebrating ? 'scale(1.15)' : 'scale(1)',
              transition: 'transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1), background 360ms, border-color 360ms',
              boxShadow: isCelebrating ? `0 0 18px ${COLORS.glow}` : 'none',
            }}>
              <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: isCelebrating ? COLORS.bg : (active ? COLORS.text : COLORS.textDim),
                fontVariantNumeric: 'tabular-nums',
              }}>{count}</span>
            </div>
          );
        })}
      </div>
      <div style={{
        padding: '4px 22px 26px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        fontFamily: 'JetBrains Mono, monospace',
      }}>
        <div>
          <div style={{ fontSize: 9, letterSpacing: '0.22em', color: COLORS.textMuted }}>REVEALED</div>
          <div style={{ fontSize: 22, fontWeight: 700, marginTop: 2 }}>{revealed.size}</div>
        </div>
        <button onClick={() => {
          setRevealed(new Set([centerId]));
          animRef.current.clear();
          ripplesRef.current = [];
          updateDailyState({ revealed: [centerId] });
        }} style={btnGhost}>RESET</button>
      </div>
    </div>
  );
}

const btnGhost = {
  height: 40, padding: '0 16px', borderRadius: 12,
  background: 'transparent', border: '1px solid ' + COLORS.outline,
  color: COLORS.text, fontFamily: 'JetBrains Mono, monospace',
  fontSize: 10, letterSpacing: '0.22em', fontWeight: 700, cursor: 'pointer',
};

const btnSolid = {
  height: 46, padding: '0 22px', borderRadius: 14,
  background: COLORS.accent, color: '#000',
  fontFamily: 'JetBrains Mono, monospace', fontSize: 11,
  letterSpacing: '0.2em', fontWeight: 700, cursor: 'pointer',
  border: 'none',
};

// ─── LEVEL MAP ────────────────────────────────────────────────────────────
function LevelMapScreen({ goto, progress, startLevel }) {
  const introDone = progress.tutorialCompleted || (progress.introLevelsCompleted || []).length >= 3;
  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 80px' }}>
        <div style={{
          padding: '6px 6px 16px', fontFamily: 'JetBrains Mono, monospace',
          fontSize: 10, letterSpacing: '0.22em', color: COLORS.textMuted,
        }}>
          {introDone ? 'INTRO DONE · ' : 'INTRO L1-L3 · '}
          {Math.min(progress.unlocked, 20)} / 20 UNLOCKED · {Math.min(progress.unlocked - 1, 20)} COMPLETED
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          {window.Levels.map((lvl) => {
            const intro = lvl.id <= 3;
            const locked = lvl.id > progress.unlocked;
            const done   = lvl.id <  progress.unlocked;
            return (
              <div key={lvl.id} onClick={() => !locked && startLevel(lvl.id)}
                style={{
                  position: 'relative', padding: '14px 12px 16px',
                  borderRadius: 14, border: '1px solid ' + (done ? COLORS.accent + '55' : locked ? COLORS.outline : COLORS.outlineStrong),
                  background: locked ? 'transparent' : (done ? 'rgba(255,138,101,0.06)' : 'rgba(255,255,255,0.025)'),
                  cursor: locked ? 'default' : 'pointer', opacity: locked ? 0.4 : 1,
                  aspectRatio: '1',
                  display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
                }}>
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                  <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, letterSpacing: '0.2em', color: COLORS.textMuted }}>
                    {String(lvl.id).padStart(2, '0')}
                  </span>
                  {intro && <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 8, color: COLORS.goldA, letterSpacing: '0.14em' }}>INTRO</span>}
                  {done && !intro && <span style={{ fontSize: 11, color: COLORS.accent, letterSpacing: '0.15em' }}>★</span>}
                  {locked && <span style={{ fontSize: 11, color: COLORS.textDim }}>🔒</span>}
                </div>
                <LevelThumb slots={lvl.slots} faded={locked} />
                <div style={{
                  fontFamily: 'JetBrains Mono, monospace', fontSize: 11, fontWeight: 700,
                  letterSpacing: '0.12em',
                }}>{lvl.title}</div>
              </div>
            );
          })}
          <div onClick={() => goto('paywall')} 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 }}>
              UNLOCK ALL
            </div>
            <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 16, fontWeight: 700, letterSpacing: '0.05em' }}>
              50+ MORE PATTERNS
            </div>
            <div style={{ fontSize: 11, color: COLORS.textMuted, marginTop: 2 }}>
              Sun, Star, Ace, King, Jack vertices. Daily streaks. Custom palettes.
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function LevelThumb({ slots, faded }) {
  const size = 70, pad = 0.4;
  const t = window.LevelEngine.fitTransform(slots, size, size, pad);
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ opacity: faded ? 0.5 : 1 }}>
      {slots.map((s, i) => {
        const verts = window.LevelEngine.rhombVerts(s).map(p => window.LevelEngine.worldToScreen(p, t));
        const d = verts.map((v, j) => (j === 0 ? 'M' : 'L') + v.x.toFixed(2) + ' ' + v.y.toFixed(2)).join(' ') + ' Z';
        return <path key={i} d={d}
          fill={s.type === 'THICK' ? COLORS.accent : COLORS.accent2}
          opacity="0.7" stroke="rgba(0,0,0,0.4)" 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 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.
      const next = progressAfterLevelComplete(progress, levelId);
      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) / 700;
        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]);

  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={{ padding: '0 22px 8px', fontSize: 11, color: COLORS.textMuted, fontFamily: 'JetBrains Mono, monospace', letterSpacing: '0.16em' }}>
        {level.hint}
      </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' }} />
        {wonAt > 0 && <FigureRevealOverlay level={level} startedAt={wonAt} />}
      </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={btnGhost}>RESET</button>
        {wonAt > 0 ? (
          hasNext
            ? <button onClick={() => goto('level:' + nextId)} style={btnSolid}>NEXT →</button>
            : <button onClick={() => goto('paywall')} style={{...btnSolid, background: COLORS.goldA}}>UNLOCK MORE</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 }) {
  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]);

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      alignItems: 'center', justifyContent: 'flex-end', padding: '0 22px 28px',
      pointerEvents: 'none', opacity,
    }}>
      <div style={{
        fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
        letterSpacing: '0.35em', color: COLORS.textDim, marginBottom: 10,
      }}>FIGURE REVEALED</div>
      <div style={{
        fontFamily: 'JetBrains Mono, monospace', fontSize: 30, fontWeight: 700,
        letterSpacing: '0.16em', color: COLORS.text,
        textShadow: `0 0 24px ${COLORS.glow}66`,
      }}>{(level.figure || level.title).toUpperCase()}</div>
      <div style={{
        fontFamily: 'JetBrains Mono, monospace', fontSize: 11,
        letterSpacing: '0.18em', color: COLORS.textMuted, marginTop: 10,
      }}>{level.caption || ''}</div>
    </div>
  );
}

// ─── Patch-based level engine ────────────────────────────────────────────
// Levels migrated to this engine show a real de-Bruijn Penrose patch with
// the target figure highlighted inside it. Fixed pattern offset per level
// (deterministic — same Sun every time you open Sun).

// Find every vertex with `n` tiles of a given type at acute corners.
// If `exact`, returns only vertices with EXACTLY n; else >= n.
function findVerticesAt(tiles, type, n, { exact = true } = {}) {
  const vMap = new Map();
  tiles.forEach((t, i) => t.vertexKeys.forEach(vk => {
    if (!vMap.has(vk)) vMap.set(vk, []);
    vMap.get(vk).push(i);
  }));
  const out = [];
  for (const [vk, ids] of vMap) {
    const matching = ids.filter(id => {
      const t = tiles[id];
      if (t.type !== type) return false;
      const v = t.verts;
      const d02 = (v[0].x - v[2].x) ** 2 + (v[0].y - v[2].y) ** 2;
      const d13 = (v[1].x - v[3].x) ** 2 + (v[1].y - v[3].y) ** 2;
      // For a rhomb with acute < 90°, the LONG diagonal connects the two
      // acute corners (verified: thick rhomb side=1 has acute-to-acute = φ).
      const acuteKeys = d02 > d13
        ? [t.vertexKeys[0], t.vertexKeys[2]]
        : [t.vertexKeys[1], t.vertexKeys[3]];
      return acuteKeys.includes(vk);
    });
    const pass = exact ? matching.length === n : matching.length >= n;
    if (pass) {
      const ref = tiles[matching[0]];
      const vPos = ref.verts[ref.vertexKeys.indexOf(vk)];
      out.push({ vertexKey: vk, vertexPos: vPos, tileIds: exact ? matching : matching.slice(0, n) });
    }
  }
  return out;
}

// Keep old name as alias for the proto code path.
const findAcuteVertexFigures = (tiles, n, type) => findVerticesAt(tiles, type, n, { exact: true });

// Build a canonical signature for a vertex: sorted "Ta,To,ta,to" tuple where
// T = thick, t = thin, a = acute corner here, o = obtuse corner here.
// Sum of corner angles at this vertex = 360° for interior vertices.
function vertexSignatureAt(tiles, ids, vk) {
  return ids.map(id => {
    const t = tiles[id];
    const v = t.verts;
    const d02 = (v[0].x - v[2].x) ** 2 + (v[0].y - v[2].y) ** 2;
    const d13 = (v[1].x - v[3].x) ** 2 + (v[1].y - v[3].y) ** 2;
    const acuteKeys = d02 > d13
      ? [t.vertexKeys[0], t.vertexKeys[2]]
      : [t.vertexKeys[1], t.vertexKeys[3]];
    const isAcute = acuteKeys.includes(vk);
    return (t.type === 'thick' ? 'T' : 't') + (isAcute ? 'a' : 'o');
  }).sort().join(',');
}

// Find a Sun vertex (5 thick-acutes), optionally extended by edge-adjacent
// rings of specified type/count. Used for L7 (Sun-1), L8 Crescent, L9
// Decagon, L10 Cartwheel.
function findSunWithRing(tiles, opts) {
  const suns = findVerticesBySignature(tiles, 'Ta,Ta,Ta,Ta,Ta');
  if (!suns.length) return null;
  suns.sort((a, b) => (a.vertexPos.x ** 2 + a.vertexPos.y ** 2) -
                      (b.vertexPos.x ** 2 + b.vertexPos.y ** 2));
  const sun = suns[0];

  if (opts.subset != null) {
    return { tileIds: sun.tileIds.slice(0, opts.subset), vertexPos: sun.vertexPos };
  }

  // First ring: tiles edge-adjacent to Sun.
  const sunSet = new Set(sun.tileIds);
  const ring = new Set();
  for (const sid of sun.tileIds) {
    for (const nId of tiles[sid].neighbors) {
      if (!sunSet.has(nId)) ring.add(nId);
    }
  }
  let ringArr = [...ring];
  if (opts.ringType) ringArr = ringArr.filter(id => tiles[id].type === opts.ringType);
  ringArr.sort((a, b) =>
    Math.hypot(tiles[a].mean.x - sun.vertexPos.x, tiles[a].mean.y - sun.vertexPos.y) -
    Math.hypot(tiles[b].mean.x - sun.vertexPos.x, tiles[b].mean.y - sun.vertexPos.y));
  const ringPick = ringArr.slice(0, opts.ringCount || 0);

  let outerPick = [];
  if (opts.outerCount) {
    const knownSet = new Set([...sun.tileIds, ...ringPick]);
    const outer = new Set();
    for (const rid of ringPick) {
      for (const nId of tiles[rid].neighbors) {
        if (!knownSet.has(nId)) outer.add(nId);
      }
    }
    let outerArr = [...outer];
    if (opts.outerType) outerArr = outerArr.filter(id => tiles[id].type === opts.outerType);
    outerArr.sort((a, b) =>
      Math.hypot(tiles[a].mean.x - sun.vertexPos.x, tiles[a].mean.y - sun.vertexPos.y) -
      Math.hypot(tiles[b].mean.x - sun.vertexPos.x, tiles[b].mean.y - sun.vertexPos.y));
    outerPick = outerArr.slice(0, opts.outerCount);
  }

  return { tileIds: [...sun.tileIds, ...ringPick, ...outerPick], vertexPos: sun.vertexPos };
}

// Two Suns sharing a thin neighbor — Twin Sun cluster.
function findTwinSuns(tiles) {
  const suns = findVerticesBySignature(tiles, 'Ta,Ta,Ta,Ta,Ta');
  if (suns.length < 2) return null;
  // Find the closest pair (excluding identical points).
  let best = null, bestDist = Infinity;
  for (let i = 0; i < suns.length; i++) {
    for (let j = i + 1; j < suns.length; j++) {
      const dx = suns[i].vertexPos.x - suns[j].vertexPos.x;
      const dy = suns[i].vertexPos.y - suns[j].vertexPos.y;
      const d = Math.hypot(dx, dy);
      if (d > 0.1 && d < bestDist) { bestDist = d; best = [i, j]; }
    }
  }
  if (!best) return null;
  const sA = suns[best[0]], sB = suns[best[1]];
  return {
    tileIds: [...new Set([...sA.tileIds, ...sB.tileIds])],
    vertexPos: { x: (sA.vertexPos.x + sB.vertexPos.x) / 2, y: (sA.vertexPos.y + sB.vertexPos.y) / 2 },
  };
}

// Find a chain of `typeSeq.length` tiles connected by edge-adjacency
// (using tile.neighbors), where each tile's type matches the sequence.
// Returns { tileIds, vertexPos } at the centroid of the chain, or null.
function findEdgeSharedChain(tiles, typeSeq) {
  function search(path) {
    if (path.length === typeSeq.length) return path;
    const lastId = path[path.length - 1];
    for (const nId of tiles[lastId].neighbors) {
      if (path.includes(nId)) continue;
      if (tiles[nId].type !== typeSeq[path.length]) continue;
      const result = search([...path, nId]);
      if (result) return result;
    }
    return null;
  }
  // Try every tile as the starting point.
  for (let i = 0; i < tiles.length; i++) {
    if (tiles[i].type !== typeSeq[0]) continue;
    const result = search([i]);
    if (result) {
      let cx = 0, cy = 0;
      for (const id of result) { cx += tiles[id].mean.x; cy += tiles[id].mean.y; }
      return { tileIds: result, vertexPos: { x: cx / result.length, y: cy / result.length } };
    }
  }
  return null;
}

// Find a Decapod cluster: a Star vertex (5 thins acutes) plus 5 edge-adjacent
// outer tiles forming a 10-rhomb decagonal shape.
function findDecapodCluster(tiles) {
  const stars = findVerticesAt(tiles, 'thin', 5, { exact: true });
  if (!stars.length) return null;
  stars.sort((a, b) => (a.vertexPos.x ** 2 + a.vertexPos.y ** 2) -
                       (b.vertexPos.x ** 2 + b.vertexPos.y ** 2));
  for (const star of stars) {
    const starSet = new Set(star.tileIds);
    // For each thin in star, pick the neighbor across its long-far edge.
    // We don't strictly check edge orientation — first non-star neighbor works.
    const ring = new Set();
    for (const thinId of star.tileIds) {
      for (const nId of tiles[thinId].neighbors) {
        if (starSet.has(nId)) continue;
        ring.add(nId);
      }
    }
    if (ring.size < 5) continue;
    // Pick the 5 ring tiles nearest to the star vertex.
    const ringArr = [...ring].map(id => ({
      id,
      d: (tiles[id].mean.x - star.vertexPos.x) ** 2 + (tiles[id].mean.y - star.vertexPos.y) ** 2,
    }));
    ringArr.sort((a, b) => a.d - b.d);
    const picked = ringArr.slice(0, 5).map(x => x.id);
    return {
      tileIds: [...star.tileIds, ...picked],
      vertexPos: star.vertexPos,
    };
  }
  return null;
}

// Find vertices whose signature matches exactly (e.g., "Ta,To,ta,to" = Ace).
function findVerticesBySignature(tiles, signature) {
  const vMap = new Map();
  tiles.forEach((t, i) => t.vertexKeys.forEach(vk => {
    if (!vMap.has(vk)) vMap.set(vk, []);
    vMap.get(vk).push(i);
  }));
  const out = [];
  for (const [vk, ids] of vMap) {
    if (vertexSignatureAt(tiles, ids, vk) === signature) {
      const ref = tiles[ids[0]];
      const vPos = ref.verts[ref.vertexKeys.indexOf(vk)];
      out.push({ vertexKey: vk, vertexPos: vPos, tileIds: ids });
    }
  }
  return out;
}

// Deterministic pattern offset in [0.10, 0.90] from a level id.
function patternForLevel(levelId) {
  const h = ((levelId * 2654435761) >>> 0) % 1000;
  return 0.10 + (h / 1000) * 0.80;
}
// 4-char hex watermark from a pattern offset.
function patchIdFromPattern(pattern) {
  let h = Math.floor(pattern * 1e6) | 0;
  h ^= h << 13; h ^= h >>> 17; h ^= h << 5;
  return (h >>> 0).toString(16).toUpperCase().slice(0, 4).padStart(4, '0');
}

// Resolve a figure spec to selected tiles inside a tiling.
// Returns { tileIds, vertexPos } or null if not found in this patch.
function findFigureInTiling(tiles, spec) {
  if (!tiles || !tiles.length) return null;
  if (spec.kind === 'anyTile') {
    const t = tiles.find(tt => tt.type === spec.type);
    return t ? { tileIds: [t.id], vertexPos: t.mean } : null;
  }
  if (spec.kind === 'pairTypes') {
    // Pick one thick and one thin that are reasonably close — for showcasing
    // both shapes at once.
    const thick = tiles.find(t => t.type === 'thick');
    if (!thick) return null;
    // Nearest thin to that thick.
    let bestThin = null, bestDist = Infinity;
    for (const t of tiles) {
      if (t.type !== 'thin') continue;
      const d = Math.hypot(t.mean.x - thick.mean.x, t.mean.y - thick.mean.y);
      if (d < bestDist) { bestDist = d; bestThin = t; }
    }
    if (!bestThin) return { tileIds: [thick.id], vertexPos: thick.mean };
    return {
      tileIds: [thick.id, bestThin.id],
      vertexPos: { x: (thick.mean.x + bestThin.mean.x) / 2, y: (thick.mean.y + bestThin.mean.y) / 2 },
    };
  }
  if (spec.kind === 'vertexN') {
    const found = findVerticesAt(tiles, spec.type, spec.n, { exact: spec.exact !== false });
    if (!found.length) return null;
    found.sort((a, b) => (a.vertexPos.x ** 2 + a.vertexPos.y ** 2) -
                        (b.vertexPos.x ** 2 + b.vertexPos.y ** 2));
    return { tileIds: found[0].tileIds, vertexPos: found[0].vertexPos };
  }
  if (spec.kind === 'vertexSig') {
    const found = findVerticesBySignature(tiles, spec.signature);
    if (!found.length) return null;
    found.sort((a, b) => (a.vertexPos.x ** 2 + a.vertexPos.y ** 2) -
                        (b.vertexPos.x ** 2 + b.vertexPos.y ** 2));
    return { tileIds: found[0].tileIds, vertexPos: found[0].vertexPos };
  }
  if (spec.kind === 'chain') {
    return findEdgeSharedChain(tiles, spec.typeSeq);
  }
  if (spec.kind === 'decapod') {
    return findDecapodCluster(tiles);
  }
  if (spec.kind === 'sunWithRing') {
    return findSunWithRing(tiles, spec);
  }
  if (spec.kind === 'twinSuns') {
    return findTwinSuns(tiles);
  }
  return null;
}

// Registry of figures migrated to the new patch engine.
// Day 1 set: 5 partial / canonical vertex figures findable in any patch.
// Each entry overrides the legacy slot-based level for that id.
const FIGURES_V2 = {
  1:  { title: 'TWO RHOMBS', figure: 'T + t',     caption: 'Thick · 72° acute. Thin · 36° acute.',
        hint: 'The whole mosaic is built from just these two rhombs.',
        spec: { kind: 'pairTypes' } },
  2:  { title: '2T·ACUTE', figure: '2T acute',   caption: 'Pair of thicks at one vertex.',
        hint: 'Two thicks share one acute corner.',
        spec: { kind: 'vertexN', type: 'thick', n: 2 } },
  3:  { title: '3T·ACUTE', figure: '3T acute',   caption: 'Triplet of thicks at one vertex.',
        hint: 'Three thicks share one acute corner.',
        spec: { kind: 'vertexN', type: 'thick', n: 3 } },
  5:  { title: '2t·ACUTE', figure: '2t acute',   caption: 'Pair of thins at one vertex.',
        hint: 'Two thins share one acute corner.',
        spec: { kind: 'vertexN', type: 'thin',  n: 2 } },
  11: { title: 'STAR',     figure: 'Star (D₅t)', caption: '5 thins · 180° at vertex.',
        hint: 'Five thins meet at one acute vertex.',
        spec: { kind: 'vertexN', type: 'thin',  n: 5 } },

  // Days 2 + 4 — all 7 canonical Penrose vertex stars (interior 360°).
  // After fixing the type-label bug in geometry.js, every canonical figure
  // appears in the standard multigrid output.
  4:  { title: 'SUN',      figure: 'Sun (D₅T)',  caption: '5T-acute · 360°.',
        hint: 'Five thicks meeting at one acute vertex — canonical Sun.',
        spec: { kind: 'vertexSig', signature: 'Ta,Ta,Ta,Ta,Ta', maxAttempts: 24, radius: 24 } },
  6:  { title: 'ACE',      figure: 'Ace',        caption: '1T-a + 1T-o + 1t-a + 1t-o · 360°.',
        hint: 'Canonical Penrose Ace vertex — one of seven.',
        spec: { kind: 'vertexSig', signature: 'Ta,To,ta,to' } },
  11: { title: 'STAR',     figure: 'Star (D₁₀t)', caption: '10 thins at one vertex · 360°.',
        hint: 'Ten thins meeting at one acute vertex — canonical Star.',
        spec: { kind: 'vertexSig', signature: 'ta,ta,ta,ta,ta,ta,ta,ta,ta,ta', maxAttempts: 60, radius: 28 } },
  12: { title: 'DEUCE',    figure: 'Deuce',      caption: '1T-a + 2t-o · 360°.',
        hint: 'Canonical Penrose Deuce — thick spear flanked by two thins.',
        spec: { kind: 'vertexSig', signature: 'Ta,to,to' } },
  14: { title: 'QUEEN',    figure: 'Queen',      caption: '1T-a + 2T-o + 2t-a · 360°.',
        hint: 'Canonical Penrose Queen vertex.',
        spec: { kind: 'vertexSig', signature: 'Ta,To,To,ta,ta', maxAttempts: 24, radius: 24 } },
  15: { title: 'JACK',     figure: 'Jack',       caption: '4T-a + 2t-a · 360°.',
        hint: 'Canonical Penrose Jack vertex — four thicks plus two thins.',
        spec: { kind: 'vertexSig', signature: 'Ta,Ta,Ta,Ta,ta,ta', maxAttempts: 60, radius: 28 } },
  16: { title: 'KING',     figure: 'King',       caption: '3T-a + 4t-a · 360°.',
        hint: 'Canonical Penrose King vertex.',
        spec: { kind: 'vertexSig', signature: 'Ta,Ta,Ta,ta,ta,ta,ta', maxAttempts: 24, radius: 24 } },

  // Day 3 — chains (worms) + Decapod cluster.
  13: { title: 'DECAPOD',  figure: 'Decapod',    caption: 'Star + 5 outer · 10 rhombs.',
        hint: 'A Star vertex plus its five outer neighbors.',
        spec: { kind: 'decapod', radius: 20 } },
  17: { title: 'WORM-3',   figure: 'Worm (3)',   caption: 'T-t-T · 3-tile chain.',
        hint: 'Thick-thin-thick threading the patch.',
        spec: { kind: 'chain', typeSeq: ['thick', 'thin', 'thick'] } },
  18: { title: 'WORM-5',   figure: 'Worm (5)',   caption: 'T-t-T-t-T · 5-tile chain.',
        hint: 'Five rhombs in a single thread.',
        spec: { kind: 'chain', typeSeq: ['thick', 'thin', 'thick', 'thin', 'thick'] } },
  19: { title: 'WORM-7',   figure: 'Worm (7)',   caption: 'T-t-T-t-T-t-T · 7-tile chain.',
        hint: 'Seven rhombs braided across the patch.',
        spec: { kind: 'chain', typeSeq: ['thick', 'thin', 'thick', 'thin', 'thick', 'thin', 'thick'] } },

  // Day 4 — Sun-derived clusters (Sun + concentric rings).
  7:  { title: '4T·ACUTE', figure: '4T acute',   caption: 'Partial Sun · 4 of 5 thicks.',
        hint: 'Four of the five Sun thicks — one petal missing.',
        spec: { kind: 'sunWithRing', subset: 4, maxAttempts: 24, radius: 24 } },
  8:  { title: 'CRESCENT', figure: 'Crescent',   caption: 'Sun + 3 outer thins.',
        hint: 'A Sun with three outer thin petals — half a Decagon.',
        spec: { kind: 'sunWithRing', ringType: 'thin', ringCount: 3, maxAttempts: 24, radius: 24 } },
  9:  { title: 'DECAGON',  figure: 'Decagon',    caption: 'Sun + 5 outer thins · ten rhombs.',
        hint: 'Sun completely ringed by five outer thins.',
        spec: { kind: 'sunWithRing', ringType: 'thin', ringCount: 5, maxAttempts: 24, radius: 24 } },
  10: { title: 'CARTWHEEL',figure: 'Cartwheel',  caption: 'Sun + 5 thins + 5 outer thicks · 15 rhombs.',
        hint: 'Penrose Cartwheel patch — 15 rhombs of pure five-fold beauty.',
        spec: { kind: 'sunWithRing', ringType: 'thin', ringCount: 5, outerType: 'thick', outerCount: 5, maxAttempts: 24, radius: 26 } },
  20: { title: 'TWIN SUNS',figure: 'Twin Suns',  caption: 'Two D₅T sharing a region.',
        hint: 'Two complete Sun vertices — closest pair in the patch.',
        spec: { kind: 'twinSuns', maxAttempts: 24, radius: 28 } },
};

// 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 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 animRef = useRef(new Map());
  const ripplesRef = useRef([]);

  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) return;
    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) {
        const next = progressAfterLevelComplete(progress, levelId);
        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]);

  const patchId = useMemo(() => patchIdFromPattern(pattern), [pattern]);

  // 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 = 0.6 + 0.4 * Math.sin(now / 420);
      targetIds.forEach(id => {
        if (filledRef.current.has(id)) return;
        const t = tiling[id];
        const edge = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
        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 = edge;
        ctx.globalAlpha = 0.10 + 0.06 * pulse;
        ctx.stroke(path);
        ctx.setLineDash([5, 4]);
        ctx.lineWidth = 1.6;
        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) / 460;
          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 = 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) / 700;
        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]);

  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);
        return;
      }
    }
  }, [tiling, transform]);

  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={{
        padding: '0 22px 12px', fontSize: 13, color: COLORS.textMuted,
        fontFamily: 'JetBrains Mono, monospace', letterSpacing: '0.14em',
        textAlign: 'center', lineHeight: 1.5,
      }}>
        {hint}
      </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' }} />
        {/* Patch watermark — top-right */}
        <div style={{
          position: 'absolute', top: 6, right: 12, textAlign: 'right',
          fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
          letterSpacing: '0.22em', color: COLORS.textDim, pointerEvents: 'none',
          lineHeight: 1.5,
        }}>
          PATCH · {patchId}<br/>
          xy · {vertexPos ? vertexPos.x.toFixed(2) : '0.00'},&nbsp;{vertexPos ? vertexPos.y.toFixed(2) : '0.00'}
        </div>
        {/* 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.outlineStrong}`, background: 'rgba(13,18,38,0.7)',
              color: COLORS.text, fontFamily: 'JetBrains Mono, monospace', fontSize: 16,
              cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
              padding: 0,
            }}>{sym}</button>
          ))}
        </div>
        {wonAt > 0 && <FigureRevealOverlay level={{ figure, caption }} startedAt={wonAt} />}
      </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={btnGhost}>RESET</button>
        {wonAt > 0 ? (
          hasNext
            ? <button onClick={() => goto('level:' + nextId)} style={btnSolid}>NEXT →</button>
            : (isProto
                ? null
                : <button onClick={() => goto('paywall')} style={{...btnSolid, background: COLORS.goldA}}>UNLOCK MORE</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 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 [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: 'TWO 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: 'APERIODIC',
      lines: [
        'Sir Roger Penrose, 1974.',
        'The pattern never repeats —',
        'yet has 5-fold symmetry.',
      ],
    },
    {
      visual: (
        <div style={{ animation: 'tessera-float 3.4s ease-in-out infinite' }}>
          <DynamicLogo size={120} scale={14} />
        </div>
      ),
      title: 'REVEAL · BUILD',
      lines: [
        'Tap to reveal today\'s mosaic.',
        'Or build named figures,',
        'level by level.',
      ],
      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: 'JetBrains Mono, monospace', fontSize: 26, fontWeight: 700,
          letterSpacing: '0.22em', color: COLORS.text,
          textShadow: `0 0 28px ${COLORS.glow}55`, 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: 52, borderRadius: 14,
            border: 'none', background: idx === last ? COLORS.thick : 'transparent',
            color: idx === last ? COLORS.bg : COLORS.text,
            border: idx === last ? 'none' : `1px solid ${COLORS.outlineStrong}`,
            fontFamily: 'JetBrains Mono, monospace', fontSize: 12, fontWeight: 700,
            letterSpacing: '0.3em', cursor: 'pointer',
          }}>
          {idx === last ? 'START' : 'NEXT →'}
        </button>
      </div>
    </div>
  );
}

// ─── PAYWALL ──────────────────────────────────────────────────────────────
function PaywallScreen({ goto }) {
  const tiers = [
    { id: 'm',  label: 'MONTHLY',  price: '$2.99',   sub: 'per month'   },
    { id: 'y',  label: 'YEARLY',   price: '$14.99',  sub: 'per year · save 58%', best: true },
    { id: 'l',  label: 'LIFETIME', price: '$29.99',  sub: 'one-time'    },
  ];
  const [picked, setPicked] = useState('y');

  return (
    <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: COLORS.bg }}>
      <TopBar title="Unlock all" 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' }}>PENROSE PRO</div>
          <div style={{ fontSize: 26, fontWeight: 700, marginTop: 6, letterSpacing: '0.02em' }}>50+ patterns</div>
          <div style={{ fontSize: 13, color: COLORS.textMuted, marginTop: 10, lineHeight: 1.55 }}>
            Sun, Star, Ace, King, Jack vertices · daily streaks · custom palettes · exports.
          </div>
        </div>

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
          {tiers.map(t => (
            <div key={t.id} onClick={() => setPicked(t.id)} style={{
              padding: '14px 16px',
              border: '1px solid ' + (picked === t.id ? COLORS.goldA : COLORS.outline),
              borderRadius: 14, cursor: 'pointer',
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              background: picked === t.id ? 'rgba(255,213,107,0.06)' : 'transparent',
              position: 'relative',
            }}>
              {t.best && <div style={{
                position: 'absolute', top: -8, right: 14,
                background: COLORS.goldA, color: '#000',
                fontFamily: 'JetBrains Mono, monospace', fontSize: 9, fontWeight: 700,
                letterSpacing: '0.18em', padding: '3px 8px', borderRadius: 6,
              }}>BEST VALUE</div>}
              <div>
                <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 12, letterSpacing: '0.22em', fontWeight: 700 }}>{t.label}</div>
                <div style={{ fontSize: 11, color: COLORS.textMuted, marginTop: 4 }}>{t.sub}</div>
              </div>
              <div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 18, fontWeight: 700 }}>{t.price}</div>
            </div>
          ))}
        </div>

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

        <button onClick={() => { /* inert per spec */ }} style={{
          ...btnSolid, background: COLORS.goldA, color: '#000', height: 52,
          marginTop: 16,
        }}>CONTINUE</button>
        <button onClick={() => goto('levelmap')} style={{
          ...btnGhost, marginTop: 8, height: 44, alignSelf: 'center',
          border: 'none', background: 'transparent', color: COLORS.textMuted,
        }}>MAYBE LATER</button>
      </div>
    </div>
  );
}

// ─── ROOT ─────────────────────────────────────────────────────────────────
function PenroseApp() {
  const [progress, setProgress] = useState(loadProgress);
  const [screen, setScreen] = useState('object');
  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 markStorySeen = useCallback(() => {
    setProgress(prev => {
      const next = { ...prev, storiesSeen: { ...prev.storiesSeen, [daily.date]: true } };
      saveProgress(next);
      return next;
    });
  }, [daily.date]);

  let content;
  if (screen === 'object') content = <window.PenroseObject.ObjectScreen goto={goto} daily={daily} progress={progress} />;
  else if (screen === 'home') content = <HomeScreen goto={goto} progress={progress} daily={daily} />;
  else if (screen === 'story') content = <window.PenroseStories.StoryScreen goto={goto} daily={daily} progress={progress} onSeen={markStorySeen} />;
  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 === 'canvas') content = <window.PenroseCanvas.CanvasScreen goto={goto} daily={daily} />;
  else if (screen === 'favorites') content = <window.PenroseObject.FavoritesScreen goto={goto} />;
  else if (screen === 'paywall') content = <PaywallScreen goto={goto} />;
  else if (screen === 'proto-sun') content = <ProtoSunScreen goto={goto} />;

  return (
    <div style={{ position: 'absolute', inset: 0, background: COLORS.bg, color: COLORS.text, overflow: 'hidden' }}>
      {content}
      <SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} goto={goto} />
    </div>
  );
}

window.PenroseApp = PenroseApp;
