// ─────────────────────────────────────────────────────────────────────
// Atelier — single-source-of-truth coloring book.
// Type a prompt → server generates ONE color image → quantizes into
// palette + regions + numberMap. Hold-to-Preview reproduces the source.
// ─────────────────────────────────────────────────────────────────────

// Generation registries — populated at runtime by SearchRail.generate().
// Exposed globally so coloring-engine.jsx can resolve `ILLUSTRATIONS[kind]`.
window.ILLUSTRATIONS = window.ILLUSTRATIONS || {};
window.REGION_DATA = window.REGION_DATA || {};
const ILLUSTRATIONS = window.ILLUSTRATIONS;
const REGION_DATA = window.REGION_DATA;

// Bump on each user-visible release so the user can confirm at a glance which
// build is loaded. Surfaced as a small pill next to the brand and in the
// login gate.
const APP_VERSION = '1.3';
if (typeof window !== 'undefined') window.__zenAppVersion = APP_VERSION;

// ─── Browser / device detection ──────────────────────────────────────
// All iOS browsers run on Apple's WebKit (Apple's policy), but Chrome iPad
// in "Request Desktop Site" mode (DEFAULT on iPad) strips the iPad UA token
// and reports as macOS — which breaks `(pointer: coarse)` and may change
// how Pencil events are classified. Detect via maxTouchPoints since UA lies.
function detectEnv() {
  if (typeof navigator === 'undefined') return { isIPad: false, isChrome: false, isSafari: false, isFirefox: false, desktopMode: false, ua: '' };
  const ua = navigator.userAgent;
  const isIPadUA = /iPad/.test(ua);
  // iPad lying as macOS: touchscreen Mac doesn't exist (yet) so multi-touch
  // on a "Macintosh" UA is iPadOS in desktop mode.
  const isIPadInDesktopMode = !isIPadUA && /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1;
  const isIPad = isIPadUA || isIPadInDesktopMode;
  const isChromeIOS = /CriOS/.test(ua);                    // explicit iPad Chrome (mobile mode)
  const isFirefoxIOS = /FxiOS/.test(ua);
  const isEdgeIOS = /EdgiOS/.test(ua);
  const isOtherIOS = isChromeIOS || isFirefoxIOS || isEdgeIOS;
  const isChromeDesktopUA = /Chrome\//.test(ua) && !/Edg\/|OPR\//.test(ua);
  const isSafari = isIPadUA && !isOtherIOS && !isChromeDesktopUA;
  // On iPad in desktop mode we can't tell Chrome from Safari — both look like
  // macOS Safari. So flag as "iPad ambiguous" rather than guessing.
  return {
    isIPad,
    isIPadInDesktopMode,
    isIPadAmbiguous: isIPadInDesktopMode,
    isChromeIOS,
    isFirefoxIOS,
    isEdgeIOS,
    isSafari,
    isChromeDesktopUA,
    maxTouchPoints: navigator.maxTouchPoints || 0,
    pointerEvents: typeof window !== 'undefined' && 'PointerEvent' in window,
    coarsePointer: typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(pointer: coarse)').matches : false,
    ua,
  };
}
if (typeof window !== 'undefined') window.__zenEnv = detectEnv();

// ─── Server saves + browser-local autosave ──────────────────────────
// Explicit "Save" snapshots live server-side (cross-device, per-user).
// In-progress work uses a localStorage slot for refresh-survival in the
// same browser; it is NOT synced cross-device (would hammer the server
// on every brushstroke). Server promotion happens when the user clicks Save.
const CURRENT_KEY = 'zen-color:current:v1';

async function apiListSaves() {
  const r = await fetch('/api/saves', { credentials: 'same-origin' });
  if (!r.ok) throw new Error('list saves failed: ' + r.status);
  const j = await r.json();
  return j.saves || [];
}
async function apiGetSave(id) {
  const r = await fetch('/api/saves/' + encodeURIComponent(id), { credentials: 'same-origin' });
  if (!r.ok) throw new Error('load save failed: ' + r.status);
  return r.json();
}
async function apiCreateSave({ generationId, title, fills, brushStrokes }) {
  const r = await fetch('/api/saves', {
    method: 'POST', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ generationId, title, fills, brushStrokes }),
  });
  if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'create save failed'); }
  return r.json();
}
async function apiUpdateSave(id, { title, fills, brushStrokes }) {
  const r = await fetch('/api/saves/' + encodeURIComponent(id), {
    method: 'PUT', credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, fills, brushStrokes }),
  });
  if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'update save failed'); }
  return r.json();
}
async function apiDeleteSave(id) {
  const r = await fetch('/api/saves/' + encodeURIComponent(id), {
    method: 'DELETE', credentials: 'same-origin',
  });
  if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'delete save failed'); }
  return r.json();
}

function loadCurrent() {
  try {
    const raw = localStorage.getItem(CURRENT_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch { return null; }
}
function persistCurrent(state) {
  try { localStorage.setItem(CURRENT_KEY, JSON.stringify(state)); return true; }
  catch { return false; }
}
function fmtRelTime(ts) {
  const s = (Date.now() - ts) / 1000;
  if (s < 60) return 'just now';
  if (s < 3600) return Math.floor(s / 60) + 'm ago';
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}
// Hydrate the localStorage autosave-current shape (regionData embedded).
// Same registry pattern as hydrateServerSave but for the older inline shape
// used by the per-browser refresh-survival slot.
function hydrateLocalCurrent(cur) {
  const Comp = makeDynamicComp({
    regions: cur.regionData.regionMeta,
    width: cur.regionData.colorableSize.width,
    height: cur.regionData.colorableSize.height,
  });
  ILLUSTRATIONS[cur.kind] = {
    Comp,
    title: cur.title,
    tag: 'Saved',
    category: 'saved',
    tags: (cur.prompt || '').toLowerCase().split(/\s+/),
  };
  REGION_DATA[cur.kind] = cur.regionData;
  return cur.kind;
}

// Hydrate a server save response into the in-memory registries so the
// canvas can render it. Response shape (from GET /api/saves/:id):
//   { id, title, fills, brushStrokes, generationId, prompt, style, colorable }
// where `colorable` is the full extracted payload from the original
// generation row. Returns the kind key to switch to.
function hydrateServerSave(save) {
  const c = save.colorable;
  if (!c) throw new Error('save has no colorable');
  const { palette, regions, numberMap, sourceColorImageHref, width, height } = c;
  const kind = `gen-save-${save.id}`;
  const Comp = makeDynamicComp({ regions, width, height });
  ILLUSTRATIONS[kind] = {
    Comp,
    title: save.title || (save.prompt || 'Saved').replace(/\b\w/g, (s) => s.toUpperCase()),
    tag: 'Saved',
    category: 'saved',
    tags: (save.prompt || '').toLowerCase().split(/\s+/),
  };
  REGION_DATA[kind] = {
    count: regions.length,
    regions: regions.map((r) => r.id),
    numberMap, palette, sourceColorImageHref,
    regionMeta: regions, colorableSize: { width, height },
    generationId: save.generationId,
  };
  return kind;
}

function AtelierApp({ anim = 'bleed', defaultShowNumbers = true }) {
  const c = useColoring('');
  const [selected, setSelected] = React.useState(1);
  const [zoom, setZoom] = React.useState(1);
  const [libOpen, setLibOpen] = React.useState(true);
  const [showNumbers, setShowNumbers] = React.useState(defaultShowNumbers);
  const [paletteOpen, setPaletteOpen] = React.useState(true);
  const [renderStyle, setRenderStyle] = React.useState('illustration');
  const [previewMode, setPreviewMode] = React.useState(false);
  const [hueTab, setHueTab] = React.useState('all');
  const [lineWeight, setLineWeight] = React.useState(0.5);
  // "Lines" header toggle — when off, the black region borders are hidden so
  // the user sees just their painted fills (no line-art outline).
  const [showLines, setShowLines] = React.useState(true);
  // Brush mode: pixel-level paint on a canvas overlay, clipped to the region
  // the stroke STARTED in. Crossing the region boundary clips the paint to
  // nothing — Option A: paint is locked to the start region. Replaces the
  // earlier "Brush only" toggle (which only suppressed paint-on-tap).
  const [brushMode, setBrushMode] = React.useState(false);
  // Brush size in source-image pixels (canvas internal coords). 24 is a
  // medium-thick mark on a 1024×1024 canvas; user can scrub via the slider.
  const [brushSize, setBrushSize] = React.useState(24);
  // Pencil-only: when on, fingers pan/pinch the canvas and only Apple Pencil
  // (pointerType === 'pen') triggers paint strokes. Best for iPad users who
  // own a Pencil and want Procreate-style navigation freedom.
  const [pencilOnly, setPencilOnly] = React.useState(false);
  // Pan tool: when on, EVERY pointer (finger, mouse, Pencil) pans the canvas
  // and painting is suspended. Mirrors Photoshop's H key. Useful for moving
  // around at high zoom with the same input device you paint with.
  const [panTool, setPanTool] = React.useState(false);
  // Header collapse toggle — hides the toggle row to reclaim vertical space
  // on small viewports (iPad portrait). Brand + chevron stay visible.
  // Default to collapsed on phone-sized viewports so the header stays sane;
  // the user opens the toggle dropdown on demand via the chevron button.
  const [headerCollapsed, setHeaderCollapsed] = React.useState(() => {
    if (typeof window === 'undefined') return false;
    return window.innerWidth < 600;
  });
  // Sketch mode: blank canvas with raw pointer-events sketching, separate
  // from the region paint pipeline. Diagnostic + creative.
  const [sketchMode, setSketchMode] = React.useState(false);

  // Preset auto-load: when the URL hash contains `test`, fetch the bundled
  // /_preset.json (a pre-extracted Realistic landscape) and hydrate the
  // canvas immediately. Lets the iPad skip the 30-60s generation wait when
  // troubleshooting Pencil/brush behavior. Same hydration shape as
  // SearchRail.generate — we just don't make the API round-trip.
  React.useEffect(() => {
    if (typeof window === 'undefined') return;
    if (!window.location.hash.includes('test')) return;
    if (c.kind) return; // already have a canvas (autosave hit first)
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch('/_preset.json', { cache: 'no-store' });
        if (!r.ok) throw new Error('preset fetch ' + r.status);
        const data = await r.json();
        if (cancelled || !data.colorable) return;
        const { palette, regions, numberMap, sourceColorImageHref, width, height } = data.colorable;
        const genKind = `gen-preset-${Date.now()}`;
        const Comp = makeDynamicComp({ regions, width, height });
        ILLUSTRATIONS[genKind] = {
          Comp,
          title: (data.prompt || 'Preset').replace(/\b\w/g, (s) => s.toUpperCase()),
          tag: 'Preset',
          category: 'generated',
          tags: (data.prompt || '').toLowerCase().split(/\s+/),
        };
        REGION_DATA[genKind] = {
          count: regions.length,
          regions: regions.map((r) => r.id),
          numberMap, palette, sourceColorImageHref,
          regionMeta: regions, colorableSize: { width, height },
        };
        c.setKind(genKind);
        setSelected(1);
        setHueTab('all');
      } catch (err) {
        console.warn('[preset] auto-load failed:', err);
      }
    })();
    return () => { cancelled = true; };
    // c.kind dep is intentional — once we hydrate, this effect re-runs but
    // exits early at the `if (c.kind) return` guard.
  }, [c.kind]);
  // Transient brush-rejection hint: surfaces when the user's brush stroke
  // crossed many regions but Strict CBN filtered them all out. Tells them
  // the brush IS firing — strict mode is just gating which regions paint.
  const [brushHint, setBrushHint] = React.useState(null);
  const brushHintTimerRef = React.useRef(null);
  React.useEffect(() => {
    const onHint = (e) => {
      setBrushHint(e.detail);
      if (brushHintTimerRef.current) clearTimeout(brushHintTimerRef.current);
      brushHintTimerRef.current = setTimeout(() => setBrushHint(null), 4500);
    };
    window.addEventListener('zen:brush-hint', onHint);
    return () => {
      window.removeEventListener('zen:brush-hint', onHint);
      if (brushHintTimerRef.current) clearTimeout(brushHintTimerRef.current);
    };
  }, []);

  // Debug overlay: shows the last N pointer events live so we can see what
  // pointerType + buttons + pressure iPad Chrome is delivering for Pencil.
  // Toggle via URL hash #debug or the 🔍 button. NOT shown by default.
  const [debugPointer, setDebugPointer] = React.useState(() => (
    typeof window !== 'undefined' && window.location.hash.includes('debug')
  ));
  const [debugEvents, setDebugEvents] = React.useState([]);
  React.useEffect(() => {
    if (!debugPointer) return;
    // Stable per-tab session id so server logs are grouped per device.
    const sessionId = (window.__zenSessionId = window.__zenSessionId || Math.random().toString(36).slice(2, 10));
    const queue = [];
    let flushTimer = null;
    const flush = () => {
      if (queue.length === 0) return;
      const events = queue.splice(0, queue.length);
      try {
        fetch('/api/log', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ sessionId, env: window.__zenEnv, events }),
          keepalive: true,
        }).catch(() => {});
      } catch {}
    };
    const enqueue = (entry) => {
      queue.push(entry);
      if (queue.length >= 20) flush();
      else {
        if (flushTimer) clearTimeout(flushTimer);
        flushTimer = setTimeout(flush, 600);
      }
    };
    const log = (e) => {
      const entry = {
        t: e.type.replace('pointer', ''),
        kind: e.type.replace('pointer', ''),
        type: e.pointerType || '?',
        id: e.pointerId,
        b: e.buttons,
        p: e.pressure?.toFixed(2),
        x: Math.round(e.clientX),
        y: Math.round(e.clientY),
        ts: Date.now(),
      };
      setDebugEvents((prev) => [entry, ...prev].slice(0, 30));
      enqueue(entry);
    };
    const logPaint = (e) => {
      const d = e.detail || {};
      const entry = {
        t: 'PAINT',
        kind: 'PAINT',
        type: d.source || '?',
        id: (d.region || '-').toString().slice(-5),
        b: '-', p: '-', x: 0, y: 0,
        ts: Date.now(),
        detail: d,
      };
      setDebugEvents((prev) => [entry, ...prev].slice(0, 30));
      enqueue(entry);
    };
    // Send env up front so server knows what device we're on.
    enqueue({ kind: 'INIT', type: 'env', detail: window.__zenEnv, ts: Date.now() });
    window.addEventListener('pointerdown', log);
    window.addEventListener('pointermove', log);
    window.addEventListener('pointerup', log);
    window.addEventListener('pointercancel', log);
    window.addEventListener('zen:debug-paint', logPaint);
    return () => {
      window.removeEventListener('pointerdown', log);
      window.removeEventListener('pointermove', log);
      window.removeEventListener('pointerup', log);
      window.removeEventListener('pointercancel', log);
      window.removeEventListener('zen:debug-paint', logPaint);
      if (flushTimer) clearTimeout(flushTimer);
      flush();
    };
  }, [debugPointer]);
  const [highlightIdx, setHighlightIdx] = React.useState(null);
  const [strictMode, setStrictMode] = React.useState(true);
  // saves is a metadata list from the server. Full record (with colorable
  // and brush strokes) is fetched on-demand when the user loads a save.
  const [saves, setSaves] = React.useState([]);
  const [currentSaveId, setCurrentSaveId] = React.useState(null);
  // Fetch the user's saves on mount. Cheap query (no colorable in the list).
  React.useEffect(() => {
    let cancelled = false;
    apiListSaves().then((list) => { if (!cancelled) setSaves(list); }).catch(() => {});
    return () => { cancelled = true; };
  }, []);
  const [currentPrompt, setCurrentPrompt] = React.useState('');
  const [currentStyle, setCurrentStyle] = React.useState('');
  const [saveStatus, setSaveStatus] = React.useState(null);
  // Default OFF: the filter hides swatches whose regions aren't on screen.
  // When the user zooms in, only 2-4 colors remain visible — but the user
  // sees region numbers on the canvas and expects the matching swatch to be
  // tappable. The desktop affordance becomes a trap on a small zoomed canvas.
  const [visibleOnly, setVisibleOnly] = React.useState(false);
  const [visibleBox, setVisibleBox] = React.useState(null);
  const [spaceDown, setSpaceDown] = React.useState(false);
  const scrollRef = React.useRef(null);
  // Unified pointer-input state: every active pointer (mouse / touch / pen) is
  // tracked here. Pan/pinch are derived from how many pointers are down + their
  // types. See onCanvasPointerDownCapture for the full state machine.
  const pointersRef = React.useRef(new Map());
  const panRef = React.useRef(null);
  const pinchRef = React.useRef(null);
  // Palm-rejection: when a Pencil event has fired recently, ignore touch
  // pointers on the canvas. Real palms slip in before the Pencil hits, so we
  // also clear any in-progress palm-pan when a pen pointer arrives.
  const lastPencilTsRef = React.useRef(0);
  const PALM_WINDOW_MS = 1500;
  // After a pinch ends, briefly suppress drag-from-palette paint events so a
  // lifted finger doesn't accidentally drop a swatch onto a region (Fix #18).
  const lastPinchEndTsRef = React.useRef(0);
  // Pending scroll target applied in a layout effect after `zoom` commits, so
  // pinch/wheel zoom doesn't read stale scrollWidth/scrollHeight (Fix #1).
  const pendingScrollRef = React.useRef(null);

  // Touch-vs-mouse environment detection. Use matchMedia('(pointer: coarse)')
  // OR navigator.maxTouchPoints > 1 — the former is unreliable on iPad Chrome
  // in desktop mode (UA looks like macOS so coarse-pointer media query says no
  // even though the device is a touchscreen iPad).
  const computeTouchEnv = () => {
    if (typeof window === 'undefined') return false;
    const coarse = window.matchMedia ? window.matchMedia('(pointer: coarse)').matches : false;
    const multitouch = (navigator.maxTouchPoints || 0) > 1;
    return coarse || multitouch;
  };
  const [isTouchEnv, setIsTouchEnv] = React.useState(computeTouchEnv);
  React.useEffect(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return;
    const mq = window.matchMedia('(pointer: coarse)');
    const onChange = () => setIsTouchEnv(computeTouchEnv());
    if (mq.addEventListener) mq.addEventListener('change', onChange);
    else if (mq.addListener) mq.addListener(onChange);
    return () => {
      if (mq.removeEventListener) mq.removeEventListener('change', onChange);
      else if (mq.removeListener) mq.removeListener(onChange);
    };
  }, []);

  const ill = ILLUSTRATIONS[c.kind];
  const data = REGION_DATA[c.kind];
  const hasIllustration = !!ill && !!data;

  // ── Autosave: persist current canvas + fills on every change so a
  // refresh restores progress. Debounced 500ms to coalesce rapid brush
  // strokes. Persists to a separate localStorage slot from the saves list
  // (the named saves remain user-managed via the Save button). ────────
  const autosaveTimerRef = React.useRef(null);
  const [autosaveTick, setAutosaveTick] = React.useState(0);
  React.useEffect(() => {
    if (!hasIllustration) return;
    if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
    autosaveTimerRef.current = setTimeout(() => {
      const ok = persistCurrent({
        kind: c.kind,
        title: ill.title,
        prompt: currentPrompt || ill.title,
        style: currentStyle || (ill.tag || '').toLowerCase(),
        regionData: { ...data, sourceColorImageHref: null },
        fills: { ...(c.fills || {}) },
        filledCount: Object.keys(c.fills || {}).length,
        totalCount: data.count,
        updatedAt: Date.now(),
      });
      if (ok) setAutosaveTick((t) => t + 1);
    }, 500);
    return () => { if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [c.fills, c.kind, hasIllustration]);

  // ── Restore autosaved current canvas on first mount ────────────────
  const didRestoreRef = React.useRef(false);
  React.useEffect(() => {
    if (didRestoreRef.current) return;
    didRestoreRef.current = true;
    const cur = loadCurrent();
    if (!cur || !cur.kind || !cur.regionData?.regionMeta) return;
    try {
      hydrateLocalCurrent(cur);
      c.setKindFills(cur.kind, cur.fills || {});
      c.setKind(cur.kind);
      setCurrentPrompt(cur.prompt || '');
      setCurrentStyle(cur.style || '');
    } catch (e) {
      console.warn('[autosave] restore failed:', e);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const filledCount = Object.keys(c.fills).length;
  const totalCount = data ? data.count : 0;
  // Auto-fit canvas to available viewport (between left rail + right palette).
  // Zoom multiplies this for hi-res inspection; canvasScroll handles overflow.
  const [viewport, setViewport] = React.useState({ w: typeof window !== 'undefined' ? window.innerWidth : 1440, h: typeof window !== 'undefined' ? window.innerHeight : 900 });
  React.useEffect(() => {
    const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
  // Two independent thresholds:
  //   • isOverlay — rail collapses to a drawer (saves horizontal space).
  //     1080 keeps iPad Pro 11" landscape (1194) on the desktop layout.
  //   • isCompact — chrome (header, editorial, actions, palette) shrinks to
  //     give the canvas more vertical real estate. Triggers on ANY iPad
  //     orientation (incl. landscape, where height ≈ 820 is precious).
  const isOverlay = viewport.w < 1080;
  const isCompact = viewport.w < 1280 || viewport.h < 900;
  // isNarrow kept as alias of isCompact since most chrome decisions key off it.
  const isNarrow = isCompact;
  // Phone-class viewport (iPhone portrait, narrow Android). Header toggles
  // can't fit inline at all here — they get moved into a dropdown panel.
  const isPhone = viewport.w < 600;
  React.useEffect(() => {
    if (isOverlay) setLibOpen(false);
    else setLibOpen(true);
  }, [isOverlay]);
  // With the palette moved to a horizontal strip below the canvas, the right
  // rail is gone. Vertical budget loses the palette-strip height instead.
  // Narrow viewports overlay the rail (no canvas-width cost) AND collapse
  // the editorial block so canvas gets the maximum vertical real estate.
  const railW = (libOpen && !isOverlay) ? 230 : 0;
  const headerH = isNarrow ? 44 : 60;
  const actionsH = isNarrow ? 40 : 56;
  const paletteStripH = paletteOpen ? (isNarrow ? 132 : 188) : 28;
  const editorialH = isNarrow ? 0 : 50; // editorial title is hidden on narrow
  // Canvas is rendered at NATIVE source resolution × zoom.
  //   zoom = 1.0 → 1 viewBox pixel = 1 screen pixel (true 1:1)
  //   zoom < 1   → downscaled (image fits in viewport)
  //   zoom > 1   → upscaled, scrollbars handle overflow
  const nativeW = data?.colorableSize?.width || 1600;
  const nativeH = data?.colorableSize?.height || 1600;
  const fitZoom = React.useMemo(() => {
    const availW = viewport.w - railW - (isNarrow ? 24 : 64);
    const availH = viewport.h - headerH - actionsH - paletteStripH - editorialH - (isNarrow ? 24 : 64);
    return Math.min(availW / nativeW, availH / nativeH, 1);
  }, [viewport.w, viewport.h, railW, paletteStripH, editorialH, headerH, actionsH, isNarrow, nativeW, nativeH]);
  // On a fresh generation, snap zoom to fit-the-viewport so the user sees the
  // whole image without scrolling, but can still zoom up to 250% (or beyond
  // what fitZoom allows) to inspect.
  React.useEffect(() => {
    if (data?.colorableSize) setZoom(fitZoom);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data?.colorableSize?.width, data?.colorableSize?.height]);
  const canvasSize = nativeW;

  const activePalette = (data && data.palette && data.palette.length) ? data.palette : BIG_PALETTE;
  const safeSelected = Math.min(Math.max(1, selected), activePalette.length);
  const selectedColor = activePalette[safeSelected - 1]?.hex || '#1a1a1a';
  const selectedName = activePalette[safeSelected - 1]?.name || '';

  const numberMap = data?.numberMap || {};

  // ── viewport / zoom / pan helpers ──
  // Convert scroll position + container size into source-image (viewBox) coords.
  const recomputeVisibleBox = React.useCallback(() => {
    const el = scrollRef.current;
    if (!el || !data?.colorableSize) { setVisibleBox(null); return; }
    const { width: nw, height: nh } = data.colorableSize;
    // Canvas content is rendered at nativeW × zoom CSS pixels.
    const z = zoom || 1;
    const visW = el.clientWidth, visH = el.clientHeight;
    const scrollL = el.scrollLeft, scrollT = el.scrollTop;
    setVisibleBox({
      x0: Math.max(0, scrollL / z),
      y0: Math.max(0, scrollT / z),
      x1: Math.min(nw, (scrollL + visW) / z),
      y1: Math.min(nh, (scrollT + visH) / z),
    });
  }, [data, zoom]);

  // After zoom changes, the scroll container's content size changes; recompute on next frame.
  React.useEffect(() => {
    const id = requestAnimationFrame(recomputeVisibleBox);
    return () => cancelAnimationFrame(id);
  }, [zoom, recomputeVisibleBox]);

  // Apply pending scroll target AFTER React commits the new zoom and DOM
  // layout reflects new scrollWidth/scrollHeight. Setting scrollLeft inside
  // a rAF (the previous approach) raced with React's commit on the first
  // pinch/wheel frame and got clamped to the OLD scroll extent, sliding the
  // pinch midpoint. useLayoutEffect runs after commit + layout, before paint
  // — exactly when the new scrollWidth is observable (Fix #1).
  React.useLayoutEffect(() => {
    const target = pendingScrollRef.current;
    pendingScrollRef.current = null;
    if (!target || !scrollRef.current) return;
    scrollRef.current.scrollLeft = target.left;
    scrollRef.current.scrollTop = target.top;
  }, [zoom]);

  // Spacebar = pan mode (Photoshop convention). Track keydown/up globally.
  React.useEffect(() => {
    const onKD = (e) => {
      if (e.code === 'Space' && !e.repeat && !isInputFocused()) {
        e.preventDefault();
        setSpaceDown(true);
      }
    };
    const onKU = (e) => { if (e.code === 'Space') setSpaceDown(false); };
    const isInputFocused = () => {
      const ae = document.activeElement;
      return ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable);
    };
    window.addEventListener('keydown', onKD);
    window.addEventListener('keyup', onKU);
    return () => { window.removeEventListener('keydown', onKD); window.removeEventListener('keyup', onKU); };
  }, []);

  // Wheel handler — Cmd/Ctrl + wheel zooms (matches trackpad pinch which fires
  // wheel events with ctrlKey=true). Plain wheel falls through to native scroll.
  // Zoom is centered on the cursor: the source-image point under the cursor stays put.
  const onCanvasWheel = (e) => {
    if (!(e.ctrlKey || e.metaKey)) return; // let native scroll happen
    e.preventDefault();
    const el = scrollRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    const contentX = el.scrollLeft + mx;
    const contentY = el.scrollTop + my;
    const factor = Math.exp(-e.deltaY * 0.0025); // smooth exponential
    const newZoom = Math.max(0.1, Math.min(16, zoom * factor));
    if (newZoom === zoom) return;
    const ratio = newZoom / zoom;
    pendingScrollRef.current = { left: contentX * ratio - mx, top: contentY * ratio - my };
    setZoom(newZoom);
  };

  // ── Unified pointer-input system (touch + pen + mouse) ──
  // Runs in CAPTURE phase so we can intercept gestures before the inner
  // ColorCanvas paint handlers see them. Logic:
  //   • 1 touch finger             → pan (drag scroll)
  //   • 2 touch fingers            → pinch-zoom + pan, centered on midpoint
  //   • Pen (any pointer count)    → cancels pan/pinch, bubbles to ColorCanvas
  //   • Mouse + Space, mouse mid   → pan
  //   • Mouse, no Space            → falls through (ColorCanvas paints)
  // Palm-rejection: pen-down clears any in-progress pan/pinch and scrubs touch
  // entries (Fix #2). Touch within PALM_WINDOW_MS *after* pen activity is
  // also suppressed.
  const PINCH_MIN_DIST = 8;
  const ZOOM_MIN = 0.1;
  // 16× = 1600%. SVG paths scale losslessly so quality is unbounded; the
  // practical limit is browser memory at very-zoomed-in canvas sizes.
  // At nativeW=1600 and zoom=16 the rendered canvas is 25,600 px wide.
  const ZOOM_MAX = 16;

  // endPointer is shared by capture-phase and window-level handlers.
  // Cleans up pointersRef + transitions the gesture state machine. Idempotent.
  const endPointer = React.useCallback((pointerId) => {
    if (!pointersRef.current.has(pointerId)) return;
    pointersRef.current.delete(pointerId);
    if (panRef.current && panRef.current.pointerId === pointerId) {
      panRef.current = null;
    }
    if (pointersRef.current.size < 2 && pinchRef.current) {
      pinchRef.current = null;
      lastPinchEndTsRef.current = Date.now();
    }
    // When pointer count drops below 2, end pinch-mode globally so ColorCanvas
    // can paint again on the next pointerdown.
    if (pointersRef.current.size < 2) {
      window.__zenPinching = false;
    }
  }, []);

  // Window-level safety net: if a pointer is captured by another element or
  // the gesture is stolen, the canvas may never see pointerup. Listen on
  // window so we always clean up (Fix #4).
  React.useEffect(() => {
    const onWindowEnd = (e) => endPointer(e.pointerId);
    window.addEventListener('pointerup', onWindowEnd);
    window.addEventListener('pointercancel', onWindowEnd);
    return () => {
      window.removeEventListener('pointerup', onWindowEnd);
      window.removeEventListener('pointercancel', onWindowEnd);
    };
  }, [endPointer]);

  const onCanvasPointerDownCapture = (e) => {
    // Pan tool overrides pen-priority: when active, even Pencil events go
    // through the pan path below instead of bubbling to ColorCanvas as paint.
    // Pen takes absolute priority — clear palm-induced pan/pinch and scrub
    // touch entries so a palm landing first doesn't disrupt a pen stroke.
    if (!panTool && e.pointerType === 'pen') {
      lastPencilTsRef.current = Date.now();
      panRef.current = null;
      pinchRef.current = null;
      window.__zenPinching = false;
      for (const [id, p] of [...pointersRef.current.entries()]) {
        if (p.type === 'touch') pointersRef.current.delete(id);
      }
      return; // bubble to ColorCanvas → paint
    }

    // Palm rejection — touch shortly after a pen event is most likely a palm.
    if (e.pointerType === 'touch' && Date.now() - lastPencilTsRef.current < PALM_WINDOW_MS) {
      e.stopPropagation();
      e.preventDefault();
      return;
    }

    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType });
    const pts = [...pointersRef.current.values()];
    const el = scrollRef.current;
    if (!el) return;

    // Pencil-only mode: a 1-finger touch pans the canvas instead of painting.
    // Pan tool: ANY pointer type (touch, mouse, pen) pans on the first pointer.
    // Both share the same single-pointer pan path. Two-pointer pinch is handled
    // by the next branch below; it'll null panRef when it takes over.
    if (((pencilOnly && e.pointerType === 'touch') || panTool) && pts.length === 1) {
      panRef.current = {
        x: e.clientX, y: e.clientY,
        sx: el.scrollLeft, sy: el.scrollTop,
        pointerId: e.pointerId,
      };
      // Cancel any in-flight paint stroke (e.g. if previous Pencil stroke
      // didn't get a clean up event before the finger landed).
      window.dispatchEvent(new CustomEvent('zen:cancel-stroke'));
      e.stopPropagation();
      e.preventDefault();
      try { e.target.setPointerCapture(e.pointerId); } catch {}
      return;
    }

    // 2+ pointers → pinch+pan. Cancel any in-flight ColorCanvas paint stroke
    // by setting the global pinching flag AND dispatching a cancel event.
    if (pts.length >= 2 && pts.every((p) => p.type === 'touch')) {
      panRef.current = null;
      window.__zenPinching = true;
      window.dispatchEvent(new CustomEvent('zen:cancel-stroke'));
      e.stopPropagation();
      e.preventDefault();
      try { e.target.setPointerCapture(e.pointerId); } catch {}
      return;
    }

    // Mouse with Space or middle button → pan. (1-finger touch + no Pen now
    // bubbles to ColorCanvas to paint, Procreate-style.)
    if (e.pointerType === 'mouse' && (spaceDown || e.button === 1)) {
      panRef.current = {
        x: e.clientX, y: e.clientY,
        sx: el.scrollLeft, sy: el.scrollTop,
        pointerId: e.pointerId,
      };
      e.stopPropagation();
      e.preventDefault();
      try { e.target.setPointerCapture(e.pointerId); } catch {}
      return;
    }
    // Otherwise (1 touch / pen / plain mouse) → bubble down to ColorCanvas to paint.
  };

  const onCanvasPointerMoveCapture = (e) => {
    if (!pointersRef.current.has(e.pointerId)) return;
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType });
    const el = scrollRef.current;
    if (!el) return;

    const pts = [...pointersRef.current.values()];

    // Lazy pinch start (handles zero-distance start case from Fix #15).
    if (!pinchRef.current && pts.length === 2 && pts.every((p) => p.type === 'touch')) {
      const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
      if (dist < PINCH_MIN_DIST) return; // wait for spread
      panRef.current = null;
      const cx = (pts[0].x + pts[1].x) / 2;
      const cy = (pts[0].y + pts[1].y) / 2;
      const rect = el.getBoundingClientRect();
      const lx = cx - rect.left, ly = cy - rect.top;
      pinchRef.current = {
        startDist: dist, startZoom: zoom,
        contentX: el.scrollLeft + lx, contentY: el.scrollTop + ly,
      };
      e.stopPropagation(); e.preventDefault();
      return;
    }

    if (pinchRef.current) {
      if (pts.length < 2) return;
      const newDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y) || 1;
      const requestedZoom = pinchRef.current.startZoom * (newDist / pinchRef.current.startDist);
      const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, requestedZoom));
      const wasClamped = newZoom !== requestedZoom;
      const cxNow = (pts[0].x + pts[1].x) / 2;
      const cyNow = (pts[0].y + pts[1].y) / 2;
      const rect = el.getBoundingClientRect();
      const lxNow = cxNow - rect.left;
      const lyNow = cyNow - rect.top;
      const r = newZoom / pinchRef.current.startZoom;
      const newScrollLeft = pinchRef.current.contentX * r - lxNow;
      const newScrollTop  = pinchRef.current.contentY * r - lyNow;

      if (newZoom === zoom) {
        // No layout change — apply scroll synchronously (pure pinch-pan).
        el.scrollLeft = newScrollLeft;
        el.scrollTop  = newScrollTop;
      } else {
        // Layout will change with the new zoom; defer to layout effect (Fix #1).
        pendingScrollRef.current = { left: newScrollLeft, top: newScrollTop };
        setZoom(newZoom);
      }

      // Reset pinch baseline when the zoom hit a cap so further pinching is
      // incremental from that cap and the midpoint stays put (Fix #3).
      if (wasClamped) {
        pinchRef.current.startDist = newDist;
        pinchRef.current.startZoom = newZoom;
        pinchRef.current.contentX = newScrollLeft + lxNow;
        pinchRef.current.contentY = newScrollTop + lyNow;
      }

      e.stopPropagation();
      e.preventDefault();
      return;
    }

    if (panRef.current && panRef.current.pointerId === e.pointerId) {
      el.scrollLeft = panRef.current.sx - (e.clientX - panRef.current.x);
      el.scrollTop  = panRef.current.sy - (e.clientY - panRef.current.y);
      e.stopPropagation();
      e.preventDefault();
    }
  };

  const onCanvasPointerEndCapture = (e) => {
    endPointer(e.pointerId);
    recomputeVisibleBox();
  };

  // Set of palette indices whose regions intersect the visible viewBox.
  // Used to filter the horizontal palette strip when "Visible only" is on.
  const visibleColorIdxs = React.useMemo(() => {
    if (!visibleBox || !data?.regionMeta) return null; // null = "show all"
    const set = new Set();
    const { x0, y0, x1, y1 } = visibleBox;
    for (const r of data.regionMeta) {
      const b = r.bbox;
      // Fall back to centroid for older saves that lack bbox
      const rx0 = b ? b[0] : r.cx, ry0 = b ? b[1] : r.cy;
      const rx1 = b ? b[2] : r.cx, ry1 = b ? b[3] : r.cy;
      if (rx1 < x0 || rx0 > x1 || ry1 < y0 || ry0 > y1) continue;
      set.add(r.colorIdx + 1);
    }
    return set;
  }, [visibleBox, data]);

  // Per-color progress: how many regions of each palette index exist, and
  // how many the user has painted. Drives swatch checkmarks + the "Now" panel.
  const colorProgress = React.useMemo(() => {
    const totals = new Map();
    const filled = new Map();
    for (const regionId of Object.keys(numberMap)) {
      const num = numberMap[regionId];
      totals.set(num, (totals.get(num) || 0) + 1);
      if (c.fills[regionId]) filled.set(num, (filled.get(num) || 0) + 1);
    }
    return { totals, filled };
  }, [numberMap, c.fills]);
  const totalForSelected = colorProgress.totals.get(safeSelected) || 0;
  const filledForSelected = colorProgress.filled.get(safeSelected) || 0;

  // Hold-to-Preview = every region filled with its assigned palette color.
  // Mathematically exact: this reproduces the source image (modulo quantization).
  const previewFills = React.useMemo(() => {
    if (!data) return {};
    const map = {};
    for (const id of Object.keys(numberMap)) {
      const num = numberMap[id];
      const sw = activePalette[num - 1];
      if (sw) map[id] = sw.hex;
    }
    return map;
  }, [data, numberMap, activePalette]);
  // Note: previewFills is no longer used by the editable canvas (the preview is
  // now a separate side-by-side child). Kept on the regionData export for any
  // future "fade to preview" or "compare painted vs target" affordances.

  // Bucket palette swatches into hue groups for the right-rail tabs.
  const hueBuckets = React.useMemo(() => bucketPaletteByHue(activePalette), [activePalette]);
  const visibleIdxs = hueTab === 'all'
    ? activePalette.map((_, i) => i + 1)
    : (hueBuckets[hueTab] || []);

  if (sketchMode) {
    return (
      <DragProvider>
        <div style={atelier.root}>
          <SketchMode onExit={() => setSketchMode(false)} />
        </div>
      </DragProvider>
    );
  }

  return (
    <DragProvider>
      <div style={atelier.root}>
        <header style={{
          ...(isNarrow ? { ...atelier.header, ...atelier.headerNarrow } : atelier.header),
          ...(headerCollapsed ? { padding: '4px 14px' } : null),
        }}>
          <div style={atelier.brand}>
            <button style={atelier.libToggle} onClick={() => setLibOpen(!libOpen)} aria-label="Toggle library">
              <span style={atelier.libToggleIcon}>{libOpen ? '⟨' : '⟩'}</span>
            </button>
            {!isNarrow && (
              <div style={atelier.mark}>
                <svg viewBox="0 0 24 24" width={20} height={20}><circle cx={12} cy={12} r={10} fill="none" stroke="#1a1a1a" strokeWidth={1}/><circle cx={12} cy={12} r={5} fill="#1a1a1a"/></svg>
              </div>
            )}
            <div>
              <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
                <div style={isNarrow ? { ...atelier.brandName, fontSize: 18 } : atelier.brandName}>
                  {isNarrow && hasIllustration ? ill.title : 'Zen Color'}
                </div>
                <span style={atelier.versionBadge} title={`Zen Color v${APP_VERSION}`}>v{APP_VERSION}</span>
              </div>
              {!isNarrow && !headerCollapsed && (
                <div style={atelier.brandSub}>{totalCount ? `${totalCount} regions · ${activePalette.length} colors${data?.colorableSize ? ` · ${data.colorableSize.width}×${data.colorableSize.height}` : ''}` : 'meditative coloring'}</div>
              )}
            </div>
          </div>
          <div style={atelier.headerRight}>
            {/*
              Toggle row. On non-phone viewports it renders inline next to the
              brand (or hides when headerCollapsed). On phone viewports it's
              suppressed here and re-rendered below the header as a stacked
              dropdown panel so it doesn't shove the brand off-screen.
            */}
            {!headerCollapsed && !isPhone && (
              <>
            <label style={atelier.toggleWrap} title="When on, only regions matching the selected swatch number can be painted.">
              <input type="checkbox" checked={strictMode} onChange={(e) => setStrictMode(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(strictMode ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(strictMode ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Strict CBN</span>
            </label>
            <label style={atelier.toggleWrap}>
              <input type="checkbox" checked={showNumbers} onChange={(e) => setShowNumbers(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(showNumbers ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(showNumbers ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Numbers</span>
            </label>
            <label style={atelier.toggleWrap} title="Hide black region borders to see your painted result without the line art.">
              <input type="checkbox" checked={showLines} onChange={(e) => setShowLines(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(showLines ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(showLines ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Lines</span>
            </label>
            <label style={atelier.toggleWrap} title="Pixel brush: drag to paint a stroke. The brush is clipped to the region you started in — boundaries contain the paint. Off = traditional tap/drag region fill.">
              <input type="checkbox" checked={brushMode} onChange={(e) => setBrushMode(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(brushMode ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(brushMode ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Brush</span>
            </label>
            <label style={atelier.toggleWrap} title="Pencil only: 1-finger drag pans the canvas, 2 fingers pinch+pan, only Apple Pencil paints. For iPad users with a Pencil who want Procreate-style navigation.">
              <input type="checkbox" checked={pencilOnly} onChange={(e) => setPencilOnly(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(pencilOnly ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(pencilOnly ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Pencil only</span>
            </label>
            <label style={atelier.toggleWrap} title="Pan tool: every pointer (finger, mouse, Pencil) pans the canvas. Painting is suspended while this is on. Like Photoshop's H key.">
              <input type="checkbox" checked={panTool} onChange={(e) => setPanTool(e.target.checked)} style={{ display: 'none' }} />
              <span style={{ ...atelier.toggleTrack, ...(panTool ? atelier.toggleTrackOn : {}) }}>
                <span style={{ ...atelier.toggleKnob, ...(panTool ? atelier.toggleKnobOn : {}) }} />
              </span>
              <span style={atelier.toggleLabel}>Pan</span>
            </label>
            <button
              onClick={() => setDebugPointer((v) => !v)}
              title="Toggle pointer-event debug overlay (for diagnosing Pencil/touch issues)"
              style={{
                width: 28, height: 28, marginLeft: 4,
                borderRadius: 6,
                border: debugPointer ? '1px solid #1a1a1a' : '1px solid #1a1a1a18',
                background: debugPointer ? '#1a1a1a' : 'transparent',
                color: debugPointer ? '#fafaf5' : '#1a1a1a66',
                cursor: 'pointer', fontSize: 12, padding: 0,
              }}
            >🔍</button>
              </>
            )}
            <button
              onClick={() => setHeaderCollapsed((v) => !v)}
              title={headerCollapsed ? 'Show header controls' : 'Hide header controls'}
              aria-label={headerCollapsed ? 'Expand header' : 'Collapse header'}
              style={{
                width: isPhone ? 40 : 28, height: isPhone ? 40 : 28,
                marginLeft: headerCollapsed ? 0 : 4,
                borderRadius: 6,
                border: '1px solid #1a1a1a18',
                background: 'transparent',
                color: '#1a1a1a88',
                cursor: 'pointer', fontSize: isPhone ? 16 : 12, padding: 0,
              }}
            >{headerCollapsed ? '⌄' : '⌃'}</button>
          </div>
        </header>
        {isPhone && !headerCollapsed && (
          <>
            <div
              style={atelier.phoneSheetBackdrop}
              onClick={() => setHeaderCollapsed(true)}
              aria-label="Close settings"
            />
            <div style={atelier.phoneSheet}>
              <div style={atelier.phoneSheetTitle}>SETTINGS</div>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Strict CBN</span>
                <input type="checkbox" checked={strictMode} onChange={(e) => setStrictMode(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(strictMode ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(strictMode ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Numbers</span>
                <input type="checkbox" checked={showNumbers} onChange={(e) => setShowNumbers(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(showNumbers ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(showNumbers ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Lines</span>
                <input type="checkbox" checked={showLines} onChange={(e) => setShowLines(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(showLines ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(showLines ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Brush</span>
                <input type="checkbox" checked={brushMode} onChange={(e) => setBrushMode(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(brushMode ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(brushMode ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Pencil only</span>
                <input type="checkbox" checked={pencilOnly} onChange={(e) => setPencilOnly(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(pencilOnly ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(pencilOnly ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <label style={atelier.phoneRow}>
                <span style={atelier.phoneRowText}>Pan</span>
                <input type="checkbox" checked={panTool} onChange={(e) => setPanTool(e.target.checked)} style={{ display: 'none' }} />
                <span style={{ ...atelier.toggleTrack, ...(panTool ? atelier.toggleTrackOn : {}) }}>
                  <span style={{ ...atelier.toggleKnob, ...(panTool ? atelier.toggleKnobOn : {}) }} />
                </span>
              </label>
              <button
                onClick={() => setHeaderCollapsed(true)}
                style={atelier.phoneSheetDone}
              >Done</button>
            </div>
          </>
        )}
        {debugPointer && (
          <div style={{
            position: 'fixed', top: isNarrow ? 50 : 70, right: 8, zIndex: 9999,
            width: 300, maxHeight: '70vh', overflow: 'auto',
            background: 'rgba(26,26,26,0.94)', color: '#fafaf5',
            fontFamily: 'JetBrains Mono, monospace', fontSize: 10,
            borderRadius: 8, padding: 10,
            boxShadow: '0 12px 40px -12px rgba(0,0,0,0.5)',
            border: '1px solid rgba(250,250,245,0.2)',
          }}>
            <div style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '1px dotted #fafaf522', opacity: 0.85 }}>
              <div style={{ fontSize: 11, color: '#ffd97a', marginBottom: 4 }}>ENV</div>
              {(() => {
                const e = window.__zenEnv || {};
                const browser = e.isChromeIOS ? 'Chrome iOS (mobile UA)'
                  : e.isFirefoxIOS ? 'Firefox iOS'
                  : e.isEdgeIOS ? 'Edge iOS'
                  : e.isSafari ? 'Safari iOS'
                  : e.isIPadInDesktopMode ? 'iPad in DESKTOP MODE (UA = macOS) — likely Chrome'
                  : 'Other / Desktop';
                return (
                  <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 2, fontSize: 9 }}>
                    <div><span style={{ opacity: 0.5 }}>browser:</span> {browser}</div>
                    <div><span style={{ opacity: 0.5 }}>iPad:</span> {String(!!e.isIPad)} <span style={{ opacity: 0.5, marginLeft: 8 }}>desktopMode:</span> {String(!!e.isIPadInDesktopMode)}</div>
                    <div><span style={{ opacity: 0.5 }}>maxTouch:</span> {e.maxTouchPoints} <span style={{ opacity: 0.5, marginLeft: 8 }}>coarse:</span> {String(e.coarsePointer)} <span style={{ opacity: 0.5, marginLeft: 8 }}>PE:</span> {String(e.pointerEvents)}</div>
                  </div>
                );
              })()}
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6, opacity: 0.7 }}>
              <span>POINTER LOG · {debugEvents.length}</span>
              <button onClick={() => setDebugEvents([])} style={{ background: 'transparent', border: '1px solid #fafaf544', color: '#fafaf5', cursor: 'pointer', fontSize: 9, padding: '1px 6px', borderRadius: 3 }}>clear</button>
            </div>
            {debugEvents.length === 0 && (
              <div style={{ fontStyle: 'italic', opacity: 0.5, padding: '8px 0' }}>
                Touch the canvas with your Pencil. Events will appear here.
              </div>
            )}
            {debugEvents.map((ev, i) => (
              <div key={`${ev.ts}-${i}`} style={{
                padding: '3px 0', borderBottom: '1px dotted #fafaf522',
                display: 'grid', gridTemplateColumns: '54px 44px 28px 32px 28px 1fr',
                gap: 4, fontSize: 10,
              }}>
                <span style={{ color: ev.t === 'down' ? '#ffd97a' : ev.t === 'up' ? '#7adfff' : ev.t === 'PAINT' ? '#a4ff7a' : '#fafaf5aa' }}>{ev.t}</span>
                <span style={{ color: ev.type === 'pen' ? '#7aff96' : ev.type === 'touch' ? '#ff9a7a' : ev.type === 'brush' ? '#a4ff7a' : ev.type === 'tap' ? '#ffd97a' : '#7aafff' }}>{ev.type}</span>
                <span style={{ opacity: 0.6 }}>#{ev.id}</span>
                <span style={{ opacity: 0.6 }}>b{ev.b}</span>
                <span style={{ opacity: 0.6 }}>p{ev.p}</span>
                <span style={{ opacity: 0.5, textAlign: 'right' }}>{ev.x},{ev.y}</span>
              </div>
            ))}
          </div>
        )}

        <div style={atelier.body}>
          {brushHint && (
            <div style={atelier.brushHintToast}>
              <div style={atelier.brushHintTitle}>
                <span style={atelier.brushHintDot} />
                Strict CBN is filtering your brush
              </div>
              <div style={atelier.brushHintBody}>
                Only regions matching color #{brushHint.selectedNum} will paint.
                Skipped {brushHint.rejectedCount} region{brushHint.rejectedCount === 1 ? '' : 's'}.
              </div>
              <button
                onClick={() => { setStrictMode(false); setBrushHint(null); }}
                style={atelier.brushHintCta}
              >Brush across any color →</button>
              <button
                onClick={() => setBrushHint(null)}
                style={atelier.brushHintDismiss}
                aria-label="Dismiss"
              >×</button>
            </div>
          )}
          {libOpen && isOverlay && (
            <div
              style={atelier.railBackdrop}
              onClick={() => setLibOpen(false)}
              aria-label="Close menu"
            />
          )}
          {libOpen && (
            <aside style={isOverlay ? { ...atelier.rail, ...atelier.railOverlay } : atelier.rail}>
              <SearchRail
                kind={c.kind} setKind={c.setKind}
                renderStyle={renderStyle} setRenderStyle={setRenderStyle}
                onGenerated={({ genKind, prompt, style }) => {
                  c.setKind(genKind);
                  setSelected(1);
                  setHueTab('all');
                  setHighlightIdx(null);
                  setPreviewMode(false);
                  setCurrentSaveId(null);   // a fresh generation isn't tied to any save
                  setCurrentPrompt(prompt || '');
                  setCurrentStyle(style || '');
                }}
                saves={saves}
                onLoadSave={async (saveMeta) => {
                  try {
                    // List entry has only metadata; pull the full record (with
                    // colorable + brush strokes) from the server.
                    const full = await apiGetSave(saveMeta.id);
                    const kind = hydrateServerSave(full);
                    c.setKindFills(kind, full.fills || {});
                    c.setKind(kind);
                    setSelected(1);
                    setHueTab('all');
                    setHighlightIdx(null);
                    setPreviewMode(false);
                    setCurrentSaveId(full.id);
                    setCurrentPrompt(full.prompt || '');
                    setCurrentStyle(full.style || '');
                    // Replay brush strokes after a tick so ColorCanvas has
                    // mounted the canvas for the new kind.
                    if (Array.isArray(full.brushStrokes) && full.brushStrokes.length > 0) {
                      setTimeout(() => {
                        window.dispatchEvent(new CustomEvent('zen:import-brush-history', { detail: { strokes: full.brushStrokes } }));
                      }, 50);
                    } else {
                      // Clear any leftover strokes from the previous canvas.
                      window.dispatchEvent(new CustomEvent('zen:brush-clear'));
                    }
                    // Reset gesture state — a stuck panRef from a prior canvas
                    // would freeze scroll on the loaded one (Fix #9).
                    pointersRef.current.clear();
                    panRef.current = null;
                    pinchRef.current = null;
                    lastPencilTsRef.current = 0;
                    pendingScrollRef.current = null;
                  } catch (e) {
                    setSaveStatus({ kind: 'err', msg: 'Load failed: ' + e.message });
                    setTimeout(() => setSaveStatus(null), 3000);
                  }
                }}
                onDeleteSave={async (id) => {
                  try {
                    await apiDeleteSave(id);
                    setSaves((prev) => prev.filter((s) => s.id !== id));
                    if (currentSaveId === id) setCurrentSaveId(null);
                  } catch (e) {
                    setSaveStatus({ kind: 'err', msg: 'Delete failed: ' + e.message });
                    setTimeout(() => setSaveStatus(null), 3000);
                  }
                }}
              />
            </aside>
          )}

          <main style={isNarrow ? { ...atelier.center, ...atelier.centerNarrow } : atelier.center}>
            {hasIllustration ? (
              <>
                {/* Editorial block hides on narrow — its info already lives in the header brandSub */}
                {!isNarrow && (
                  <div style={atelier.editorial}>
                    <div style={atelier.editorialTag}>
                      {ill.tag.toUpperCase()} · {totalCount} REGIONS · {activePalette.length} COLORS
                      {data?.colorableSize ? ` · ${data.colorableSize.width}×${data.colorableSize.height}` : ''}
                    </div>
                    <h1 style={atelier.editorialTitle}>{ill.title}</h1>
                  </div>
                )}

                <div
                  ref={scrollRef}
                  style={{
                    ...atelier.canvasScroll,
                    // touch-action: none so the browser hands ALL gestures to JS
                    // (no native pinch/pan fighting our pinch+pan handler).
                    touchAction: 'none',
                    cursor: (spaceDown || pencilOnly || panTool) ? (panRef.current ? 'grabbing' : 'grab') : 'default',
                  }}
                  onScroll={recomputeVisibleBox}
                  onWheel={onCanvasWheel}
                  onPointerDownCapture={onCanvasPointerDownCapture}
                  onPointerMoveCapture={onCanvasPointerMoveCapture}
                  onPointerUpCapture={onCanvasPointerEndCapture}
                  onPointerCancelCapture={onCanvasPointerEndCapture}
                >
                  <div style={atelier.canvasWrap}>
                    <ColorCanvas
                      kind={c.kind} fills={c.fills} onPaint={c.paint}
                      anim={anim} ink="#1a1a1a" size={canvasSize * zoom}
                      selectedColor={selectedColor}
                      selectedNum={safeSelected}
                      strictMatch={c.kind.startsWith('gen-') && strictMode}
                      showNumbers={showNumbers}
                      numberMap={numberMap}
                      highlightIdx={highlightIdx}
                      lineWeight={showLines ? lineWeight : 0}
                      previewMode={false}
                      regionMeta={data?.regionMeta}
                      brushMode={brushMode}
                      brushSize={brushSize}
                      colorableSize={data?.colorableSize}
                    />
                  </div>
                  {previewMode && (() => {
                    // Side-by-side preview: render the gradient/source rendition
                    // as a non-interactive sibling of the editable canvas. Lives
                    // inside the same scrollRef so pinch-zoom and pan affect both
                    // halves together. pointer-events: none so the preview never
                    // captures pointer input meant for the editable canvas.
                    const Comp = ILLUSTRATIONS[c.kind]?.Comp;
                    if (!Comp) return null;
                    return (
                      <div style={{ ...atelier.canvasWrap, marginLeft: 16, pointerEvents: 'none' }}>
                        <Comp
                          fills={{}}
                          anim={anim}
                          size={canvasSize * zoom}
                          showNumbers={false}
                          numberMap={{}}
                          lineWeight={0}
                          previewMode={true}
                        />
                      </div>
                    );
                  })()}
                </div>
              </>
            ) : (
              <EmptyStateGenerator
                isNarrow={isNarrow}
                isPhone={isPhone}
                onOpenRail={() => setLibOpen(true)}
                onGenerated={({ genKind, prompt, style }) => {
                  c.setKind(genKind);
                  setSelected(1);
                  setHueTab('all');
                  setHighlightIdx(null);
                  setPreviewMode(false);
                  setCurrentSaveId(null);
                  setCurrentPrompt(prompt || '');
                  setCurrentStyle(style || '');
                }}
              />
            )}

            {hasIllustration && (
            <div style={isNarrow ? { ...atelier.actions, ...atelier.actionsNarrow } : atelier.actions}>
              <button style={isNarrow ? { ...atelier.actionBtn, ...atelier.actionBtnNarrow } : atelier.actionBtn} onClick={() => {
                // Brush mode keeps its own per-stroke history on a canvas; route
                // undo there so the last stroke's pixels are actually cleared.
                // Fill mode falls through to c.undo (shrinks the fills map).
                if (brushMode) window.dispatchEvent(new CustomEvent('zen:brush-undo'));
                else c.undo();
              }}>↶</button>
              <button style={isNarrow ? { ...atelier.actionBtn, ...atelier.actionBtnNarrow } : atelier.actionBtn} onClick={() => {
                c.reset();
                // Brush strokes don't update fills, so the fills-empty effect
                // wouldn't fire for brush-only sessions. Fire the brush clear
                // event explicitly to wipe the canvas + history.
                window.dispatchEvent(new CustomEvent('zen:brush-clear'));
              }}>Clear</button>
              <span style={atelier.actionDivider} />
              <button style={isNarrow ? { ...atelier.actionBtn, ...atelier.actionBtnNarrow } : atelier.actionBtn} onClick={() => setZoom((z) => Math.max(0.1, +(z * 0.8).toFixed(3)))}>−</button>
              <span style={atelier.zoomLabel}>{zoom >= 1 ? `${Math.round(zoom * 100)}%` : `${(zoom * 100).toFixed(0)}%`}</span>
              <button style={isNarrow ? { ...atelier.actionBtn, ...atelier.actionBtnNarrow } : atelier.actionBtn} onClick={() => setZoom((z) => Math.min(16, +(z * 1.25).toFixed(3)))}>+</button>
              <button style={isNarrow ? { ...atelier.actionBtn, ...atelier.actionBtnNarrow } : atelier.actionBtn} onClick={() => setZoom(fitZoom)} title="Fit to viewport">Fit</button>
              {!isNarrow && (
                <button style={atelier.actionBtn} onClick={() => setZoom(1)} title="Native pixels (1:1)">1:1</button>
              )}
              <span style={atelier.actionDivider} />
              <span style={atelier.actionMeta}>{filledCount}/{totalCount}</span>
              {!isNarrow && (
                <>
                  <span style={atelier.actionDivider} />
                  <label style={atelier.lineWeightWrap} title="Line weight">
                    <span style={atelier.lineWeightLabel}>LINE</span>
                    <input type="range" min={0.5} max={2.5} step={0.1} value={lineWeight}
                      onChange={(e) => setLineWeight(parseFloat(e.target.value))}
                      style={atelier.lineWeightInput} />
                    <span style={atelier.lineWeightVal}>{lineWeight.toFixed(1)}</span>
                  </label>
                </>
              )}
              {brushMode && (
                <>
                  <span style={atelier.actionDivider} />
                  <label style={atelier.lineWeightWrap} title="Brush size (source-image pixels)">
                    <span style={atelier.lineWeightLabel}>BRUSH</span>
                    <input type="range" min={4} max={80} step={1} value={brushSize}
                      onChange={(e) => setBrushSize(parseInt(e.target.value, 10))}
                      style={atelier.lineWeightInput} />
                    <span style={atelier.lineWeightVal}>{brushSize}</span>
                  </label>
                </>
              )}
              <span style={atelier.actionDivider} />
              <button
                style={{
                  ...atelier.actionBtn,
                  ...(isNarrow ? atelier.actionBtnNarrow : {}),
                  opacity: hasIllustration ? 1 : 0.5,
                }}
                disabled={!hasIllustration}
                title={currentSaveId ? 'Update saved canvas' : 'Save painting (tap-fills + brush strokes) to your account'}
                onClick={async () => {
                  if (!hasIllustration) return;
                  // Capture brush strokes from ColorCanvas if any. Returns
                  // [] when there are no strokes or the canvas isn't mounted.
                  const brushStrokes = (typeof window !== 'undefined' && window.__zenGetBrushHistory)
                    ? window.__zenGetBrushHistory()
                    : [];
                  const fills = { ...(c.fills || {}) };
                  const title = ill.title;
                  const generationId = data?.generationId || null;
                  try {
                    setSaveStatus({ kind: 'ok', msg: 'Saving…' });
                    if (currentSaveId) {
                      await apiUpdateSave(currentSaveId, { title, fills, brushStrokes });
                      // Refresh metadata in the saves list.
                      setSaves((prev) => prev.map((s) => s.id === currentSaveId
                        ? { ...s, title, updatedAt: Date.now(), filledCount: Object.keys(fills).length }
                        : s));
                      setSaveStatus({ kind: 'ok', msg: 'Updated' });
                    } else {
                      // First save for this canvas — POST to create.
                      if (!generationId) throw new Error('no generationId on this canvas (refresh and re-generate)');
                      const { id } = await apiCreateSave({ generationId, title, fills, brushStrokes });
                      setCurrentSaveId(id);
                      setSaves((prev) => [{
                        id, generationId, title, updatedAt: Date.now(),
                        filledCount: Object.keys(fills).length,
                      }, ...prev]);
                      setSaveStatus({ kind: 'ok', msg: 'Saved' });
                    }
                  } catch (e) {
                    setSaveStatus({ kind: 'err', msg: 'Save failed: ' + e.message });
                  }
                  setTimeout(() => setSaveStatus(null), 2500);
                }}
              >💾 {isNarrow ? '' : (currentSaveId ? 'Update save' : 'Save')}</button>
              {saveStatus && (
                <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: saveStatus.kind === 'ok' ? '#3d7a3d' : '#8a2820' }}>
                  {saveStatus.msg}
                </span>
              )}
              <span style={atelier.actionDivider} />
              <button
                style={{
                  ...atelier.actionBtn,
                  ...atelier.actionBtnPrimary,
                  ...(isNarrow ? atelier.actionBtnNarrow : {}),
                  opacity: hasIllustration ? 1 : 0.5,
                }}
                onClick={() => { if (hasIllustration) setPreviewMode((v) => !v); }}
                disabled={!hasIllustration}
              >👁 {isNarrow ? '' : (previewMode ? 'Hide Preview' : 'Show Preview')}</button>
            </div>
            )}

            {hasIllustration && (paletteOpen ? (
              <PaletteStrip
                palette={activePalette}
                hueTab={hueTab}
                setHueTab={setHueTab}
                hueBuckets={hueBuckets}
                visibleIdxs={visibleIdxs}
                visibleColorIdxs={visibleColorIdxs}
                visibleOnly={visibleOnly}
                setVisibleOnly={setVisibleOnly}
                safeSelected={safeSelected}
                setSelected={(n) => { setSelected(n); setHighlightIdx(null); }}
                setHighlightIdx={setHighlightIdx}
                colorProgress={colorProgress}
                selectedColor={selectedColor}
                selectedName={selectedName}
                totalForSelected={totalForSelected}
                filledForSelected={filledForSelected}
                onClose={() => setPaletteOpen(false)}
                isTouch={isTouchEnv}
                isNarrow={isNarrow}
              />
            ) : (
              <button style={atelier.paletteRevealStrip} onClick={() => setPaletteOpen(true)}>
                <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.16em' }}>SHOW PALETTE ⌃</span>
              </button>
            ))}
          </main>
        </div>

        <DragGhost />
      </div>
    </DragProvider>
  );
}

// ─── Sketch mode ─────────────────────────────────────────────────────
// A blank white canvas where you draw freeform with Pencil/finger/mouse.
// No generation, no regions, no strict CBN — just raw stroke capture. If
// drawing works HERE but not on a generated canvas, the issue is in the
// region/paint pipeline. If it doesn't work here, the issue is upstream
// (browser pointer event delivery, gesture lock, etc.).
function SketchMode({ onExit }) {
  const svgRef = React.useRef(null);
  const [strokes, setStrokes] = React.useState([]);
  const [color, setColor] = React.useState('#1a1a1a');
  const [width, setWidth] = React.useState(3);
  const currentRef = React.useRef(null);
  const ptrIdRef = React.useRef(null);
  const [diag, setDiag] = React.useState({ down: 0, move: 0, up: 0, lastType: '-', lastBtn: 0, lastPressure: 0 });

  const screenToSvg = (clientX, clientY) => {
    const svg = svgRef.current;
    if (!svg) return { x: clientX, y: clientY };
    const pt = svg.createSVGPoint();
    pt.x = clientX; pt.y = clientY;
    const ctm = svg.getScreenCTM();
    if (!ctm) return { x: clientX, y: clientY };
    const inv = ctm.inverse();
    const r = pt.matrixTransform(inv);
    return { x: r.x, y: r.y };
  };

  const onDown = (e) => {
    e.preventDefault();
    setDiag((d) => ({ ...d, down: d.down + 1, lastType: e.pointerType, lastBtn: e.buttons, lastPressure: e.pressure }));
    ptrIdRef.current = e.pointerId;
    try { svgRef.current.setPointerCapture && svgRef.current.setPointerCapture(e.pointerId); } catch {}
    const p = screenToSvg(e.clientX, e.clientY);
    currentRef.current = { color, width, points: [`${p.x.toFixed(1)},${p.y.toFixed(1)}`] };
    setStrokes((s) => [...s, currentRef.current]);
  };
  const onMove = (e) => {
    if (currentRef.current == null) return;
    if (e.pointerId !== ptrIdRef.current) return;
    setDiag((d) => ({ ...d, move: d.move + 1, lastType: e.pointerType, lastBtn: e.buttons, lastPressure: e.pressure }));
    const p = screenToSvg(e.clientX, e.clientY);
    currentRef.current.points.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
    setStrokes((s) => s.slice());
  };
  const onUp = (e) => {
    setDiag((d) => ({ ...d, up: d.up + 1 }));
    currentRef.current = null;
    ptrIdRef.current = null;
  };

  const palette = ['#1a1a1a', '#c84a32', '#e8b04a', '#7a9b8e', '#4a5d8a', '#6a4878'];

  return (
    <div style={sketchStyles.wrap}>
      <div style={sketchStyles.toolbar}>
        <button onClick={onExit} style={sketchStyles.exitBtn}>← Back</button>
        <span style={sketchStyles.title}>Sketch · diagnostic free-draw</span>
        <div style={sketchStyles.colorRow}>
          {palette.map((c) => (
            <button key={c} onClick={() => setColor(c)} style={{ ...sketchStyles.colorChip, background: c, ...(color === c ? sketchStyles.colorChipOn : {}) }} />
          ))}
        </div>
        <div style={sketchStyles.widthRow}>
          <span style={sketchStyles.widthLabel}>SIZE</span>
          <input type="range" min={1} max={20} value={width} onChange={(e) => setWidth(parseInt(e.target.value, 10))} style={{ width: 120, accentColor: '#1a1a1a' }} />
        </div>
        <button onClick={() => { setStrokes([]); setDiag({ down: 0, move: 0, up: 0, lastType: '-', lastBtn: 0, lastPressure: 0 }); }} style={sketchStyles.clearBtn}>Clear</button>
      </div>

      <div style={sketchStyles.diagBar}>
        <span><b>down</b> {diag.down}</span>
        <span><b>move</b> {diag.move}</span>
        <span><b>up</b> {diag.up}</span>
        <span><b>type</b> <span style={{ color: diag.lastType === 'pen' ? '#3d7a3d' : diag.lastType === 'touch' ? '#c84a32' : '#4a5d8a' }}>{diag.lastType}</span></span>
        <span><b>btns</b> {diag.lastBtn}</span>
        <span><b>pressure</b> {diag.lastPressure?.toFixed(2)}</span>
        <span><b>strokes</b> {strokes.length}</span>
      </div>

      <div style={sketchStyles.canvasWrap}>
        <svg
          ref={svgRef}
          viewBox="0 0 1000 1000"
          preserveAspectRatio="xMidYMid meet"
          style={sketchStyles.svg}
          onPointerDown={onDown}
          onPointerMove={onMove}
          onPointerUp={onUp}
          onPointerCancel={onUp}
        >
          <rect x={0} y={0} width={1000} height={1000} fill="#fafaf5" />
          {strokes.map((s, i) => (
            <polyline
              key={i}
              points={s.points.join(' ')}
              fill="none"
              stroke={s.color}
              strokeWidth={s.width}
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          ))}
        </svg>
      </div>

      <div style={sketchStyles.footer}>
        If you can draw lines here, your Pencil/touch events are working.
        If not, the debug bar above shows what events are firing (or not).
      </div>
    </div>
  );
}

const sketchStyles = {
  wrap: {
    flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0,
    background: '#fafaf5',
  },
  toolbar: {
    display: 'flex', alignItems: 'center', gap: 12,
    padding: '10px 16px', borderBottom: '1px solid #1a1a1a14',
    flexWrap: 'wrap',
  },
  exitBtn: {
    fontFamily: 'Geist, sans-serif', fontSize: 12, padding: '10px 14px',
    background: 'transparent', border: '1px solid #1a1a1a30', borderRadius: 6,
    cursor: 'pointer', minHeight: 44,
  },
  title: {
    fontFamily: 'JetBrains Mono, monospace', fontSize: 11, letterSpacing: '0.12em',
    color: '#1a1a1a99',
  },
  colorRow: { display: 'flex', gap: 6 },
  colorChip: {
    width: 36, height: 36, borderRadius: 18,
    border: '2px solid #fafaf5', boxShadow: '0 0 0 1px #1a1a1a22',
    cursor: 'pointer', padding: 0, transition: 'transform 150ms',
  },
  colorChipOn: { transform: 'scale(1.15)', boxShadow: '0 0 0 2px #1a1a1a' },
  widthRow: { display: 'flex', alignItems: 'center', gap: 8 },
  widthLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.16em', color: '#1a1a1a66' },
  clearBtn: {
    fontFamily: 'Geist, sans-serif', fontSize: 12, padding: '10px 14px',
    background: 'transparent', border: '1px solid #1a1a1a30', borderRadius: 999,
    cursor: 'pointer', minHeight: 44, marginLeft: 'auto',
  },
  diagBar: {
    display: 'flex', gap: 14, flexWrap: 'wrap',
    padding: '6px 16px',
    background: '#1a1a1a',
    color: '#fafaf5',
    fontFamily: 'JetBrains Mono, monospace', fontSize: 10,
    letterSpacing: '0.04em',
  },
  canvasWrap: {
    flex: 1, minHeight: 0,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    padding: 12,
    background: '#ece2c9',
  },
  svg: {
    width: '100%', height: '100%',
    maxWidth: '100%', maxHeight: '100%',
    background: '#fff',
    border: '1px solid #1a1a1a14',
    borderRadius: 4,
    touchAction: 'none',
    cursor: 'crosshair',
    boxShadow: '0 12px 40px -16px #1a1a1a30',
  },
  footer: {
    fontFamily: 'Cormorant Garamond, serif', fontStyle: 'italic',
    fontSize: 13, color: '#1a1a1a88',
    textAlign: 'center', padding: '8px 16px',
  },
};

// ─── Empty state ─────────────────────────────────────────────────────
// When there's no canvas yet, the center stage shows an "atelier card" with
// an inline prompt + style picker. iPad-friendly hit targets, distinctive
// editorial composition (Cormorant display + JetBrains accents) so the
// first-run experience doesn't fall back to a generic search box.
function EmptyStateGenerator({ isNarrow, isPhone, onGenerated, onOpenRail }) {
  const [query, setQuery] = React.useState('');
  const [style, setStyle] = React.useState('illustration');
  const [target, setTarget] = React.useState(800);
  const [genState, setGenState] = React.useState({ status: 'idle', error: null });
  const trimmed = query.trim();
  const styleLabel = style === 'oil-painting' ? 'an Oil Painting' : style === 'realistic' ? 'a Realistic photograph' : 'an Illustration';

  const generate = async () => {
    if (!trimmed) return;
    setGenState({ status: 'loading', error: null });
    try {
      const r = await fetch('/api/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt: trimmed, style, targetRegions: target }),
      });
      const data = await r.json();
      if (!r.ok) throw new Error(data?.error || 'generation failed');
      if (!data.colorable) throw new Error('no colorable in response');
      const { palette, regions, numberMap, sourceColorImageHref, width, height } = data.colorable;
      const genKind = `gen-${slugify(trimmed)}-${Date.now()}`;
      const Comp = makeDynamicComp({ regions, width, height });
      ILLUSTRATIONS[genKind] = {
        Comp,
        title: trimmed.replace(/\b\w/g, (c) => c.toUpperCase()),
        tag: 'Generated',
        category: 'generated',
        tags: trimmed.toLowerCase().split(/\s+/),
      };
      REGION_DATA[genKind] = {
        count: regions.length,
        regions: regions.map((r) => r.id),
        numberMap, palette, sourceColorImageHref,
        regionMeta: regions, colorableSize: { width, height },
        // Server-issued id of the generation row. Needed by the Save button so
        // the save record can link back to its source for re-hydration.
        generationId: data.generationId,
      };
      onGenerated && onGenerated({ genKind, prompt: trimmed, style });
    } catch (err) {
      setGenState({ status: 'error', error: String(err.message || err) });
    }
  };

  const seeds = ['mountain at sunset', 'sleeping cat', 'koi pond', 'sunflowers in rain', 'forest path', 'ocean wave'];

  // Phone layout drops everything optional: eyebrow, the marketing headline,
  // and the OR-TRY seeds (those live in the library rail, no need to duplicate).
  // The wrap is scrollable so even a clipped card is reachable.
  const wrapStyle = isPhone
    ? { ...emptyStyles.wrap, ...emptyStyles.wrapPhone }
    : emptyStyles.wrap;
  const cardStyle = isPhone
    ? { ...emptyStyles.card, ...emptyStyles.cardPhone }
    : emptyStyles.card;

  return (
    <div style={wrapStyle}>
      <div style={cardStyle}>
        {!isPhone && (
          <>
            <div style={emptyStyles.eyebrow}>BEGIN A WORK</div>
            <div style={emptyStyles.headline}>
              <em style={emptyStyles.headlineEm}>Type</em>{' '}any scene.<br />
              We'll{' '}<em style={emptyStyles.headlineEm}>render</em>{' '}it as {styleLabel}.
            </div>
          </>
        )}
        {isPhone && (
          <div style={emptyStyles.phoneTitle}>Type a scene to color</div>
        )}

        <div style={emptyStyles.inputRow}>
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') generate(); }}
            placeholder={isPhone ? 'e.g. koi pond at dusk' : 'A koi pond at dusk…'}
            style={isPhone ? { ...emptyStyles.input, ...emptyStyles.inputPhone } : emptyStyles.input}
            autoFocus={!isPhone}
          />
        </div>

        <div style={emptyStyles.styleRow}>
          {[
            { id: 'illustration', label: 'Illustration' },
            { id: 'oil-painting', label: 'Oil' },
            { id: 'realistic', label: 'Realistic' },
          ].map((s) => (
            <button key={s.id}
              onClick={() => setStyle(s.id)}
              style={{
                ...emptyStyles.styleChip,
                ...(style === s.id ? emptyStyles.styleChipOn : {}),
              }}>{s.label}</button>
          ))}
        </div>

        <div style={emptyStyles.detailRow}>
          <span style={emptyStyles.detailLabel}>DETAIL</span>
          <input type="range" min={50} max={1500} step={50} value={target}
            onChange={(e) => setTarget(parseInt(e.target.value, 10))}
            style={emptyStyles.detailSlider} />
          <span style={emptyStyles.detailValue}>{target}</span>
        </div>

        <button
          onClick={generate}
          disabled={!trimmed || genState.status === 'loading'}
          style={{
            ...emptyStyles.cta,
            ...(genState.status === 'loading' || !trimmed ? emptyStyles.ctaDisabled : {}),
          }}>
          {genState.status === 'loading' ? '⟳ generating…' : '✨ Begin'}
        </button>

        {genState.status === 'error' && (
          <div style={emptyStyles.error}>Couldn't reach /api/generate. {genState.error}</div>
        )}

        {!isPhone && (
          <>
            <div style={emptyStyles.divider}>
              <span style={emptyStyles.dividerLine} />
              <span style={emptyStyles.dividerLabel}>OR TRY</span>
              <span style={emptyStyles.dividerLine} />
            </div>

            <div style={emptyStyles.seedsRow}>
              {seeds.map((s) => (
                <button key={s}
                  onClick={() => { setQuery(s); }}
                  style={emptyStyles.seedChip}>{s}</button>
              ))}
            </div>
          </>
        )}

        {isNarrow && (
          <button onClick={onOpenRail} style={emptyStyles.savedLink}>
            ⌕ Library / saved work
          </button>
        )}
      </div>
    </div>
  );
}

const emptyStyles = {
  wrap: {
    flex: 1, minHeight: 0,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    padding: 16,
  },
  // Phone: scroll the wrap rather than the card. alignItems: flex-start means
  // the card hugs the top, so the input is always the first thing the user
  // sees regardless of how much further content lives below.
  wrapPhone: {
    alignItems: 'flex-start',
    overflowY: 'auto',
    WebkitOverflowScrolling: 'touch',
    padding: '8px 12px 20px',
  },
  card: {
    width: '100%', maxWidth: 560,
    background: '#fff',
    border: '1px solid #1a1a1a14',
    borderRadius: 4,
    boxShadow: '0 1px 0 #1a1a1a06, 0 32px 64px -32px #1a1a1a30',
    padding: '36px 32px 28px',
    display: 'flex', flexDirection: 'column', gap: 14,
    position: 'relative',
  },
  cardPhone: {
    padding: '18px 16px 16px',
    gap: 12,
    boxShadow: '0 1px 0 #1a1a1a06, 0 16px 32px -20px #1a1a1a30',
  },
  phoneTitle: {
    fontFamily: 'Cormorant Garamond, serif',
    fontSize: 22, fontWeight: 500, lineHeight: 1.15,
    color: '#1a1a1a',
    marginBottom: 2,
  },
  inputPhone: {
    fontSize: 18,
    padding: '10px 4px',
  },
  eyebrow: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 10, letterSpacing: '0.24em',
    color: '#1a1a1a66',
    paddingBottom: 6,
    borderBottom: '1px solid #1a1a1a10',
  },
  headline: {
    fontFamily: 'Cormorant Garamond, serif',
    fontSize: 28, fontWeight: 400, lineHeight: 1.2,
    color: '#1a1a1a',
    margin: '4px 0 8px',
  },
  headlineEm: {
    fontStyle: 'italic',
    fontWeight: 500,
    background: 'linear-gradient(180deg, transparent 60%, #e8b04a55 60%)',
    padding: '0 2px',
  },
  inputRow: { display: 'flex', alignItems: 'center', gap: 8 },
  input: {
    flex: 1,
    fontFamily: 'Cormorant Garamond, serif',
    fontSize: 22, fontStyle: 'italic',
    background: 'transparent',
    border: 'none',
    borderBottom: '1.5px solid #1a1a1a',
    outline: 'none',
    padding: '8px 2px',
    color: '#1a1a1a',
    minHeight: 44,
  },
  styleRow: { display: 'flex', gap: 6, flexWrap: 'wrap' },
  styleChip: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 11, letterSpacing: '0.08em',
    padding: '10px 14px',
    minHeight: 44, minWidth: 44,
    borderRadius: 999,
    border: '1px solid #1a1a1a18',
    background: '#fafaf5',
    color: '#1a1a1a99',
    cursor: 'pointer',
    transition: 'background 180ms, color 180ms, border-color 180ms',
  },
  styleChipOn: {
    background: '#1a1a1a',
    color: '#fafaf5',
    borderColor: '#1a1a1a',
  },
  detailRow: { display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 },
  detailLabel: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 9, letterSpacing: '0.16em', color: '#1a1a1a66',
    minWidth: 44,
  },
  detailSlider: { flex: 1, accentColor: '#1a1a1a', minHeight: 30 },
  detailValue: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 11, color: '#1a1a1a',
    minWidth: 88, textAlign: 'right',
  },
  cta: {
    marginTop: 4,
    fontFamily: 'Geist, sans-serif',
    fontSize: 14, fontWeight: 500,
    padding: '14px 18px',
    background: '#1a1a1a', color: '#fafaf5',
    border: 'none', borderRadius: 6,
    cursor: 'pointer',
    minHeight: 50,
    letterSpacing: '0.02em',
    transition: 'transform 150ms, opacity 150ms',
  },
  ctaDisabled: {
    background: 'transparent',
    color: '#1a1a1a66',
    border: '1px dashed #1a1a1a30',
    cursor: 'not-allowed',
  },
  error: {
    fontFamily: 'Cormorant Garamond, serif', fontStyle: 'italic',
    fontSize: 13, color: '#8a2820',
    background: '#fae8e8', borderRadius: 4, padding: 10, lineHeight: 1.4,
  },
  divider: {
    display: 'flex', alignItems: 'center', gap: 8,
    margin: '6px 0 2px',
  },
  dividerLine: { flex: 1, height: 1, background: '#1a1a1a14' },
  dividerLabel: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 9, letterSpacing: '0.24em', color: '#1a1a1a55',
  },
  seedsRow: { display: 'flex', gap: 6, flexWrap: 'wrap' },
  seedChip: {
    fontFamily: 'Cormorant Garamond, serif',
    fontStyle: 'italic',
    fontSize: 14,
    padding: '8px 12px',
    minHeight: 36,
    borderRadius: 999,
    background: '#1a1a1a06',
    border: '1px solid #1a1a1a14',
    color: '#1a1a1a',
    cursor: 'pointer',
  },
  savedLink: {
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 10, letterSpacing: '0.16em',
    padding: '10px 12px',
    minHeight: 44,
    background: 'transparent',
    border: '1px solid #1a1a1a14',
    borderRadius: 6,
    color: '#1a1a1a99',
    cursor: 'pointer',
    marginTop: 8,
  },
};

// ─── Horizontal palette strip ────────────────────────────────────────
// Sits below the canvas like an artist's paint tray. Hue tabs across the
// top, swatches scroll horizontally, "Now" panel pinned at the left as a
// permanent reference for the active swatch.
function PaletteStrip({
  palette, hueTab, setHueTab, hueBuckets,
  visibleIdxs, visibleColorIdxs, visibleOnly, setVisibleOnly,
  safeSelected, setSelected, setHighlightIdx,
  colorProgress, selectedColor, selectedName,
  totalForSelected, filledForSelected, onClose,
  isTouch = false, isNarrow = false,
}) {
  // Apple HIG / Material recommend ≥44pt for touch targets. ALWAYS use ≥44 on
  // touch devices — narrow viewport shouldn't override touch hitbox safety.
  const swatchSize = isTouch ? 44 : (isNarrow ? 36 : 30);
  // Apply visible-only filter on top of hue-tab filtering.
  const filteredIdxs = React.useMemo(() => {
    if (!visibleOnly || !visibleColorIdxs) return visibleIdxs;
    return visibleIdxs.filter((i) => visibleColorIdxs.has(i));
  }, [visibleIdxs, visibleColorIdxs, visibleOnly]);

  const visCount = visibleColorIdxs ? visibleColorIdxs.size : palette.length;

  return (
    <section style={isNarrow ? { ...atelier.paletteStrip, ...atelier.paletteStripNarrow } : atelier.paletteStrip}>
      <div style={atelier.paletteStripTop}>
        <div style={atelier.hueTabsRow}>
          {HUE_TAB_DEFS.map((t) => {
            const count = t.id === 'all' ? palette.length : (hueBuckets[t.id]?.length || 0);
            const disabled = t.id !== 'all' && count === 0;
            // On phones the empty buckets are pure noise — six "· 0" pills
            // crowding the hue row. Hide them entirely.
            if (isNarrow && disabled) return null;
            const active = hueTab === t.id;
            return (
              <button key={t.id} disabled={disabled}
                onClick={() => setHueTab(t.id)}
                style={{
                  ...atelier.hueTabHorz,
                  ...(isNarrow ? atelier.hueTabHorzNarrow : {}),
                  ...(active ? atelier.hueTabHorzActive : {}),
                  opacity: disabled ? 0.25 : 1,
                }}
                title={`${t.label} · ${count}`}>
                <span style={{ ...atelier.hueTabSwatch, background: t.swatch }} />
                {!isNarrow && <span>{t.label}</span>}
                <span style={atelier.hueTabCount}>{count}</span>
              </button>
            );
          })}
        </div>
        <div style={atelier.paletteStripMeta}>
          <button
            onClick={() => setVisibleOnly(!visibleOnly)}
            style={{
              ...atelier.visiblePill,
              ...(isNarrow ? atelier.hueTabHorzNarrow : {}),
              ...(visibleOnly ? atelier.visiblePillOn : {}),
            }}
            title="Show only colors used by regions visible in your current zoom area"
          >
            <span style={atelier.visibleDot} />
            {isNarrow
              ? (visibleOnly ? `visible · ${visCount}` : `all · ${palette.length}`)
              : `${visibleOnly ? 'visible only' : 'show all'} · ${visibleOnly ? visCount : palette.length}`}
          </button>
          <button style={atelier.iconBtnSmall} onClick={onClose} aria-label="Hide palette" title="Hide palette">⌄</button>
        </div>
      </div>

      <div style={isNarrow ? { ...atelier.paletteStripRow, minHeight: 50, gap: 8 } : atelier.paletteStripRow}>
        <div style={isNarrow ? { ...atelier.nowCard, minWidth: 110, padding: '4px 8px', gap: 6 } : atelier.nowCard}>
          <div style={isNarrow
            ? { ...atelier.nowSwatch, width: 28, height: 28, background: selectedColor }
            : { ...atelier.nowSwatch, background: selectedColor }}>
            <span style={atelier.nowSwatchNum}>{safeSelected}</span>
          </div>
          <div style={atelier.nowMeta}>
            <div style={isNarrow ? { ...atelier.nowName, fontSize: 14 } : atelier.nowName}>{selectedName}</div>
            {!isNarrow && (
              <div style={atelier.nowHex}>{selectedColor.toUpperCase()}</div>
            )}
            {totalForSelected > 0 && (
              <div style={{
                fontFamily: 'JetBrains Mono, monospace', fontSize: 9.5, marginTop: isNarrow ? 1 : 3,
                color: filledForSelected >= totalForSelected ? '#3d7a3d' : '#1a1a1a99',
                letterSpacing: '0.04em',
              }}>
                {filledForSelected}/{totalForSelected}{filledForSelected >= totalForSelected ? ' ✓' : ''}
              </div>
            )}
          </div>
        </div>

        <div style={atelier.swatchScroll}>
          {filteredIdxs.length === 0 && (
            <div style={atelier.swatchEmpty}>
              {visibleOnly ? 'No colors in this view. Pan or flip to "show all".' : 'No swatches in this hue.'}
            </div>
          )}
          {filteredIdxs.map((idx) => {
            const p = palette[idx - 1];
            if (!p) return null;
            return (
              <NumberedSwatch key={`${p.hex}-${idx}`} index={idx} hex={p.hex} name={p.name}
                size={swatchSize}
                selected={safeSelected === idx}
                total={colorProgress.totals.get(idx) || 0}
                filled={colorProgress.filled.get(idx) || 0}
                onSelect={(n) => setSelected(n)}
                onLongPress={() => setHighlightIdx(idx)}
                onLongPressEnd={() => setHighlightIdx(null)}
              />
            );
          })}
        </div>
      </div>
    </section>
  );
}

// ─── Dynamic illustration component factory ───
// Two display modes:
//   • Paintable (lineWeight > 0): black strokes form the line art. Filled
//     regions show palette colors (flat). Unfilled regions are nearly
//     transparent so click-hits work.
//   • Preview  (lineWeight = 0): each region is filled with a 2-stop
//     linear gradient sampled from the source image (when the region had
//     enough luminance variance), or its mean source color. Photo-fidelity
//     via gradients — derived from quantization, NOT a source-image overlay.
function makeDynamicComp({ regions, width, height }) {
  // Sort largest-first so smaller (visually nested) regions render LAST in
  // document order. document.elementFromPoint returns the topmost — i.e. last
  // in source order — so this ensures clicks on a small region inside a big
  // one hit the small region, not its container. Required because contour
  // extraction emits each region as a single closed path without holes,
  // so a containing region's path covers its inner regions' pixels.
  const sortedRegions = [...regions].sort((a, b) => (b.area || 0) - (a.area || 0));
  return function DynamicComp({ fills, anim = 'bleed', size = 500, showNumbers = false, numberMap = {}, lineWeight = 1.0, previewMode = false, brushMode = false }) {
    const trans = anim === 'snap' ? 'fill 0s'
                : anim === 'wash' ? 'fill 500ms ease-out'
                : 'fill 600ms cubic-bezier(0.22,1,0.36,1)';
    // previewMode (gradient render) is now an explicit prop, decoupled from
    // lineWeight=0. So "Lines off" hides strokes without forcing gradient mode.
    const gradRegions = previewMode ? sortedRegions.filter((r) => r.gradient) : [];
    return (
      <svg viewBox={`0 0 ${width} ${height}`} width={size} height={size} style={{ display: 'block', userSelect: 'none', touchAction: 'none' }}>
        {previewMode && gradRegions.length > 0 && (
          <defs>
            {gradRegions.map((r) => (
              <linearGradient key={`g-${r.id}`} id={`grad-${r.id}`}
                gradientUnits="userSpaceOnUse"
                x1={r.gradient.x1} y1={r.gradient.y1}
                x2={r.gradient.x2} y2={r.gradient.y2}>
                <stop offset="0%" stopColor={r.gradient.c1} />
                <stop offset="100%" stopColor={r.gradient.c2} />
              </linearGradient>
            ))}
          </defs>
        )}
        <g className="regions">
          {sortedRegions.map((r) => {
            const fill = fills[r.id];
            // For regions with topologically-nested children, append each
            // hole's d-string and switch the SVG fill rule to even-odd. This
            // stops A's color from bleeding through B's transparent path
            // before B is painted (the "nested color overlap" bug).
            const hasHoles = Array.isArray(r.holes) && r.holes.length > 0;
            const pathD = hasHoles ? (r.d + ' ' + r.holes.join(' ')) : r.d;
            const fillRule = hasHoles ? 'evenodd' : undefined;
            let pathFill, stroke, sw;
            if (previewMode) {
              pathFill = r.gradient ? `url(#grad-${r.id})` : (r.meanHex || fill || '#888888');
              stroke = r.meanHex || fill || 'none';
              sw = 1;
            } else if (lineWeight === 0) {
              // Lines OFF. Boundary-tracer simplification + SVG anti-aliasing
              // leave a sub-pixel gap between adjacent paths, which shows the
              // canvas background (white) through as "white lines." Render a
              // tiny stroke in the fill color on painted regions to close the
              // gap invisibly. Unpainted regions stay strokeless so the empty
              // canvas reads as truly empty.
              pathFill = fill || 'rgba(255,255,255,0.001)';
              if (fill) { stroke = fill; sw = 0.6; }
              else      { stroke = 'none'; sw = 0; }
            } else {
              pathFill = fill || 'rgba(255,255,255,0.001)';
              // For chunks split out of one oversized parent (siblingGroup set),
              // draw the stroke in the fill color once painted so the internal
              // boundary between siblings dissolves into the fill. The outer
              // parent silhouette is still drawn by neighboring non-sibling
              // regions (e.g. the foreground subject) whose strokes remain ink.
              stroke = (r.siblingGroup && fill) ? fill : '#1a1a1a';
              sw = lineWeight;
            }
            return (
              <path key={r.id} d={pathD}
                data-region={r.id} data-num={numberMap[r.id] || ''}
                fill={pathFill}
                fillRule={fillRule}
                stroke={stroke}
                strokeWidth={sw}
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
                pointerEvents="all"
                style={{ cursor: 'pointer', transition: trans }} />
            );
          })}
        </g>
        {!previewMode && showNumbers && regions.map((r) => {
          // Defensive fallback: if numberMap is missing this region (would be
          // a server bug), derive the number from r.colorIdx so the user
          // doesn't see unlabeled blank regions.
          const num = numberMap[r.id] ?? (r.colorIdx != null ? r.colorIdx + 1 : null);
          if (!num) return null;
          // Position from server-computed pole-of-inaccessibility (always
          // inside the region). Font size from inscribed circle radius
          // (labelR) so the digits actually fit. Multi-digit numbers need
          // more horizontal space — shrink font for 2+ digits.
          const lx = r.labelX ?? r.cx;
          const ly = r.labelY ?? r.cy;
          const lr = r.labelR ?? Math.sqrt(r.area || 0) / 4;
          const digits = String(num).length;
          const widthFactor = digits === 1 ? 1.6 : digits === 2 ? 1.05 : 0.75;
          const fontSize = Math.max(6, Math.min(24, lr * widthFactor));
          return (
            <text key={`n-${r.id}`} x={lx} y={ly + fontSize / 3} textAnchor="middle"
              pointerEvents="none"
              fontFamily="JetBrains Mono, monospace"
              fontSize={fontSize}
              fontWeight="500"
              fill="#1a1a1a"
              stroke="#fafaf5"
              strokeWidth={Math.max(0.8, fontSize / 6)}
              paintOrder="stroke fill"
              style={{ userSelect: 'none' }}>
              {num}
            </text>
          );
        })}
      </svg>
    );
  };
}

function slugify(s) {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'gen';
}

// ─── Hue bucketing ───────────────────────────────────────────────────
const HUE_TAB_DEFS = [
  { id: 'all',      label: 'All',      swatch: 'linear-gradient(90deg,#c84a32,#e8b04a,#7a9b8e,#4a5d8a,#6a4878)' },
  { id: 'neutrals', label: 'Neutrals', swatch: '#9a9a9a' },
  { id: 'reds',     label: 'Reds',     swatch: '#c84a32' },
  { id: 'oranges',  label: 'Oranges',  swatch: '#d97740' },
  { id: 'yellows',  label: 'Yellows',  swatch: '#e8b04a' },
  { id: 'greens',   label: 'Greens',   swatch: '#7a9b8e' },
  { id: 'blues',    label: 'Blues',    swatch: '#4a5d8a' },
  { id: 'purples',  label: 'Purples',  swatch: '#6a4878' },
];

function bucketPaletteByHue(palette) {
  const buckets = { neutrals: [], reds: [], oranges: [], yellows: [], greens: [], blues: [], purples: [] };
  palette.forEach((p, i) => {
    const idx = i + 1;
    const m = /^#?([0-9a-f]{6})$/i.exec(p.hex);
    if (!m) return;
    const n = parseInt(m[1], 16);
    const r = (n >> 16) & 0xff, g = (n >> 8) & 0xff, b = n & 0xff;
    const max = Math.max(r, g, b), min = Math.min(r, g, b);
    const sat = max === min ? 0 : (max - min) / (max < 128 ? max + min : 510 - max - min);
    if (sat < 0.12) { buckets.neutrals.push(idx); return; }
    let h;
    const d = max - min;
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
    else if (max === g) h = ((b - r) / d + 2) * 60;
    else h = ((r - g) / d + 4) * 60;
    if (h < 18 || h >= 330) buckets.reds.push(idx);
    else if (h < 45) buckets.oranges.push(idx);
    else if (h < 70) buckets.yellows.push(idx);
    else if (h < 165) buckets.greens.push(idx);
    else if (h < 250) buckets.blues.push(idx);
    else buckets.purples.push(idx);
  });
  return buckets;
}

// ─── Search rail ─────────────────────────────────────────────────────
// Upload-your-own-image affordance. POSTs multipart/form-data to /api/upload
// and hydrates the same way the AI-generate path does. Lives next to the
// generate button in the rail.
function UploadImage({ onGenerated, targetRegions, setGenState, genState }) {
  const pickerRef = React.useRef(null);
  const cameraRef = React.useRef(null);
  const handleFile = async (file) => {
    if (!file) return;
    setGenState({ status: 'loading', error: null, message: `Processing ${file.name}…` });
    try {
      const form = new FormData();
      form.append('image', file);
      form.append('title', file.name.replace(/\.[^.]+$/, ''));
      form.append('targetRegions', String(targetRegions));
      const r = await fetch('/api/upload', { method: 'POST', body: form, credentials: 'same-origin' });
      const data = await r.json();
      if (!r.ok) throw new Error(data?.error || 'upload failed');
      if (!data.colorable) throw new Error('no colorable in response');
      const { palette, regions, numberMap, sourceColorImageHref, width, height } = data.colorable;
      const baseName = (file.name || 'upload').replace(/\.[^.]+$/, '');
      const genKind = `gen-${slugify(baseName)}-${Date.now()}`;
      const Comp = makeDynamicComp({ regions, width, height });
      ILLUSTRATIONS[genKind] = {
        Comp,
        title: baseName.replace(/\b\w/g, (c) => c.toUpperCase()),
        tag: 'Uploaded',
        category: 'uploaded',
        tags: baseName.toLowerCase().split(/[\s_-]+/),
      };
      REGION_DATA[genKind] = {
        count: regions.length,
        regions: regions.map((r) => r.id),
        numberMap, palette, sourceColorImageHref,
        regionMeta: regions, colorableSize: { width, height },
        generationId: data.generationId,
      };
      onGenerated && onGenerated({ genKind, prompt: baseName, style: 'upload' });
      setGenState({ status: 'done', error: null, message: `${regions.length} regions · ${palette.length} colors` });
    } catch (err) {
      setGenState({ status: 'error', error: String(err.message || err), message: null });
    }
  };
  // Two parallel inputs: the camera one carries capture="environment" so iOS
  // opens the rear camera directly; the picker one omits capture so iOS shows
  // the full "Photo Library / Take Photo / Choose File" sheet.
  const btnStyle = {
    ...atelier.genBtn,
    background: 'transparent',
    color: '#1a1a1a',
    border: '1px solid #1a1a1a30',
    flex: 1,
    textAlign: 'center',
    minHeight: 40,
  };
  const disabled = genState.status === 'loading';
  return (
    <>
      <input
        ref={pickerRef}
        type="file"
        accept="image/*"
        style={{ display: 'none' }}
        onChange={(e) => {
          const f = e.target.files?.[0];
          if (f) handleFile(f);
          e.target.value = '';
        }}
      />
      <input
        ref={cameraRef}
        type="file"
        accept="image/*"
        capture="environment"
        style={{ display: 'none' }}
        onChange={(e) => {
          const f = e.target.files?.[0];
          if (f) handleFile(f);
          e.target.value = '';
        }}
      />
      <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
        <button style={btnStyle} onClick={() => cameraRef.current?.click()} disabled={disabled}>
          📷 Camera
        </button>
        <button style={btnStyle} onClick={() => pickerRef.current?.click()} disabled={disabled}>
          🖼 Upload
        </button>
      </div>
    </>
  );
}

function SearchRail({ kind, setKind, onGenerated, renderStyle, setRenderStyle, saves = [], onLoadSave, onDeleteSave }) {
  const [query, setQuery] = React.useState('');
  const [genState, setGenState] = React.useState({ status: 'idle', error: null, message: null });
  const [targetRegions, setTargetRegions] = React.useState(800);
  const trimmed = query.trim();
  const showSearch = trimmed.length > 0;
  const suggestions = ['cat sleeping', 'mountain at sunset', 'sunflowers', 'koi pond', 'ocean wave', 'forest path'];

  const generate = async () => {
    if (!trimmed) return;
    setGenState({ status: 'loading', error: null, message: 'Generating…' });
    try {
      const r = await fetch('/api/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt: trimmed, style: renderStyle, targetRegions }),
      });
      const data = await r.json();
      if (!r.ok) throw new Error(data?.error || 'generation failed');
      if (!data.colorable) throw new Error('no colorable in response');

      const { palette, regions, numberMap, sourceColorImageHref, width, height } = data.colorable;
      const genKind = `gen-${slugify(trimmed)}-${Date.now()}`;
      const Comp = makeDynamicComp({ regions, width, height });
      ILLUSTRATIONS[genKind] = {
        Comp,
        title: trimmed.replace(/\b\w/g, (c) => c.toUpperCase()),
        tag: 'Generated',
        category: 'generated',
        tags: trimmed.toLowerCase().split(/\s+/),
      };
      REGION_DATA[genKind] = {
        count: regions.length,
        regions: regions.map((r) => r.id),
        numberMap,
        palette,
        sourceColorImageHref,
        regionMeta: regions,
        colorableSize: { width, height },
        generationId: data.generationId,
      };
      onGenerated && onGenerated({ genKind, prompt: trimmed, style: renderStyle });
      setGenState({ status: 'done', error: null, message: `${regions.length} regions · ${palette.length} colors` });
    } catch (err) {
      setGenState({ status: 'error', error: String(err.message || err), message: null });
    }
  };

  const styleLabel = renderStyle === 'oil-painting' ? 'Oil Painting' : renderStyle === 'realistic' ? 'Realistic' : 'Illustration';

  return (
    <>
      <div style={{ ...atelier.smallLabel, marginBottom: 4 }}>STYLE</div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 8 }}>
        {[
          { id: 'illustration', label: 'Illustration' },
          { id: 'oil-painting', label: 'Oil' },
          { id: 'realistic', label: 'Realistic' },
        ].map((s) => (
          <button key={s.id}
            onClick={() => setRenderStyle && setRenderStyle(s.id)}
            style={{
              fontFamily: 'Geist, sans-serif', fontSize: 11, padding: '5px 6px', borderRadius: 6,
              border: 'none', cursor: 'pointer', letterSpacing: '0.04em',
              background: renderStyle === s.id ? '#1a1a1a' : '#1a1a1a08',
              color: renderStyle === s.id ? '#fafaf5' : '#1a1a1a99',
              transition: 'background 180ms, color 180ms',
            }}>{s.label}</button>
        ))}
      </div>

      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 4 }}>
        <span style={atelier.smallLabel}>DETAIL</span>
        <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: '#1a1a1a' }}>{targetRegions} regions</span>
      </div>
      <input type="range" min={50} max={1500} step={50} value={targetRegions}
        onChange={(e) => setTargetRegions(parseInt(e.target.value, 10))}
        style={{ width: '100%', accentColor: '#1a1a1a' }}
        title="Target number of regions (50 = simple, 1500 = max detail)" />
      <div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: 'JetBrains Mono, monospace', fontSize: 9, color: '#1a1a1a66', marginTop: -8 }}>
        <span>simple</span>
        <span>standard</span>
        <span>max</span>
      </div>

      <div style={atelier.searchWrap}>
        <span style={atelier.searchIcon}>⌕</span>
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter') generate(); }}
          placeholder="Type a scene to color…"
          style={atelier.searchInput}
        />
        {query && (
          <button style={atelier.searchClear} onClick={() => setQuery('')}>×</button>
        )}
      </div>
      {showSearch && (
        <button style={atelier.genBtn} onClick={generate} disabled={genState.status === 'loading'}>
          {genState.status === 'loading' ? 'Generating…' : `✨ Generate as ${styleLabel}`}
        </button>
      )}
      <UploadImage
        onGenerated={onGenerated}
        targetRegions={targetRegions}
        setGenState={setGenState}
        genState={genState}
      />
      {genState.status === 'error' && (
        <div style={atelier.genError}>Couldn't reach /api/generate. {genState.error}</div>
      )}
      {genState.message && genState.status !== 'error' && (
        <div style={{ ...atelier.genCaption, textAlign: 'left', padding: '4px 2px', textTransform: 'none', letterSpacing: 0, fontStyle: 'italic' }}>
          {genState.message}
        </div>
      )}
      {!showSearch && (
        <div style={atelier.suggestionsRow}>
          {suggestions.map((s) => (
            <button key={s} style={atelier.chip} onClick={() => setQuery(s)}>{s}</button>
          ))}
        </div>
      )}

      {saves.length > 0 && (
        <>
          <div style={atelier.divider} />
          <div style={{ ...atelier.smallLabel }}>SAVED · {saves.length}</div>
          <div style={atelier.savesList}>
            {saves.map((s) => (
              <div key={s.id} style={{ ...atelier.saveItem, ...(kind === s.kind ? atelier.saveItemActive : {}) }}>
                <button onClick={() => onLoadSave && onLoadSave(s)} style={atelier.saveLoadBtn}>
                  <div style={atelier.saveTitle}>{s.title}</div>
                  <div style={atelier.saveMeta}>
                    {s.filledCount}/{s.totalCount} · {fmtRelTime(s.updatedAt)}
                  </div>
                </button>
                <button
                  style={atelier.saveDeleteBtn}
                  onClick={() => {
                    if (window.confirm(`Delete "${s.title}"?`)) onDeleteSave && onDeleteSave(s.id);
                  }}
                  title="Delete this save"
                >×</button>
              </div>
            ))}
          </div>
        </>
      )}
    </>
  );
}

window.SearchRail = SearchRail;

// ─── Numbered swatch ─────────────────────────────────────────────────
function NumberedSwatch({ index, hex, name, selected, total = 0, filled = 0, size = 30, onSelect, onLongPress, onLongPressEnd }) {
  const drag = useDrag();
  const timerRef = React.useRef(null);
  const longRef = React.useRef(false);

  const onDown = (e) => {
    e.preventDefault();
    longRef.current = false;
    drag.startDrag(hex, e);
    timerRef.current = setTimeout(() => {
      longRef.current = true;
      onLongPress && onLongPress();
    }, 350);
  };
  const onUp = () => {
    if (timerRef.current) clearTimeout(timerRef.current);
    if (longRef.current) onLongPressEnd && onLongPressEnd();
    else onSelect(index);
    // Synchronously end the swatch drag — relying on the DragProvider's
    // window pointerup listener creates a race on iPad Chrome where the
    // listener isn't attached yet when up fires, leaving drag.active stuck
    // and blocking subsequent canvas paints.
    if (drag && drag.setActive) {
      drag.setActive(false);
      drag.setDragColor && drag.setDragColor(null);
    }
  };
  const onLeaveBtn = () => {
    if (timerRef.current) clearTimeout(timerRef.current);
    // Don't end the drag here — pointer might still be down for a drag-to-canvas.
  };
  const lum = (() => {
    const r = parseInt(hex.slice(1,3),16)/255, g = parseInt(hex.slice(3,5),16)/255, b = parseInt(hex.slice(5,7),16)/255;
    return 0.299*r + 0.587*g + 0.114*b;
  })();
  const fg = lum > 0.6 ? '#1a1a1a' : '#fafaf5';
  const isDone = total > 0 && filled >= total;
  const remaining = total - filled;
  const titleText = total > 0
    ? `${index} · ${name} — ${filled}/${total} painted${isDone ? ' ✓' : ''}`
    : `${index} · ${name}`;

  return (
    <button
      onPointerDown={onDown}
      onPointerUp={onUp}
      onPointerLeave={onLeaveBtn}
      title={titleText}
      style={{
        width: size, height: size, borderRadius: Math.round(size * 0.18),
        background: hex,
        border: selected ? '1.5px solid #1a1a1a' : '1px solid #1a1a1a18',
        boxShadow: selected ? '0 0 0 3px #1a1a1a14, 0 2px 4px #1a1a1a18' : '0 1px 2px #1a1a1a08',
        cursor: 'grab', padding: 0,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'JetBrains Mono, monospace',
        fontSize: Math.max(9.5, Math.round(size * 0.32)),
        color: fg,
        opacity: isDone ? 0.4 : 0.95,
        transition: 'transform 180ms, box-shadow 180ms, opacity 200ms',
        transform: selected ? 'scale(1.1)' : 'scale(1)',
        touchAction: 'manipulation',
        position: 'relative',
        flexShrink: 0,
      }}
    >
      <span style={{ textDecoration: isDone ? 'line-through' : 'none' }}>{index}</span>
      {isDone && (
        <span style={{
          position: 'absolute', top: -4, right: -4,
          width: 13, height: 13, borderRadius: '50%',
          background: '#3d7a3d', color: '#fff',
          fontSize: 9, lineHeight: '13px', textAlign: 'center',
          boxShadow: '0 0 0 1.5px #fafaf5',
          fontFamily: 'system-ui, sans-serif', fontWeight: 700,
        }}>✓</span>
      )}
      {!isDone && total > 0 && remaining <= 3 && (
        <span style={{
          position: 'absolute', bottom: -4, right: -4,
          minWidth: 13, height: 13, padding: '0 3px', borderRadius: 7,
          background: '#1a1a1a', color: '#fafaf5',
          fontSize: 8.5, lineHeight: '13px', textAlign: 'center',
          boxShadow: '0 0 0 1.5px #fafaf5',
          fontFamily: 'JetBrains Mono, monospace',
        }}>{remaining}</span>
      )}
    </button>
  );
}

window.NumberedSwatch = NumberedSwatch;

const atelier = {
  root: {
    fontFamily: 'Geist, -apple-system, sans-serif',
    background: '#fafaf5',
    color: '#1a1a1a',
    width: '100%', height: '100%',
    display: 'flex', flexDirection: 'column',
    overflow: 'hidden',
    touchAction: 'none',
  },
  header: {
    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
    padding: '14px 28px', borderBottom: '1px solid #1a1a1a14',
    flexShrink: 0,
  },
  headerNarrow: {
    padding: '6px 12px',
  },
  brand: { display: 'flex', alignItems: 'center', gap: 10 },
  libToggle: { width: 44, height: 44, borderRadius: 8, border: '1px solid #1a1a1a18', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 6, fontSize: 16, color: '#1a1a1a' },
  libToggleIcon: { fontSize: 14, color: '#1a1a1a88' },
  mark: { width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' },
  brandName: { fontFamily: 'Cormorant Garamond, serif', fontSize: 22, fontWeight: 500, lineHeight: 1 },
  brandSub: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.12em', color: '#1a1a1a88', marginTop: 4 },
  versionBadge: {
    fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
    letterSpacing: '0.08em', color: '#1a1a1a66',
    padding: '1px 6px', borderRadius: 999,
    background: '#1a1a1a0a', border: '1px solid #1a1a1a14',
  },
  headerRight: { display: 'flex', alignItems: 'center', gap: 16 },
  toggleWrap: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', minHeight: 44, padding: '0 4px' },
  toggleTrack: { width: 30, height: 18, borderRadius: 999, background: '#1a1a1a18', position: 'relative', transition: 'background 200ms' },
  toggleTrackOn: { background: '#1a1a1a' },
  toggleKnob: { position: 'absolute', top: 2, left: 2, width: 14, height: 14, borderRadius: '50%', background: '#fafaf5', transition: 'transform 200ms' },
  toggleKnobOn: { transform: 'translateX(12px)' },
  toggleLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.12em', color: '#1a1a1a' },

  // Phone-only: stacked settings sheet rendered just below the header when the
  // chevron is expanded. Replaces the inline toggle row (which would overflow
  // a 393px viewport with 6+ toggles).
  phoneSheetBackdrop: {
    position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
    background: '#1a1a1a30', zIndex: 70,
  },
  phoneSheet: {
    position: 'fixed', top: 56, right: 8, zIndex: 71,
    minWidth: 240, maxWidth: 'calc(100vw - 16px)',
    background: '#fafaf5',
    border: '1px solid #1a1a1a14',
    borderRadius: 12,
    boxShadow: '0 18px 48px -16px #1a1a1a40',
    padding: '10px 8px 8px',
    display: 'flex', flexDirection: 'column', gap: 2,
  },
  phoneSheetTitle: {
    fontFamily: 'JetBrains Mono, monospace', fontSize: 9,
    letterSpacing: '0.2em', color: '#1a1a1a66',
    padding: '4px 10px 8px',
  },
  phoneRow: {
    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
    minHeight: 48, padding: '0 12px',
    cursor: 'pointer', borderRadius: 8,
  },
  phoneRowText: {
    fontFamily: 'Geist, sans-serif', fontSize: 15, color: '#1a1a1a',
  },
  phoneSheetDone: {
    marginTop: 6,
    fontFamily: 'Geist, sans-serif', fontSize: 14, fontWeight: 500,
    padding: '12px 16px',
    background: '#1a1a1a', color: '#fafaf5',
    border: 'none', borderRadius: 10,
    cursor: 'pointer', minHeight: 44,
  },

  body: { display: 'flex', flex: 1, minHeight: 0, position: 'relative' },

  rail: { width: 230, borderRight: '1px solid #1a1a1a10', padding: '20px 16px', display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flexShrink: 0 },
  railOverlay: {
    position: 'absolute', top: 0, left: 0, bottom: 0, zIndex: 50,
    width: 'min(300px, 86vw)', background: '#fafaf5',
    boxShadow: '6px 0 24px -8px #1a1a1a30',
  },
  railBackdrop: {
    position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
    background: '#1a1a1a30', zIndex: 49,
    cursor: 'pointer',
  },
  railLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.16em', color: '#1a1a1a66' },
  smallLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.16em', color: '#1a1a1a66' },
  searchWrap: { position: 'relative', display: 'flex', alignItems: 'center', background: '#fff', border: '1px solid #1a1a1a18', borderRadius: 6, padding: '0 8px', height: 32 },
  searchIcon: { fontSize: 14, color: '#1a1a1a66', marginRight: 6 },
  searchInput: { border: 'none', outline: 'none', flex: 1, fontFamily: 'Geist, sans-serif', fontSize: 13, background: 'transparent', color: '#1a1a1a' },
  searchClear: { width: 18, height: 18, borderRadius: '50%', border: 'none', background: '#1a1a1a18', color: '#1a1a1a', cursor: 'pointer', fontSize: 14, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 },
  suggestionsRow: { display: 'flex', flexWrap: 'wrap', gap: 4 },
  chip: { fontFamily: 'Geist, sans-serif', fontSize: 11, padding: '3px 8px', borderRadius: 999, background: '#1a1a1a06', border: '1px solid #1a1a1a14', cursor: 'pointer', color: '#1a1a1a99' },
  genBtn: { fontFamily: 'Geist, sans-serif', fontSize: 12, padding: '8px 10px', borderRadius: 6, background: '#1a1a1a', color: '#fafaf5', border: 'none', cursor: 'pointer', textAlign: 'left' },
  genError: { fontFamily: 'Cormorant Garamond, serif', fontStyle: 'italic', fontSize: 12, color: '#8a2820', background: '#fae8e8', borderRadius: 4, padding: 8, lineHeight: 1.4 },
  genCaption: { fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.12em', color: '#1a1a1a66', textAlign: 'center', textTransform: 'uppercase' },
  savesList: { display: 'flex', flexDirection: 'column', gap: 4 },
  saveItem: { display: 'flex', alignItems: 'stretch', gap: 4, background: '#fff', border: '1px solid #1a1a1a18', borderRadius: 6, padding: 6 },
  saveItemActive: { borderColor: '#1a1a1a', boxShadow: '0 0 0 2px #1a1a1a10' },
  saveLoadBtn: { flex: 1, background: 'transparent', border: 'none', textAlign: 'left', cursor: 'pointer', padding: 0, display: 'flex', flexDirection: 'column', gap: 2 },
  saveTitle: { fontFamily: 'Cormorant Garamond, serif', fontSize: 14, fontWeight: 500, color: '#1a1a1a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
  saveMeta: { fontFamily: 'JetBrains Mono, monospace', fontSize: 9, color: '#1a1a1a66', letterSpacing: '0.04em' },
  saveDeleteBtn: { width: 22, background: 'transparent', border: 'none', color: '#1a1a1a66', cursor: 'pointer', fontSize: 14, padding: 0, alignSelf: 'flex-start' },

  center: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch', padding: '14px 24px 0', gap: 10, overflow: 'hidden', minWidth: 0 },
  centerNarrow: { padding: '6px 8px 0', gap: 4 },
  editorial: { textAlign: 'center', alignSelf: 'center', maxWidth: 720, flexShrink: 0 },
  editorialTag: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.18em', color: '#1a1a1a88' },
  editorialTitle: { fontFamily: 'Cormorant Garamond, serif', fontWeight: 400, fontSize: 26, lineHeight: 1.1, margin: '2px 0 0' },
  canvasScroll: {
    overflow: 'auto',
    flex: '1 1 auto',
    minHeight: 0,
    borderRadius: 6,
    background: '#f1ebdd',
    boxShadow: 'inset 0 1px 0 #1a1a1a08, inset 0 0 0 1px #1a1a1a08',
    display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start',
    padding: 18,
  },
  canvasWrap: {
    background: '#fff', border: '1px solid #1a1a1a14', borderRadius: 4,
    boxShadow: '0 1px 0 #1a1a1a06, 0 24px 56px -28px #1a1a1a30',
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
    margin: 'auto',
  },
  actions: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'center', flexShrink: 0, padding: '6px 0' },
  actionsNarrow: { gap: 6, padding: '4px 0' },
  actionBtn: { fontFamily: 'Geist, sans-serif', fontSize: 12, padding: '6px 12px', background: 'transparent', border: '1px solid #1a1a1a30', borderRadius: 999, cursor: 'pointer', letterSpacing: '0.04em', minHeight: 36 },
  // ≥44 minHeight on touch even in compact mode — Apple HIG. Visual padding
  // stays tight; the tappable area is what counts.
  actionBtnNarrow: { fontSize: 12, padding: '10px 12px', minHeight: 44, minWidth: 44 },
  actionBtnPrimary: { background: '#1a1a1a', color: '#fafaf5', borderColor: '#1a1a1a' },
  actionDivider: { width: 1, height: 14, background: '#1a1a1a18' },
  actionMeta: { fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: '#1a1a1a88' },
  zoomLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 11, color: '#1a1a1a88', minWidth: 36, textAlign: 'center' },
  lineWeightWrap: { display: 'inline-flex', alignItems: 'center', gap: 6 },
  lineWeightLabel: { fontFamily: 'JetBrains Mono, monospace', fontSize: 9, letterSpacing: '0.12em', color: '#1a1a1a66' },
  lineWeightInput: { width: 80, accentColor: '#1a1a1a' },
  lineWeightVal: { fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: '#1a1a1a88', minWidth: 24, textAlign: 'right' },

  iconBtnSmall: { width: 44, height: 44, borderRadius: 8, border: '1px solid #1a1a1a18', background: 'transparent', cursor: 'pointer', fontSize: 14, color: '#1a1a1a88', display: 'flex', alignItems: 'center', justifyContent: 'center' },

  // ── Horizontal palette strip — paint tray below the canvas ──
  paletteStrip: {
    flexShrink: 0,
    background: 'linear-gradient(#f0e8d6, #ece2c9)',
    borderTop: '1px solid #1a1a1a14',
    borderBottom: '1px solid #1a1a1a08',
    boxShadow: 'inset 0 2px 4px -2px #1a1a1a14, inset 0 -1px 0 #1a1a1a06',
    margin: '0 -24px',
    padding: '10px 24px 14px',
    display: 'flex', flexDirection: 'column', gap: 8,
  },
  paletteStripNarrow: {
    margin: '0 -8px',
    padding: '4px 8px 6px',
    gap: 4,
  },
  paletteStripTop: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' },
  hueTabsRow: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
  hueTabHorz: {
    display: 'inline-flex', alignItems: 'center', gap: 5,
    fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase',
    padding: '4px 10px', borderRadius: 999,
    background: 'rgba(255,255,255,0.4)', border: '1px solid #1a1a1a14',
    color: '#1a1a1a99', cursor: 'pointer',
    transition: 'background 180ms, color 180ms, border-color 180ms',
  },
  hueTabHorzNarrow: { padding: '12px 10px', fontSize: 10, minHeight: 44 },
  hueTabHorzActive: { background: '#1a1a1a', color: '#fafaf5', borderColor: '#1a1a1a' },
  hueTabSwatch: { width: 7, height: 7, borderRadius: '50%', display: 'inline-block' },
  hueTabCount: { fontSize: 9, opacity: 0.6, marginLeft: 2 },

  paletteStripMeta: { display: 'flex', alignItems: 'center', gap: 8 },
  visiblePill: {
    display: 'inline-flex', alignItems: 'center', gap: 6,
    fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.08em',
    padding: '10px 14px', borderRadius: 999,
    minHeight: 36,
    background: 'rgba(255,255,255,0.5)', border: '1px solid #1a1a1a14',
    color: '#1a1a1a99', cursor: 'pointer',
    transition: 'background 180ms, color 180ms',
  },
  visiblePillOn: { background: '#1a1a1a', color: '#fafaf5', borderColor: '#1a1a1a' },
  visibleDot: { width: 6, height: 6, borderRadius: '50%', background: 'currentColor', opacity: 0.8 },

  paletteStripRow: { display: 'flex', alignItems: 'center', gap: 14, minHeight: 70 },
  nowCard: {
    display: 'flex', alignItems: 'center', gap: 10,
    padding: '8px 12px', borderRadius: 6,
    background: 'rgba(255,255,255,0.7)',
    border: '1px solid #1a1a1a14',
    boxShadow: '0 1px 0 #fff inset, 0 1px 2px #1a1a1a08',
    flexShrink: 0,
    minWidth: 158,
  },
  nowSwatch: { width: 38, height: 38, borderRadius: 4, border: '1px solid #1a1a1a22', boxShadow: 'inset 0 1px 0 #ffffff30', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
  nowSwatchNum: { fontFamily: 'JetBrains Mono, monospace', fontSize: 11, fontWeight: 500, color: '#fafaf5', mixBlendMode: 'difference' },
  nowMeta: { display: 'flex', flexDirection: 'column', gap: 0, lineHeight: 1.15 },
  nowName: { fontFamily: 'Cormorant Garamond, serif', fontSize: 17, fontStyle: 'italic', color: '#1a1a1a' },
  nowHex: { fontFamily: 'JetBrains Mono, monospace', fontSize: 9.5, color: '#1a1a1a66', letterSpacing: '0.08em' },

  swatchScroll: {
    flex: 1, minWidth: 0,
    display: 'flex', alignItems: 'center', gap: 8,
    overflowX: 'auto', overflowY: 'visible',
    padding: '8px 4px',
    scrollbarWidth: 'thin',
  },
  swatchEmpty: { fontFamily: 'Cormorant Garamond, serif', fontStyle: 'italic', fontSize: 13, color: '#1a1a1a66', padding: '0 12px' },

  divider: { height: 1, background: '#1a1a1a10', margin: '6px 0' },
  brushHintToast: {
    position: 'absolute', top: 16, left: '50%', transform: 'translateX(-50%)',
    zIndex: 60,
    background: '#1a1a1a',
    color: '#fafaf5',
    fontFamily: 'Geist, sans-serif',
    fontSize: 13,
    padding: '14px 18px',
    borderRadius: 8,
    boxShadow: '0 12px 40px -8px rgba(0,0,0,0.4)',
    display: 'flex', flexDirection: 'column', gap: 8,
    minWidth: 280, maxWidth: 'calc(100% - 32px)',
  },
  brushHintTitle: {
    display: 'flex', alignItems: 'center', gap: 8,
    fontFamily: 'JetBrains Mono, monospace',
    fontSize: 11, letterSpacing: '0.08em',
    color: '#ffd97a',
  },
  brushHintDot: { width: 7, height: 7, borderRadius: '50%', background: '#c84a32' },
  brushHintBody: { fontSize: 13, lineHeight: 1.4 },
  brushHintCta: {
    background: '#fafaf5', color: '#1a1a1a',
    border: 'none', borderRadius: 6,
    padding: '10px 14px',
    fontFamily: 'Geist, sans-serif', fontSize: 12, fontWeight: 500,
    cursor: 'pointer', minHeight: 40,
    marginTop: 4,
  },
  brushHintDismiss: {
    position: 'absolute', top: 6, right: 8,
    background: 'transparent', color: '#fafaf588',
    border: 'none', cursor: 'pointer',
    fontSize: 16, padding: 4,
    width: 28, height: 28,
  },

  paletteRevealStrip: {
    flexShrink: 0,
    margin: '0 -24px',
    padding: '6px 24px',
    background: 'rgba(255,255,255,0.4)',
    border: 'none', borderTop: '1px solid #1a1a1a14',
    cursor: 'pointer',
    color: '#1a1a1a88',
  },
};

window.AtelierApp = AtelierApp;
