// Tessra v3.3 stable — release candidate.
// Screens: Home, Daily, LevelMap, LevelPlay, About, 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-v33-progress';
const LEGACY_PROGRESS_KEY = 'penrose-v32-progress';
function loadProgress() {
  try {
    let raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) {
      raw = localStorage.getItem(LEGACY_PROGRESS_KEY);
      if (raw) {
        try { localStorage.setItem(STORAGE_KEY, raw); } catch (e) {}
      }
    }
    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) {}
}

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

const WAITLIST_KEY = 'penrose-v33-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 (Soft Atlas in production — themes.js) ────────────────
const ColorsContext = React.createContext(window.TessraThemes ? window.TessraThemes.active : {});
function useC() { return React.useContext(ColorsContext); }

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

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

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

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

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

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

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

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

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

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

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

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

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

function boundaryOutlineAlpha(now, introStart) {
  const age = now - introStart;
  if (age < 1400) {
    const fadeIn = Math.min(1, age / 320);
    const pulse = 0.55 + 0.35 * Math.sin(now / 280);
    return fadeIn * pulse;
  }
  if (age < 2600) {
    const t = (age - 1400) / 1200;
    return 0.42 + (0.14 - 0.42) * t;
  }
  return 0.12 + 0.03 * Math.sin(now / 2400);
}

function winSnapshotFromSlots(slots, canvasW, canvasH) {
  const LE = window.LevelEngine;
  if (!LE || !LE.outerBoundaryLoops) return null;
  const w = Math.max(1, canvasW || 1);
  const h = Math.max(1, canvasH || 1);
  const t = LE.fitTransform(slots, w, h, 0.6);
  const polys = slots.map(s =>
    LE.rhombVerts(s).map(p => LE.worldToScreen(p, t))
  );
  const loops = LE.outerBoundaryLoops(polys);
  if (!loops.length) return null;
  const { cx, cy } = LE.loopsCentroid(loops);
  const d = LE.loopsToSvgD(loops);
  if (!d) return null;
  return { d, cx, cy, width: w, height: h };
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ─── Side menu ────────────────────────────────────────────────────────────
function SideMenu({ open, onClose, goto, themeId, onPickTheme }) {
  const COLORS = useC();
  if (!open) return null;
  const items = [
    { id: 'home',     label: 'Home' },
    { id: 'daily',    label: 'Mosaic of the Day' },
    { id: 'levelmap', label: 'Levels' },
    { id: 'about',    label: 'About' },
    { id: 'waitlist', label: 'Full version' },
    ...(window.TessraThemes.production === false ? [{ id: 'proto-sun', label: 'Proto · Sun' }] : []),
  ];
  const palettes = window.TessraThemes.showPicker ? window.TessraThemes.list : [];
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 70,
      background: COLORS.overlayBg,
      display: 'flex', flexDirection: 'column', padding: '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>
      {palettes.length > 1 && (
        <div style={{ marginBottom: 20 }} onClick={(e) => e.stopPropagation()}>
          <MoodPicker palettes={palettes} themeId={themeId} onPick={onPickTheme} />
        </div>
      )}
      <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12 }}>
        <DynamicLogo size={56} scale={7} glow={false} animate={false} />
        <BnbStudioLogo height={26} style={{ paddingBottom: 4 }} />
      </div>
    </div>
  );
}

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

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

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

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

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

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

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

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

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

        <Card onClick={() => goto('levelmap')} accent={COLORS.accent2}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
            <div>
              <BigTitle color={COLORS.accent2}>Levels</BigTitle>
              <Caption style={{ marginTop: 8 }}>{unlocked}/20</Caption>
              <Caption style={{ color: COLORS.textDim, marginTop: 4 }}>
                {completed >= 20 ? 'COMPLETED' : (completed > 0 ? 'CONTINUE' : 'BEGIN')}
              </Caption>
            </div>
            <MiniRhomb type="THIN" color={COLORS.accent2} outline />
          </div>
          <div style={{ marginTop: 18, fontSize: 12, color: COLORS.textMuted, lineHeight: 1.5 }}>
            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, color }) {
  const COLORS = useC();
  return <div style={{ fontFamily: SERIF, fontSize: 30, fontWeight: 600, marginTop: 6, letterSpacing: '-0.01em', color: color || COLORS.text }}>{children}</div>;
}

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

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

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

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

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

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

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

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

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

function DailyScreen({ goto, daily, dailyOptions, dailyIndex, onSelectDaily, dailyByDate = {}, dailyState, updateDailyState }) {
  const COLORS = useC();
  const canvasRef = useRef(null);
  const [tiling, setTiling] = useState(null);
  const [centerId, setCenterId] = useState(-1);
  const [revealed, setRevealed] = useState(() => new Set(dailyState.revealed || []));
  const animRef = useRef(new Map()); // tileId → animation start time
  const ripplesRef = useRef([]); // [{x, y, start}] in world coords
  const figurePulsesRef = useRef([]); // [{x, y, start, tint, tileIds}]
  const prevRevealedRef = useRef(new Set());
  const introRef = useRef(null);   // { start } while the zoom-in intro is playing

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

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

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

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

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

  // Generate tiling for the selected daily seed.
  useEffect(() => {
    const tiles = window.PenroseGeo.generateTiling({ radius: 18, pattern: daily.pattern });
    const c = window.PenroseGeo.findCenterTile(tiles);
    const saved = Array.isArray(dailyState.revealed)
      ? dailyState.revealed.filter(id => tiles[id])
      : [];
    setTiling(tiles);
    setCenterId(c);
    animRef.current.clear();
    ripplesRef.current = [];
    figurePulsesRef.current = [];
    setCelebrating(null);
    prevRevealedRef.current = new Set();
    prevCountsRef.current = Object.fromEntries(DAILY_FIGURES.map(f => [f.key, 0]));
    // Don't clobber an intro already playing for this same day — revealing the
    // first tile re-runs this effect (dailyState.revealed changes) and we must
    // let the zoom-in finish.
    const introPlayingThisDay = introRef.current && introRef.current.date === daily.date;
    if (saved.length > 0) {
      setRevealed(new Set(saved));
      prevRevealedRef.current = new Set(saved);
      if (!introPlayingThisDay) introRef.current = null;
    } else {
      const initial = new Set([c]);
      setRevealed(initial);
      prevRevealedRef.current = new Set(initial);
      updateDailyState({ revealed: [c] });
      // Fresh day → play the zoom-out → zoom-in intro once.
      introRef.current = REDUCED_MOTION ? null : { start: performance.now(), date: daily.date };
    }
  }, [daily.date, daily.pattern, daily.patternId, dailyState.revealed, updateDailyState]);

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

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

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

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

      const now = performance.now();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

function LevelMapScreen({ goto, progress, startLevel }) {
  const COLORS = useC();
  const sections = window.AtlasSections || [];
  const total = window.Levels.length;
  const completed = Math.max(0, Math.min(progress.unlocked - 1, total));
  const unlocked = Math.min(progress.unlocked, total);
  const currentId = Math.max(1, Math.min(progress.unlocked, total));
  const percent = Math.round((completed / total) * 100);

  const scrollRef = useRef(null);
  const stationRefs = useRef({});

  const orderedSections = useMemo(
    () => sections.map((section) => ({
      ...section,
      branchColor: atlasBranchColor(COLORS, section.branch),
      levels: window.Levels.filter((lvl) => lvl.id >= section.from && lvl.id <= section.to),
    })),
    [sections, COLORS.id],
  );

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

  return (
    <div style={{
      position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
      background: `radial-gradient(circle at 50% 12%, ${COLORS.surfaceElevated}, transparent 34%), linear-gradient(180deg, ${COLORS.bgPanel}, ${COLORS.bg} 62%)`,
    }}>
      <TopBar title="Levels" onMenu={() => goto('menu')} onBack={() => goto('home')} />
      <div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', padding: '4px 20px 30px' }}>
        <div style={{ maxWidth: 340, margin: '0 auto', padding: '8px 0 4px', textAlign: 'center' }}>
          <div style={{
            fontFamily: SERIF, fontSize: 31, fontWeight: 600,
            letterSpacing: '-0.01em', lineHeight: 1.05, color: COLORS.text,
          }}>Twenty quiet figures</div>
          <div style={{
            marginTop: 10, fontSize: 11.5, lineHeight: 1.55,
            color: COLORS.textMuted,
          }}>
            A route through Penrose families, from paired rhombs to nested stars.
          </div>

          <div style={{
            display: 'grid',
            gridTemplateColumns: '1fr 1fr 1fr',
            gap: 12,
            marginTop: 20,
            textAlign: 'center',
          }}>
            <AtlasMetric label="Done" value={`${completed}/${total}`} color={COLORS.goldA} />
            <AtlasMetric label="Open" value={String(unlocked).padStart(2, '0')} color={COLORS.accent} />
            <AtlasMetric label="Map" value={`${percent}%`} />
          </div>
        </div>

        <div style={{ maxWidth: 340, margin: '14px auto 0' }}>
          <div style={{
            height: 5,
            borderRadius: 999,
            background: COLORS.lockedSurface,
            overflow: 'hidden',
          }}>
            <div style={{
              width: `${percent}%`,
              height: '100%',
              background: `linear-gradient(90deg, ${COLORS.accent}, ${COLORS.goldA})`,
            }} />
          </div>
        </div>

        <div style={{ maxWidth: 340, margin: '0 auto', padding: '12px 0 10px' }}>
          {orderedSections.map((section) => (
            <div key={section.id}>
              {section.levels.map((lvl) => {
                const locked = lvl.id > progress.unlocked;
                const done = lvl.id < progress.unlocked;
                const current = lvl.id === progress.unlocked
                  || (progress.unlocked > total && lvl.id === total);
                return (
                  <LevelStation
                    key={lvl.id}
                    level={lvl}
                    locked={locked}
                    done={done}
                    current={current}
                    sectionColor={section.branchColor}
                    stationRef={(el) => { stationRefs.current[lvl.id] = el; }}
                    onOpen={(id) => {
                      if (id > progress.unlocked) return;
                      startLevel(id);
                    }}
                  />
                );
              })}
            </div>
          ))}
        </div>

      </div>
    </div>
  );
}

function LevelThumb({ levelId, size = 70, state = 'current', sectionColor, faded }) {
  const COLORS = useC();
  const glyph = useMemo(() => {
    if (!window.TessraGlyphs) return null;
    return window.TessraGlyphs.resolve(levelId, { size, pad: 0.38 });
  }, [levelId, size]);
  const branch = sectionColor || COLORS.outline;
  const dim = faded ? 0.5 : 1;

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

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

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

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

        <AboutSection kicker="The game" title="Find the figure">
          Tessra turns a Penrose patch into a quiet puzzle. Each level asks for
          one named figure: Twin Rhombs, Sun, Ace, Decagon, Star. Progress is a
          small collection of shapes you learn to recognize by sight.
        </AboutSection>

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

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

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

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

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

// ─── LEVEL PLAY ───────────────────────────────────────────────────────────
function LevelPlayScreen({ goto, levelId, progress, setProgress }) {
  // 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 [winSnapshot, setWinSnapshot] = useState(null);
  const animRef = useRef(new Map());
  const ripplesRef = useRef([]);
  const canvasRef = useRef(null);
  const boundaryIntroRef = useRef({ start: performance.now() });
  const winHandledRef = useRef(false);
  const outlineEdges = useMemo(
    () => (window.LevelEngine.outerBoundaryEdges
      ? window.LevelEngine.outerBoundaryEdges(level.slots)
      : []),
    [level],
  );

  useEffect(() => {
    setFilled(level.slots.map(() => false));
    setWonAt(0);
    setWinSnapshot(null);
    winHandledRef.current = false;
    animRef.current.clear();
    ripplesRef.current = [];
    boundaryIntroRef.current = { start: performance.now() };
  }, [levelId]);

  const allFilled = filled.every(Boolean);
  useEffect(() => {
    if (!allFilled || wonAt || winHandledRef.current) return;
    winHandledRef.current = true;
    const canvas = canvasRef.current;
    if (canvas) {
      const rect = canvas.getBoundingClientRect();
      setWinSnapshot(winSnapshotFromSlots(
        level.slots,
        Math.max(1, rect.width),
        Math.max(1, rect.height),
      ));
    }
    setWonAt(performance.now());
    haptic(20);
    unlockLevelIfCurrent(setProgress, levelId);
  }, [allFilled, wonAt, levelId, setProgress, level.slots]);

  // 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);
      }

      // Outer silhouette — bright on load, then a faint guide for the full figure.
      if (outlineEdges.length > 0) {
        const alpha = boundaryOutlineAlpha(now, boundaryIntroRef.current.start);
        ctx.save();
        ctx.strokeStyle = COLORS.outlineActive;
        ctx.lineWidth = 1.6;
        ctx.lineJoin = 'round';
        ctx.shadowColor = COLORS.glow;
        ctx.shadowBlur = alpha > 0.3 ? 10 : 4;
        ctx.globalAlpha = alpha;
        outlineEdges.forEach(([a, b]) => {
          const pa = window.LevelEngine.worldToScreen(a, t);
          const pb = window.LevelEngine.worldToScreen(b, t);
          ctx.beginPath();
          ctx.moveTo(pa.x, pa.y);
          ctx.lineTo(pb.x, pb.y);
          ctx.stroke();
        });
        ctx.restore();
      }

      // 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 };
        });
        fillTileShape(ctx, verts, s.type, COLORS, 0.92 * Math.min(1, Math.max(0, sc)));
        if (!COLORS.tileGradient) {
          ctx.beginPath();
          verts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
          ctx.closePath();
          ctx.strokeStyle = s.type === 'THICK' ? COLORS.thickEdge : COLORS.thinEdge;
          ctx.globalAlpha = 0.55;
          ctx.lineWidth = 0.8;
          ctx.stroke();
          ctx.globalAlpha = 1;
        } else {
          strokeTileGrout(ctx, verts, COLORS, 0.75 * Math.min(1, Math.max(0, sc)), 0.9);
        }
      });

      // 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
        || (now - boundaryIntroRef.current.start) < 2800) {
        raf = requestAnimationFrame(frame);
      }
    }
    frame();
    return () => raf && cancelAnimationFrame(raf);
  }, [level, filled, wonAt, allFilled, COLORS, outlineEdges]);

  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}`} onMenu={() => goto('menu')} 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 && winSnapshot && <VictoryFigureFill snapshot={winSnapshot} startedAt={wonAt} />}
        {wonAt > 0 && <FigureRevealOverlay level={level} startedAt={wonAt} headline="Complete" snapshot={winSnapshot} />}
      </div>
      <div style={{
        padding: '12px 22px 26px', display: 'flex', justifyContent: 'space-between', gap: 10,
      }}>
        <button onClick={() => { setFilled(level.slots.map(() => false)); setWonAt(0); setWinSnapshot(null); winHandledRef.current = false; animRef.current.clear(); }}
          style={btnDangerStyle(COLORS)}>RESET</button>
        {wonAt > 0 ? (
          <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
            {hasNext
              ? <button onClick={() => goto('level:' + nextId)} style={btnSolidStyle(COLORS)}>NEXT →</button>
              : <button onClick={() => goto('waitlist')} style={{...btnSolidStyle(COLORS), background: COLORS.goldA}}>GET NOTIFIED</button>}
          </div>
        ) : (
          <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>
  );
}

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

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

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

// 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 [winSnapshot, setWinSnapshot] = useState(null);
  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 introRef = useRef(null); // { start } while the zoom-in intro is playing
  const winHandledRef = useRef(false);

  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);
    setWinSnapshot(null);
    winHandledRef.current = false;
    animRef.current.clear();
    ripplesRef.current = [];
    // Zoom-in intro on load — same flourish as the daily mosaic.
    introRef.current = REDUCED_MOTION ? null : { start: performance.now() };
  }, [pattern, spec]);

  const allFilled = targetIds.length > 0 && filled.size === targetIds.length;
  useEffect(() => {
    if (!allFilled || wonAt || winHandledRef.current) return;
    winHandledRef.current = true;
    const canvas = canvasRef.current;
    if (canvas && tiling && vertexPos) {
      const rect = canvas.getBoundingClientRect();
      setWinSnapshot(winSnapshotFromPatch(
        tiling,
        targetIds,
        Math.max(1, rect.width),
        Math.max(1, rect.height),
        vertexPos,
        zoomRef.current,
      ));
    }
    setWonAt(performance.now());
    haptic(20);
    if (!isProto) unlockLevelIfCurrent(setProgress, levelId);
  }, [allFilled, wonAt, levelId, setProgress, isProto, tiling, targetIds, vertexPos]);

  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 now = performance.now();
      let stillAnimating = true;

      // Intro: ease the camera in from a wide view of the surrounding patch.
      let introT = 1;
      if (introRef.current) {
        const dt = (now - introRef.current.start) / 2200;
        if (dt >= 1) introRef.current = null;
        else introT = easeInOutCubic(Math.max(0, dt));
      }
      const z = zoomRef.current;
      const startZoom = 0.34;            // wide view at intro start
      const effZ = startZoom + (z - startZoom) * introT;
      const VIS_R = 4.5 / effZ;
      const scale = Math.min(W, H) / (2 * VIS_R);
      const ox = W / 2 - transform.cx * scale;
      const oy = H / 2 + transform.cy * scale;
      // Surrounding tiles read stronger while zoomed out, settling to a whisper.
      const ghostBoost = 1 + (1 - introT) * 7;

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

      // 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 = Math.min(0.5, 0.045 * fade * ghostBoost);
        ctx.fill();
        ctx.strokeStyle = introT < 1 ? COLORS.grout : (t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge);
        ctx.globalAlpha = Math.min(0.7, 0.22 * fade * ghostBoost);
        ctx.lineWidth = 0.8;
        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;
        const tileVerts = t.verts.map((v) => {
          const lx = tcx + (v.x - tcx) * sc, ly = tcy + (v.y - tcy) * sc;
          return { x: ox + lx * scale, y: oy - ly * scale };
        });
        const fa = 0.92 * Math.min(1, Math.max(0, sc));
        fillTileShape(ctx, tileVerts, t.type, COLORS, fa);
        if (!COLORS.tileGradient) {
          ctx.beginPath();
          tileVerts.forEach((v, j) => (j === 0 ? ctx.moveTo(v.x, v.y) : ctx.lineTo(v.x, v.y)));
          ctx.closePath();
          ctx.strokeStyle = t.type === 'thick' ? COLORS.thickEdge : COLORS.thinEdge;
          ctx.globalAlpha = 0.55;
          ctx.lineWidth = 0.8;
          ctx.stroke();
          ctx.globalAlpha = 1;
        } else {
          strokeTileGrout(ctx, tileVerts, COLORS, 0.9 * fa, 0.9);
        }
      });

      // 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;
      }

      // Once the figure is solved and all tile pops / ripples have settled, the
      // canvas is static — stop repainting so the CSS win animations stay smooth.
      const idleAfterWin = wonAtRef.current && !introRef.current
        && animRef.current.size === 0 && ripplesRef.current.length === 0;
      if (!idleAfterWin) raf = requestAnimationFrame(frame);
    }
    frame();
    return () => raf && cancelAnimationFrame(raf);
  }, [tiling, targetIds, vertexPos, transform, COLORS]);

  const onTap = useCallback((e) => {
    if (!tiling || !transform || wonAtRef.current || introRef.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) {
          setToast('');
        } 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}`}
        onMenu={() => goto('menu')}
        onBack={() => goto('levelmap')}
      />
      <div style={{ flex: 1, position: 'relative' }}>
        {loadError ? (
          <div style={{
            position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
            alignItems: 'center', justifyContent: 'center', padding: '0 28px', gap: 14,
          }}>
            <div style={{ fontFamily: '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',
            animation: wonAt > 0 && !REDUCED_MOTION ? 'tessera-canvas-lift 880ms cubic-bezier(0.18, 0.89, 0.32, 1.18) both' : 'none',
            transformOrigin: '50% 50%',
          }} />
        )}
        {!loadError && (
        <>
        {/* Zoom controls — bottom-right of canvas */}
        {wonAt <= 0 && <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>}
        {wonAt <= 0 && <MicrocopyToast message={toast} />}
        {wonAt > 0 && winSnapshot && <VictoryFigureFill snapshot={winSnapshot} startedAt={wonAt} />}
        {wonAt > 0 && <FigureRevealOverlay level={{ title, figure, caption }} startedAt={wonAt} headline="Complete" snapshot={winSnapshot} />}
        </>
        )}
      </div>
      <div style={{ padding: '12px 22px 26px', display: 'flex', justifyContent: 'space-between', gap: 10 }}>
        <button onClick={() => {
          setFilled(new Set()); setWonAt(0); setWinSnapshot(null);
          winHandledRef.current = false;
          animRef.current.clear(); ripplesRef.current = [];
          if (isProto) setPattern(0.10 + Math.random() * 0.80);
        }} style={btnDangerStyle(COLORS)}>RESET</button>
        {wonAt > 0 ? (
          <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
            {hasNext
              ? <button onClick={() => goto('level:' + nextId)} style={btnSolidStyle(COLORS)}>NEXT →</button>
              : (isProto
                  ? null
                  : <button onClick={() => goto('waitlist')} style={{...btnSolidStyle(COLORS), background: COLORS.goldA}}>GET NOTIFIED</button>)}
          </div>
        ) : (
          <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: `radial-gradient(ellipse at 50% 12%, ${COLORS.bgPanel}, ${COLORS.bg} 68%)`,
    }}>
      <TopBar title="Full version" onMenu={() => goto('menu')} onBack={() => goto('home')} />
      <div style={{ flex: 1, padding: '0 22px 14px', display: 'flex', flexDirection: 'column' }}>
        <div style={{
          marginTop: 4,
          marginBottom: 22,
          borderRadius: 22,
          border: `1px solid ${COLORS.outline}`,
          background: `linear-gradient(155deg, ${COLORS.bgPanel}, ${COLORS.surface})`,
          padding: '26px 20px 22px',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          textAlign: 'center',
          boxShadow: '0 18px 40px rgba(0,0,0,0.18)',
        }}>
          <div style={{ animation: REDUCED_MOTION ? 'none' : 'tessera-float 3.4s ease-in-out infinite' }}>
            <DynamicLogo size={92} scale={13} glow />
          </div>
          <div style={{
            marginTop: 22,
            fontFamily: SERIF,
            fontSize: 40,
            fontWeight: 600,
            letterSpacing: '-0.02em',
            lineHeight: 1,
            color: COLORS.text,
            textShadow: `0 0 32px ${COLORS.outlineActive}33`,
          }}>Tessra</div>
          <div style={{
            marginTop: 10,
            fontFamily: MONO,
            fontSize: 10,
            fontWeight: 700,
            letterSpacing: '0.28em',
            textTransform: 'uppercase',
            color: COLORS.goldA,
          }}>Full version</div>
          <div style={{
            marginTop: 16,
            maxWidth: 300,
            fontFamily: MONO,
            fontSize: 12,
            color: COLORS.textMuted,
            lineHeight: 1.6,
            letterSpacing: '0.02em',
          }}>
            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 [splashDone, setSplashDone] = useState(false);
  const [screen, setScreen] = useState('home');
  const [levelId, setLevelId] = useState(1);
  const [menuOpen, setMenuOpen] = useState(false);

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

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

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

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

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

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

  // Splash → onboarding (first launch) → app.
  if (!splashDone) {
    return shell(<SplashScreen onDone={finishSplash} />);
  }

  // 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={todayDaily} logoPaused={menuOpen} />;
  else if (screen === 'daily') content = <DailyScreen
    goto={goto}
    daily={daily}
    dailyOptions={dailyOptions}
    dailyIndex={dailyIndex}
    onSelectDaily={setDailyIndex}
    dailyByDate={progress.dailyByDate || {}}
    dailyState={dailyState}
    updateDailyState={updateDailyState}
  />;
  else if (screen === 'levelmap') content = <LevelMapScreen goto={goto} progress={progress} startLevel={startLevel} />;
  else if (screen === 'levelplay') content = <LevelPlayScreen goto={goto} levelId={levelId} progress={progress} setProgress={setProgress} />;
  else if (screen === 'about') content = <AboutScreen goto={goto} />;
  else if (screen === '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} themeId={themeId} onPickTheme={pickTheme} />
    </>
  );
}

window.PenroseApp = PenroseApp;
