// MOX — root React app. Composes header, main view, composer, history palette,
// side panels (settings, voice stub), and the tweaks panel.
//
// Views:
//   'judge'  — the Q/A prototype (main surface)
//   'inventory' — component inventory
//   'moodboard' — visual direction + delight callout
//
// State lives at this level and is passed down. History persists to localStorage.

(function () {
const { useState, useEffect, useRef, useCallback, useMemo } = React;
const rh = React.createElement;
// Alias kept short for dense JSX-like composition below.
const h = rh;

// ── Helpers ─────────────────────────────────────────────────
function useLocalStorage(key, initial) {
  const [val, setVal] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : initial;
    } catch {
      return initial;
    }
  });
  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
  }, [key, val]);
  return [val, setVal];
}

function timeAgo(ts) {
  const m = Math.floor((Date.now() - ts) / 60000);
  if (m < 1) return 'just now';
  if (m < 60) return `${m}m ago`;
  const hr = Math.floor(m / 60);
  if (hr < 24) return `${hr}h ago`;
  const d = Math.floor(hr / 24);
  return `${d}d ago`;
}

// ── Gold temperature presets (Tweaks) ───────────────────────
const GOLD_PRESETS = {
  warm: { // default per user
    '--gold-hi': '#f2cd7a',
    '--gold': '#e6bd6a',
    '--gold-mid': '#c8962b',
    '--gold-deep': '#8a6420',
  },
  midas: {
    '--gold-hi': '#ffd782',
    '--gold': '#d4a84b',
    '--gold-mid': '#a0782a',
    '--gold-deep': '#5c4315',
  },
  patina: {
    '--gold-hi': '#e0c07a',
    '--gold': '#b89a52',
    '--gold-mid': '#8a7330',
    '--gold-deep': '#4a3a18',
  },
  ruby_edge: {
    '--gold-hi': '#f2a06a',
    '--gold': '#e68a5a',
    '--gold-mid': '#a65a34',
    '--gold-deep': '#6a3820',
  },
};

// ────────────────────────────────────────────────────────────
// Header
// ────────────────────────────────────────────────────────────
function Header({ view, setView, status, onOpenSettings, onOpenHistory, historyCount, onReset }) {
  const I = window.Icons;
  return h('header', { className: 'hdr' },
    h('button', {
      className: 'brand',
      type: 'button',
      onClick: onReset,
      title: 'Back to start',
      'aria-label': 'Back to start',
      style: { background: 'transparent', border: 0, cursor: 'pointer', padding: 0 },
    },
      h('div', { className: 'brand-mark' }, h(window.MoxMark, { size: 36 })),
    ),
    h('div', { className: 'hdr-right' },
      h('button', {
        className: 'icon-btn hdr-history',
        onClick: onOpenHistory,
        title: 'History',
        'aria-label': 'Open history',
      },
        h(I.History, null),
        historyCount > 0 && h('span', { className: 'hdr-badge' }, historyCount),
      ),
      h('button', {
        className: 'icon-btn',
        onClick: onOpenSettings,
        title: 'Settings',
        'aria-label': 'Open settings',
      },
        h(I.Settings, null)),
    )
  );
}

function StatusPill({ status }) {
  const cls = status.state === 'error' ? 'is-err' : status.state === 'warn' ? 'is-warn' : '';
  return h('button', { className: `status ${cls}`, title: status.detail || '' },
    h('span', { className: 'status-dot' }),
    h('span', null, status.label),
    h('span', { className: 'status-sep' }, '·'),
    h('span', { className: 'status-model' }, status.model),
  );
}

// ────────────────────────────────────────────────────────────
// Empty state — centered prompt at rest
// ────────────────────────────────────────────────────────────
// Fluid question sizing — short questions get dramatic, long ones shrink to
// fit. The grid row holding the question auto-grows with content, so the gem
// floats down when the question needs more space. Values tuned for desktop;
// CSS scales down on mobile via a media query if needed.
function pickQuestionFontSize(text) {
  const L = (text || '').length;
  if (L < 25) return 44;
  if (L < 40) return 36;
  if (L < 60) return 28;
  if (L < 90) return 22;
  return 19;
}

function EmptyHero({ onPick, thinking = false, thinkingFor = '' }) {
  // Desktop: cap at 360 so the gem is a centerpiece, not a screen-filler.
  // Mobile: 72% of the smaller viewport dimension (more dramatic presence).
  const gemSize = typeof window !== 'undefined'
    ? (window.innerWidth < 700
        ? Math.min(420, Math.max(300, Math.floor(Math.min(window.innerWidth, window.innerHeight) * 0.72)))
        : 320)
    : 320;
  // During thinking, render gem on the left and a column with question +
  // stages on the right. Wrap the right-side content so flex can treat them
  // as one block, centered next to the gem in the viewport.
  if (thinking) {
    return h('div', { className: 'hero is-thinking' },
      h('div', { className: 'hero-mark' }, h(window.MoxMark, { size: gemSize })),
      h('div', { className: 'hero-thinking-col' },
        thinkingFor
          ? h('div', {
              className: 'hero-thinking-q',
              style: { fontSize: pickQuestionFontSize(thinkingFor) + 'px' },
            }, thinkingFor)
          : null,
        h(Thinking, { variant: 'stages-only' }),
      ),
    );
  }

  // Idle state: gem centered, boot-word below (opacity 0 in done).
  return h('div', { className: 'hero' },
    h('div', { className: 'hero-mark' }, h(window.MoxMark, { size: gemSize })),
    h('div', { className: 'hero-boot-word' }, 'ONYX'),
  );
}

// ────────────────────────────────────────────────────────────
// Thinking stages — progressive disclosure (main delight)
// ────────────────────────────────────────────────────────────
const THINKING_STAGES = [
  { label: 'Expanding query',    ms: 700 },
  { label: 'Retrieving rules',   ms: 1600 },
  { label: 'Consulting glossary', ms: 900 },
  { label: 'Drafting answer',    ms: 1800 },
];

function Thinking({ variant = 'stencil' }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const t0 = Date.now();
    const id = setInterval(() => setTick(Date.now() - t0), 60);
    return () => clearInterval(id);
  }, []);

  const offsets = useMemo(() => {
    const o = [];
    let acc = 0;
    for (const s of THINKING_STAGES) { o.push(acc); acc += s.ms; }
    return o;
  }, []);

  function stageStatus(i) {
    const end = offsets[i] + THINKING_STAGES[i].ms;
    if (tick >= end) return 'done';
    if (tick >= offsets[i]) return 'active';
    return 'pending';
  }

  if (variant === 'dots') {
    return h('div', { className: 'thinking' },
      h('div', { className: 'thinking-dots' },
        h('span'), h('span'), h('span')
      ),
      h('div', { className: 'stages', style: { textAlign: 'center' } },
        h('div', { style: { color: 'var(--ink-3)', fontStyle: 'italic', fontFamily: 'var(--font-serif)' } },
          'Consulting the rulebook…')
      )
    );
  }

  // stages-only variant: just the 4 progress rows, no stencil / no wrapper.
  // Used under the gem during thinking (EmptyHero) for perceived-progress.
  if (variant === 'stages-only') {
    return h('div', { className: 'stages hero-stages' },
      THINKING_STAGES.map((s, i) => {
        const st = stageStatus(i);
        const ms = st === 'done' ? s.ms : st === 'active' ? Math.min(tick - offsets[i], s.ms) : 0;
        return h('div', { key: i, className: `stage is-${st}` },
          h('span', { className: 'stage-indicator' }),
          h('span', null, s.label),
          h('span', { className: 'stage-ms' }, st === 'pending' ? '' : `${ms}ms`)
        );
      })
    );
  }

  return h('div', { className: 'thinking' },
    h('div', { className: 'stencil' },
      h('div', { className: 'stencil-glow' }),
      h('div', { className: 'stencil-text' }, 'onyx'),
    ),
    h('div', { className: 'stages' },
      THINKING_STAGES.map((s, i) => {
        const st = stageStatus(i);
        const ms = st === 'done' ? s.ms : st === 'active' ? Math.min(tick - offsets[i], s.ms) : 0;
        return h('div', { key: i, className: `stage is-${st}` },
          h('span', { className: 'stage-indicator' }),
          h('span', null, s.label),
          h('span', { className: 'stage-ms' }, st === 'pending' ? '' : `${ms}ms`)
        );
      })
    )
  );
}

// ────────────────────────────────────────────────────────────
// Answer card
// ────────────────────────────────────────────────────────────
function AnswerCard({ entry, answer, onAsk }) {
  const I = window.Icons;
  const [copied, setCopied] = useState(false);
  const [saved, setSaved] = useState(false);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(
        `Q: ${entry.question}\n\n${answer.verdict}\n\n${answer.body}`
      );
      setCopied(true);
      setTimeout(() => setCopied(false), 1400);
    } catch {}
  };

  return h('article', { className: 'answer-wrap' },
    h('header', null,
      h('h2', { className: 'answer-question' }, entry.question),
      h('div', { className: 'answer-meta', style: { marginTop: '8px' } },
        h('span', null, timeAgo(entry.ts)),
        h('span', { className: 'meta-dot' }, '·'),
        h('span', null, h('span', { className: 'meta-val' }, answer.tokens.retrieved), ' rules retrieved'),
        h('span', { className: 'meta-dot' }, '·'),
        h('span', null, h('span', { className: 'meta-val' }, answer.tokens.rules), ' cited'),
        answer.tokens.cards > 0 && [
          h('span', { key: 'd', className: 'meta-dot' }, '·'),
          h('span', { key: 'c' }, h('span', { className: 'meta-val' }, answer.tokens.cards), ' card', answer.tokens.cards > 1 ? 's' : '')
        ],
      )
    ),
    h('div', { className: 'verdict' },
      h(window.MoxMarkdown, null, answer.verdict)
    ),
    h('div', { className: 'answer-body' },
      h(window.MoxMarkdown, null, answer.body)
    ),
    (answer.related && answer.related.length > 0) && h('div', { className: 'related' },
      h('div', { className: 'related-title' }, 'Related'),
      h('div', { className: 'related-list' },
        answer.related.map((r, idx) =>
          h('button', {
            key: idx,
            className: 'related-item',
            onClick: () => onAsk && onAsk(r.question),
          },
            h('div', { className: 'related-framing' }, r.framing),
            h('div', { className: 'related-question' }, r.question)
          )
        )
      )
    ),
    h('div', { className: 'answer-actions' },
      h('button', { className: `action ${copied ? 'is-done' : ''}`, onClick: copy },
        h(copied ? I.Check : I.Copy, null),
        copied ? 'Copied' : 'Copy'
      ),
      h('button', { className: `action ${saved ? 'is-done' : ''}`, onClick: () => setSaved(s => !s) },
        h(I.Bookmark, null),
        saved ? 'Saved' : 'Save'
      ),
    )
  );
}

// ────────────────────────────────────────────────────────────
// Composer (bottom)
// ────────────────────────────────────────────────────────────
function Composer({ value, setValue, onSend, disabled, hasCurrent }) {
  const I = window.Icons;
  const ref = useRef(null);
  const recorderRef = useRef(null);
  const [recording, setRecording] = useState(false);
  const [transcribing, setTranscribing] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 120) + 'px';
  }, [value]);

  // Mode is a persistent toggle — user picks "New Question" or "Follow-up"
  // explicitly, then submits via arrow or Enter. Defaults to 'new' and snaps
  // back to 'new' after each submission (safer default — follow-up is opt-in).
  const [mode, setMode] = useState('new');
  useEffect(() => {
    // If the user somehow is on 'followup' but there's no current answer yet,
    // force back to 'new'.
    if (mode === 'followup' && !hasCurrent) setMode('new');
  }, [hasCurrent, mode]);

  const submit = () => {
    if (disabled || !value.trim()) return;
    const followUp = mode === 'followup';
    onSend({ followUp });
    setMode('new'); // reset to safe default for next input
  };

  const handleKey = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      submit();
    }
  };

  const stopRecorder = () => {
    const rec = recorderRef.current;
    if (rec && rec.state !== 'inactive') rec.stop();
  };

  const startMic = async () => {
    if (recording || transcribing || disabled) return;
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
        ? 'audio/webm;codecs=opus'
        : (MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : '');
      const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
      recorderRef.current = rec;
      const chunks = [];
      rec.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
      rec.onstop = async () => {
        // Release the mic so the browser mic indicator turns off.
        stream.getTracks().forEach(t => t.stop());
        if (chunks.length === 0) { setRecording(false); return; }
        const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });
        setRecording(false);
        setTranscribing(true);
        try {
          const fd = new FormData();
          const ext = (rec.mimeType || '').includes('mp4') ? 'mp4' : 'webm';
          fd.append('audio', blob, `mox.${ext}`);
          const r = await fetch('/api/voice/transcribe', { method: 'POST', body: fd });
          if (!r.ok) throw new Error(`HTTP ${r.status}`);
          const d = await r.json();
          const text = (d.text || '').trim();
          if (text) {
            setValue(text);
            // Auto-send after a short beat so the user can read the transcript.
            setTimeout(() => {
              onSend({ followUp: mode === 'followup', override: text });
              setMode('new');
            }, 200);
          }
        } catch (err) {
          console.error('transcribe failed', err);
        } finally {
          setTranscribing(false);
        }
      };
      rec.start();
      setRecording(true);
    } catch (err) {
      alert('Microphone access denied or unsupported. Check browser permissions.');
      console.error(err);
    }
  };

  const micClass = recording
    ? 'composer-btn is-recording'
    : transcribing ? 'composer-btn is-transcribing' : 'composer-btn';
  const micTitle = recording
    ? 'Stop recording'
    : transcribing ? 'Transcribing…' : 'Push to talk';

  return h('div', { className: 'composer-wrap' },
    h('div', { className: 'composer' },
      h('textarea', {
        ref,
        className: 'composer-input',
        placeholder: recording ? 'Listening…' : (transcribing ? 'Transcribing…' : 'Ask a rules question…'),
        value,
        onChange: (e) => setValue(e.target.value),
        onKeyDown: handleKey,
        rows: 1,
        disabled: disabled || recording || transcribing,
      }),
      h('div', { className: 'composer-actions' },
        h('button', {
          className: micClass,
          title: micTitle,
          onClick: recording ? stopRecorder : startMic,
          disabled: transcribing || disabled,
        },
          h(I.Mic, null)
        ),
        h('button', {
          className: 'composer-btn is-submit',
          title: 'Send',
          onClick: submit,
          disabled: disabled || !value.trim(),
        },
          h(I.ArrowUp, null)
        ),
      )
    ),
    // Mode pills — persistent-state selector. Clicking does NOT submit, it
    // only sets the mode. Submission happens via the arrow button or Enter.
    h('div', { className: 'mode-pills' },
      h('button', {
        className: `mode-pill ${mode === 'new' ? 'is-active' : ''}`,
        onClick: () => setMode('new'),
        title: 'Treat the next question as a fresh one (no prior context)',
      },
        'New Question'
      ),
      h('button', {
        className: `mode-pill ${mode === 'followup' ? 'is-active' : ''}`,
        onClick: () => hasCurrent && setMode('followup'),
        disabled: !hasCurrent,
        title: hasCurrent
          ? 'Treat the next question as a follow-up (carries the last Q/A as context)'
          : 'Ask a question first, then you can switch to follow-up mode',
      },
        'Follow-up'
      ),
    )
  );
}

// ────────────────────────────────────────────────────────────
// History — command-palette style
// ────────────────────────────────────────────────────────────
function HistoryPalette({ history, onClose, onPick, onClear }) {
  const I = window.Icons;
  const [q, setQ] = useState('');
  const [focus, setFocus] = useState(0);
  const inputRef = useRef(null);

  useEffect(() => { inputRef.current?.focus(); }, []);

  const filtered = useMemo(() => {
    if (!q.trim()) return history;
    const needle = q.toLowerCase();
    return history.filter(h => h.question.toLowerCase().includes(needle));
  }, [q, history]);

  useEffect(() => { setFocus(0); }, [q]);

  const handleKey = (e) => {
    if (e.key === 'Escape') return onClose();
    if (e.key === 'ArrowDown') { e.preventDefault(); setFocus(f => Math.min(f + 1, filtered.length - 1)); }
    if (e.key === 'ArrowUp')   { e.preventDefault(); setFocus(f => Math.max(f - 1, 0)); }
    if (e.key === 'Enter')     { e.preventDefault(); if (filtered[focus]) onPick(filtered[focus]); }
  };

  // Group by recency
  const groups = useMemo(() => {
    const now = Date.now();
    const g = { today: [], yesterday: [], earlier: [] };
    for (const item of filtered) {
      const ageH = (now - item.ts) / 3600000;
      if (ageH < 12) g.today.push(item);
      else if (ageH < 36) g.yesterday.push(item);
      else g.earlier.push(item);
    }
    return g;
  }, [filtered]);

  let idx = 0;
  const renderGroup = (title, items) => {
    if (!items.length) return null;
    return h('div', { key: title },
      h('div', { className: 'palette-section' }, title),
      items.map(item => {
        const i = idx++;
        return h('div', {
          key: item.id,
          className: `hist-item ${focus === i ? 'is-focus' : ''}`,
          onMouseEnter: () => setFocus(i),
          onClick: () => onPick(item),
        },
          h('div', { className: 'hist-q' }, item.question),
          h('div', { className: 'hist-meta' },
            h('span', { className: 'hist-badge' }, window.MOX_ANSWERS[item.answerKey]?.tokens?.rules || 0, ' cr'),
            h('span', null, timeAgo(item.ts)),
          )
        );
      })
    );
  };

  return h('div', {
    className: 'palette-overlay',
    onClick: (e) => { if (e.target.classList.contains('palette-overlay')) onClose(); },
    onKeyDown: handleKey,
  },
    h('div', { className: 'palette' },
      h('div', { className: 'palette-search' },
        h(I.Search, null),
        h('input', {
          ref: inputRef,
          placeholder: 'Search past questions…',
          value: q,
          onChange: (e) => setQ(e.target.value),
        }),
        h('span', { className: 'palette-esc' }, 'ESC'),
      ),
      h('div', { className: 'palette-list' },
        filtered.length === 0
          ? h('div', { style: { padding: '32px 16px', textAlign: 'center', color: 'var(--ink-3)', fontFamily: 'var(--font-serif)', fontStyle: 'italic' } },
              q.trim() ? 'No matches.' : 'No history yet.')
          : [renderGroup('Today', groups.today),
             renderGroup('Yesterday', groups.yesterday),
             renderGroup('Earlier', groups.earlier)],
      ),
      h('div', { className: 'palette-footer' },
        h('div', { className: 'shortcut' },
          h('kbd', null, '↑↓'), 'navigate',
          h('kbd', { style: { marginLeft: 10 } }, '↵'), 'open',
        ),
        history.length > 0 && h('button', { className: 'palette-clear', onClick: onClear },
          'Clear history'
        )
      )
    )
  );
}

// ────────────────────────────────────────────────────────────
// Settings panel (stub)
// ────────────────────────────────────────────────────────────
function SettingsPanel({ onClose, settings, setSettings }) {
  const I = window.Icons;
  const set = (k, v) => setSettings(s => ({ ...s, [k]: v }));
  return h(React.Fragment, null,
    h('div', { className: 'panel-overlay', onClick: onClose }),
    h('aside', { className: 'panel' },
      h('div', { className: 'panel-hdr' },
        h('div', { className: 'panel-title' }, 'Settings'),
        h('button', { className: 'icon-btn', onClick: onClose }, h(I.Close, null))
      ),
      h('div', { className: 'panel-body' },
        h('div', { className: 'field' },
          h('div', { className: 'field-label' }, 'Answer model'),
          h('div', { className: 'select' },
            (() => {
              const all = [
                { key: 'qwen-3-6',   label: 'Qwen 3.6 · local', backend: 'local',     id: 'Qwen3.6-35B-A3B-UD-Q4_K_M.gguf', advanced: false },
                { key: 'haiku-4-5',  label: 'Haiku 4.5',      backend: 'anthropic', id: 'claude-haiku-4-5-20251001',        advanced: true },
                { key: 'sonnet-4-6', label: 'Sonnet 4.6',     backend: 'anthropic', id: 'claude-sonnet-4-6',                advanced: true },
                { key: 'opus-4-7',   label: 'Opus 4.7',       backend: 'anthropic', id: 'claude-opus-4-7',                  advanced: true },
              ];
              const visible = settings.showAdvancedModels ? all : all.filter(o => !o.advanced);
              return visible.map(opt =>
                h('button', {
                  key: opt.key,
                  className: `select-opt ${settings.model === opt.key ? 'is-active' : ''}`,
                  onClick: () => setSettings(s => ({ ...s, model: opt.key, backend: opt.backend, modelId: opt.id })),
                }, opt.label)
              );
            })()
          ),
          h('div', { className: 'field-help' },
            settings.showAdvancedModels
              ? 'Qwen 3.6 runs local on the Studio — free, ~15s latency. Haiku for cloud speed (~4s). Sonnet for gnarly layer/stack interactions. Opus for slow research. (Bench: Qwen matches Haiku on correctness.)'
              : 'Qwen 3.6 runs fully local on the Studio — free per query, ~15s latency. Sufficient for 95%+ of questions per internal bench.')
        ),
        h('div', null,
          h('div', { className: 'toggle' },
            h('div', { className: 'toggle-text' },
              h('div', { className: 'toggle-title' }, 'Query expansion'),
              h('div', { className: 'toggle-sub' }, 'Mistral-small rewrites your question to retrieve better rules. Slower by ~800ms.')
            ),
            h('button', {
              className: `toggle-sw ${settings.expansion ? 'is-on' : ''}`,
              onClick: () => set('expansion', !settings.expansion),
            })
          ),
          h('div', { className: 'toggle' },
            h('div', { className: 'toggle-text' },
              h('div', { className: 'toggle-title' }, 'Speak answers aloud'),
              h('div', { className: 'toggle-sub' }, 'Kokoro-82M runs locally on the Studio. Browser TTS as a fallback.')
            ),
            h('button', {
              className: `toggle-sw ${settings.tts ? 'is-on' : ''}`,
              onClick: () => set('tts', !settings.tts),
            })
          ),
          settings.tts && h('div', { className: 'field' },
            h('div', { className: 'field-label' }, 'Voice'),
            h('div', { className: 'select' },
              [
                { key: 'kokoro-am_michael', label: 'Michael · warm male', provider: 'kokoro', voice: 'am_michael' },
                { key: 'kokoro-bm_daniel',  label: 'Daniel · British judge', provider: 'kokoro', voice: 'bm_daniel' },
                { key: 'kokoro-af_bella',   label: 'Bella · warm female', provider: 'kokoro', voice: 'af_bella' },
                { key: 'kokoro-am_adam',    label: 'Adam · authoritative', provider: 'kokoro', voice: 'am_adam' },
                { key: 'browser',           label: 'Browser (offline fallback)', provider: 'browser', voice: '' },
              ].map(opt =>
                h('button', {
                  key: opt.key,
                  className: `select-opt ${settings.voice === opt.key ? 'is-active' : ''}`,
                  onClick: () => setSettings(s => ({ ...s, voice: opt.key, ttsProvider: opt.provider, ttsVoice: opt.voice })),
                }, opt.label)
              )
            ),
            h('div', { className: 'field-help' }, 'Kokoro voices are local + free. ~3-5 seconds of latency to generate.')
          ),
          h('div', { className: 'toggle' },
            h('div', { className: 'toggle-text' },
              h('div', { className: 'toggle-title' }, 'Wake word listening'),
              h('div', { className: 'toggle-sub' }, '"Hey ONYX" (openWakeWord). Mic is local-only; no audio leaves the device.')
            ),
            h('button', {
              className: `toggle-sw ${settings.wake ? 'is-on' : ''}`,
              onClick: () => set('wake', !settings.wake),
            })
          ),
        ),
        h('div', { className: 'field' },
          h('div', { className: 'field-label' }, 'Corpus'),
          h('div', { className: 'kv' }, h('div', { className: 'kv-k' }, 'Rules version'), h('div', { className: 'kv-v' }, 'CR 2026-03-07')),
          h('div', { className: 'kv' }, h('div', { className: 'kv-k' }, 'Card corpus'), h('div', { className: 'kv-v' }, 'MTGJSON 5.2.2-26j')),
          h('div', { className: 'kv' }, h('div', { className: 'kv-k' }, 'Embed model'), h('div', { className: 'kv-v' }, 'bge-m3-local')),
          h('div', { className: 'kv' }, h('div', { className: 'kv-k' }, 'Last index'), h('div', { className: 'kv-v' }, '11h ago'))
        ),
        h('div', { className: 'field' },
          h('div', { className: 'field-label' }, 'Development'),
          h('button', { className: 'action' }, h(I.Sparkle, null), 'Run benchmark (24 Qs)')
        )
      )
    )
  );
}

// ────────────────────────────────────────────────────────────
// Voice mode panel (stub)
// ────────────────────────────────────────────────────────────
function VoicePanel({ onClose }) {
  const I = window.Icons;
  const [state, setState] = useState('listening'); // listening | transcribing | answering | speaking
  const states = ['listening', 'transcribing', 'answering', 'speaking'];
  return h(React.Fragment, null,
    h('div', { className: 'panel-overlay', onClick: onClose }),
    h('aside', { className: 'panel' },
      h('div', { className: 'panel-hdr' },
        h('div', { className: 'panel-title' }, 'Voice — preview'),
        h('button', { className: 'icon-btn', onClick: onClose }, h(I.Close, null))
      ),
      h('div', { className: 'voice-stage' },
        h('div', { className: 'voice-halo' },
          h('div', { className: 'voice-core' })
        ),
        h('div', { className: 'voice-state' },
          state === 'listening' && 'Listening for "Hey ONYX"',
          state === 'transcribing' && 'Transcribing…',
          state === 'answering' && 'Thinking…',
          state === 'speaking' && 'Answering',
        ),
        h('div', { className: 'voice-transcript' },
          state === 'listening' && 'The engraved panel on the case glows gold when active.',
          state === 'transcribing' && '"If I have a Thassa\'s Oracle on the battlefield with an emp—"',
          state === 'answering' && 'Pulling rules · CR 603.2, CR 700.4',
          state === 'speaking' && h('div', { className: 'voice-wave' },
            [...Array(12)].map((_, i) =>
              h('span', { key: i, style: { animationDelay: `${i * 0.06}s` } })
            )
          ),
        ),
        h('div', { className: 'select', style: { width: '100%', maxWidth: 320, marginTop: 24 } },
          states.map(s =>
            h('button', {
              key: s,
              className: `select-opt ${state === s ? 'is-active' : ''}`,
              onClick: () => setState(s),
            }, s)
          )
        ),
        h('div', { style: { fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', letterSpacing: '0.04em', maxWidth: 320, textAlign: 'center', lineHeight: 1.5 } },
          'Toggle through states to preview. The TTS amplitude will drive the glow intensity on the physical ONYX panel.')
        ,
      )
    )
  );
}

// ────────────────────────────────────────────────────────────
// Tweaks panel
// ────────────────────────────────────────────────────────────
function TweaksPanel({ tweaks, setTweaks, visible, onClose }) {
  if (!visible) return null;
  const set = (k, v) => setTweaks(t => ({ ...t, [k]: v }));

  return h('aside', { className: 'tweaks' },
    h('div', { className: 'tweaks-hdr' },
      'Tweaks',
      h('button', { className: 'icon-btn', style: { width: 20, height: 20 }, onClick: onClose },
        h(window.Icons.Close, { size: 12 })
      )
    ),
    h('div', { className: 'tweaks-body' },
      h('div', { className: 'tweaks-row' },
        h('div', { className: 'tweaks-label' }, 'Thinking indicator'),
        h('div', { className: 'select' },
          h('button', { className: `select-opt ${tweaks.thinking === 'stencil' ? 'is-active' : ''}`, onClick: () => set('thinking', 'stencil') }, 'Stencil'),
          h('button', { className: `select-opt ${tweaks.thinking === 'dots' ? 'is-active' : ''}`, onClick: () => set('thinking', 'dots') }, 'Dots'),
        )
      ),
      h('div', { className: 'tweaks-row' },
        h('div', { className: 'tweaks-label' }, 'Citation style'),
        h('div', { className: 'select' },
          h('button', { className: `select-opt ${tweaks.citeStyle === 'underline' ? 'is-active' : ''}`, onClick: () => set('citeStyle', 'underline') }, 'Serif'),
          h('button', { className: `select-opt ${tweaks.citeStyle === 'pill' ? 'is-active' : ''}`, onClick: () => set('citeStyle', 'pill') }, 'Pill'),
          h('button', { className: `select-opt ${tweaks.citeStyle === 'super' ? 'is-active' : ''}`, onClick: () => set('citeStyle', 'super') }, 'Super'),
        )
      ),
      h('div', { className: 'tweaks-row' },
        h('div', { className: 'tweaks-label' }, 'Gold temperature'),
        h('div', { className: 'swatches' },
          Object.keys(GOLD_PRESETS).map(k =>
            h('button', {
              key: k,
              className: `swatch ${tweaks.gold === k ? 'is-active' : ''}`,
              style: {
                background: `radial-gradient(circle at 30% 30%, ${GOLD_PRESETS[k]['--gold-hi']}, ${GOLD_PRESETS[k]['--gold-deep']})`,
                color: GOLD_PRESETS[k]['--gold'],
              },
              onClick: () => set('gold', k),
              title: k,
            })
          )
        )
      ),
      h('div', { className: 'tweaks-row' },
        h('div', { className: 'tweaks-label' }, 'Server status'),
        h('div', { className: 'select' },
          h('button', { className: `select-opt ${tweaks.status === 'ready' ? 'is-active' : ''}`, onClick: () => set('status', 'ready') }, 'Ready'),
          h('button', { className: `select-opt ${tweaks.status === 'warn' ? 'is-active' : ''}`, onClick: () => set('status', 'warn') }, 'No key'),
          h('button', { className: `select-opt ${tweaks.status === 'error' ? 'is-active' : ''}`, onClick: () => set('status', 'error') }, 'Down'),
        )
      ),
    )
  );
}

// ────────────────────────────────────────────────────────────
// Inventory & Moodboard views
// ────────────────────────────────────────────────────────────
function InventoryView() {
  const I = window.Icons;

  const cards = [
    {
      name: 'Answer card',
      id: 'answer',
      desc: 'The hero surface. Question in serif italic, a sticky-visible 2-sentence verdict set against a faint gold gradient, then the markdown body.',
      demo: h('div', null,
        h('div', { style: { fontFamily: 'var(--font-serif)', fontStyle: 'italic', color: 'var(--ink-0)', fontSize: 14 } },
          '“Does Thassa\'s Oracle win on an empty library?”'),
        h('div', { style: { borderLeft: '2px solid var(--gold)', background: 'linear-gradient(to right, rgba(230,189,106,0.06), transparent)', padding: '6px 10px', marginTop: 8, fontFamily: 'var(--font-serif)', fontSize: 13 } },
          h('strong', { style: { color: 'var(--gold-hi)', fontFamily: 'var(--font-ui)', fontSize: 12 } }, 'Yes'),
          ' — on resolution of its ETB trigger.'
        )
      ),
    },
    {
      name: 'Citation',
      id: 'cite',
      desc: 'Inline CR reference. Scholarly underlined serif by default; hoverable to reveal rule text. Tweak to pill or superscript.',
      demo: h('div', { style: { fontFamily: 'var(--font-serif)', fontSize: 14, color: 'var(--ink-1)' } },
        'The trigger goes on the stack ',
        h('span', { className: 'cite' }, 'CR 603.2'),
        ', then the library check resolves ',
        h('span', { className: 'cite' }, 'CR 700.4'),
        '.'
      ),
    },
    {
      name: 'Oracle block',
      id: 'oracle',
      desc: 'Blockquote rendering for card text or rule excerpts. Labeled "ORACLE" or "RULE" in tiny caps. Original design — not a recreation of any printed card frame.',
      demo: h('blockquote', { className: 'md-quote md-quote-oracle', style: { margin: 0, fontSize: 13 } },
        h('span', { className: 'md-quote-label' }, 'ORACLE'),
        h('span', { className: 'md-quote-text' }, 'When Thassa\'s Oracle enters, look at the top X cards of your library, where X is your devotion to blue…')
      ),
    },
    {
      name: 'Verdict',
      id: 'verdict',
      desc: 'First two sentences — the answer in a glance. Always visible when the answer is on screen.',
      demo: h('div', { className: 'verdict', style: { fontSize: 14, padding: '10px 14px' } },
        h('strong', null, 'Depends on which Nissa.'),
        ' Only planeswalkers whose +1 creates tokens interact with Doubling Season.'
      ),
    },
    {
      name: 'Thinking indicator',
      id: 'thinking',
      desc: 'The moment of delight. A gold "mox" stencil breathes while stages resolve. Echoes the engraved backlit LED on the physical box.',
      demo: h('div', { style: { display: 'flex', gap: 16, alignItems: 'center' } },
        h('div', { style: { position: 'relative', width: 80, height: 32 } },
          h('div', {
            style: {
              position: 'absolute', inset: 0, border: '1px solid var(--gold-deep)',
              borderRadius: 4, display: 'grid', placeItems: 'center',
              fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: 16,
              color: 'var(--gold)', letterSpacing: '0.18em',
              animation: 'stencil-breathe 2.8s infinite',
            },
          }, 'mox')
        ),
        h('div', { style: { fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--gold)' } },
          '◐ drafting answer · 2.1s'
        )
      ),
    },
    {
      name: 'Status pill',
      id: 'status',
      desc: 'Header corner. Breathing gold dot = ready. Solid amber = missing API key. Static ruby = server down. Hover for detail.',
      demo: h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
        h(StatusPill, { status: { state: 'ok', label: 'Ready', model: 'haiku-4-5' } }),
        h(StatusPill, { status: { state: 'warn', label: 'No key', model: 'haiku-4-5' } }),
        h(StatusPill, { status: { state: 'error', label: 'Server down', model: 'haiku-4-5' } })
      ),
    },
    {
      name: 'History item',
      id: 'history',
      desc: 'Row inside the command-palette history. Shows question, CR count badge, and relative time.',
      demo: h('div', { className: 'hist-item', style: { background: 'var(--bg-3)' } },
        h('div', { className: 'hist-q' }, 'Does a creature with flash have summoning sickness?'),
        h('div', { className: 'hist-meta' },
          h('span', { className: 'hist-badge' }, '1 cr'),
          h('span', null, '4m ago'),
        )
      ),
    },
    {
      name: 'Action row',
      id: 'actions',
      desc: 'Beneath each answer. Copy, save, follow-up. Actions flip gold on success.',
      demo: h('div', { style: { display: 'flex', gap: 8 } },
        h('button', { className: 'action is-done' }, h(I.Check, null), 'Copied'),
        h('button', { className: 'action' }, h(I.Bookmark, null), 'Save'),
        h('button', { className: 'action' }, h(I.Plus, null), 'Ask follow-up'),
      ),
    },
    {
      name: 'Error banner',
      id: 'error',
      desc: 'Graceful failure. Left ruby bar + icon. Tells the user what went wrong and what to do.',
      demo: h('div', { className: 'errbar', style: { margin: 0, fontSize: 12 } },
        h('span', { className: 'errbar-icon' }, h(I.Alert, null)),
        h('div', { className: 'errbar-body' },
          h('div', { className: 'errbar-title' }, 'ANTHROPIC_API_KEY not set'),
          h('div', { className: 'errbar-sub' }, 'Add it to your ~/.mox.env and restart the server.')
        )
      ),
    },
  ];

  return h('div', { className: 'scroll' },
    h('div', { className: 'inv' },
      h('p', { className: 'inv-intro' },
        'A small inventory of the reusable pieces — everything is dark-mode-only and keyed to one warm-gold accent. Components are intentionally plain; weight lives in the typography and the one moment of light.'),
      h('div', { className: 'inv-grid' },
        cards.map(c =>
          h('div', { key: c.id, className: 'inv-card' },
            h('div', { className: 'inv-card-hdr' },
              h('div', { className: 'inv-card-name' }, c.name),
              h('div', { className: 'inv-card-id' }, c.id)
            ),
            h('div', { className: 'inv-card-demo' }, c.demo),
            h('div', { className: 'inv-card-desc' }, c.desc)
          )
        )
      )
    )
  );
}

function MoodboardView() {
  return h('div', { className: 'scroll' },
    h('div', { className: 'mood' },

      // Big "mox" tile — the whole vibe in one block
      h('div', { className: 'mood-tile mood-span-5 mood-big-gold' }, 'mox'),

      // Type specimen
      h('div', { className: 'mood-tile mood-span-7 mood-type-sample' },
        h('span', { className: 'mood-tile-label' }, 'Typography'),
        h('h1', null,
          'A judge at the table,',
          h('br'),
          h('em', null, 'not an assistant.')
        ),
        h('p', null,
          'The answer body is set in a warm serif at 16px. Rule citations pop as ',
          h('span', { className: 'cite' }, 'CR 601.2a'),
          ' — scholarly and underlined. UI chrome stays in the system sans; monospace handles the small, technical corners (CR numbers, status, timings).'
        )
      ),

      // Palette
      h('div', { className: 'mood-tile mood-span-12' },
        h('span', { className: 'mood-tile-label' }, 'Palette'),
        h('div', { className: 'mood-palette', style: { marginTop: 28 } },
          ['#0a0c10', '#0f1115', '#151820', '#1c2029', '#242934', '#3a372f'].map(c =>
            h('div', { key: c, style: { background: c } }, c)
          )
        ),
        h('div', { className: 'mood-palette', style: { marginTop: 2 } },
          ['#f2cd7a', '#e6bd6a', '#c8962b', '#8a6420', '#5a4417', '#c8443b'].map(c =>
            h('div', { key: c, style: { background: c } }, c)
          )
        ),
      ),

      // Citation showcase
      h('div', { className: 'mood-tile mood-span-4' },
        h('span', { className: 'mood-tile-label' }, 'Citations'),
        h('div', { style: { marginTop: 32, display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-serif)', fontSize: 15 } },
          h('div', null, 'Serif: see ', h('span', { className: 'cite' }, 'CR 603.2')),
          h('div', { 'data-cite-style-demo': true },
            h('style', null, '[data-cite-style-demo] .cite { font-family: var(--font-mono); font-style: normal; font-size: 11px; padding: 1px 7px; background: var(--bg-3); color: var(--gold); border: 1px solid var(--line); text-decoration: none; border-radius: 999px; }'),
            'Pill: see ', h('span', { className: 'cite' }, 'CR 603.2')
          ),
          h('div', null, 'Super: see text', h('sup', { style: { color: 'var(--gold-mid)', fontFamily: 'var(--font-mono)', fontSize: 10, marginLeft: 2 } }, '[603.2]'))
        )
      ),

      // Oracle block
      h('div', { className: 'mood-tile mood-span-4' },
        h('span', { className: 'mood-tile-label' }, 'Oracle block'),
        h('blockquote', { className: 'md-quote md-quote-oracle', style: { marginTop: 32, fontSize: 13 } },
          h('span', { className: 'md-quote-label' }, 'ORACLE'),
          h('span', { className: 'md-quote-text' }, 'When Thassa\'s Oracle enters, look at the top X cards of your library.')
        )
      ),

      // Motion note
      h('div', { className: 'mood-tile mood-span-4' },
        h('span', { className: 'mood-tile-label' }, 'Motion'),
        h('div', { style: { marginTop: 36, display: 'flex', flexDirection: 'column', gap: 14, fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.5 } },
          h('div', null, h('strong', { style: { color: 'var(--gold)', fontFamily: 'var(--font-ui)' } }, 'Breathing'), ' — 2.8s cycle, opacity + glow, matches the physical LED.'),
          h('div', null, h('strong', { style: { color: 'var(--gold)', fontFamily: 'var(--font-ui)' } }, 'Progress'), ' — stages reveal left-aligned, monospace. Feels like a log, not a spinner.'),
          h('div', null, h('strong', { style: { color: 'var(--gold)', fontFamily: 'var(--font-ui)' } }, 'Arrival'), ' — the answer fades in after the last stage finishes; no layout shift.'),
        )
      ),

      // Delight callout
      h('div', { className: 'delight-callout' },
        h('div', null,
          h('div', { style: { fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', color: 'var(--gold-mid)', textTransform: 'uppercase', marginBottom: 8 } },
            'One moment of delight'),
          h('h2', null, 'The breathing stencil.'),
          h('p', null,
            'It happens on every query — 20 times a game night, 600+ times a month. Anything this frequent either becomes dead chrome or becomes a signature. We make it a signature: a small gold word-mark, italic, engraved into its frame, pulsing in time with a slow breath.'),
          h('p', null,
            'It ties the digital UI to the eventual physical artifact — the acrylic box with the backlit engraved ', h('strong', null, 'mox'), ' stencil on the lid. When voice-mode ships, the TTS amplitude drives the same glow on both screen and case. One gesture, two surfaces.'),
        ),
        h('div', { style: { display: 'grid', placeItems: 'center' } },
          h('div', { style: { position: 'relative', width: 260, height: 140, display: 'grid', placeItems: 'center' } },
            h('div', { style: { position: 'absolute', inset: 0, border: '1px solid var(--gold-deep)', borderRadius: 6, background: 'linear-gradient(to bottom, rgba(255,255,255,0.02), rgba(0,0,0,0.2))', boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04), inset 0 -1px 0 rgba(0,0,0,0.4)' } }),
            h('div', { style: { position: 'absolute', inset: -40, background: 'radial-gradient(ellipse at center, rgba(230,189,106,0.18), transparent 60%)', animation: 'stencil-glow-pulse 2.8s infinite' } }),
            h('div', {
              className: 'stencil-text',
              style: { fontSize: 72, letterSpacing: '0.18em' }
            }, 'mox')
          )
        )
      ),

    )
  );
}

// ────────────────────────────────────────────────────────────
// Judge view — the core prototype
// ────────────────────────────────────────────────────────────
function JudgeView({
  history, setHistory, current, setCurrent, status,
  paletteOpen, setPaletteOpen, tweaks, settings,
}) {
  const [composer, setComposer] = useState('');
  const [thinking, setThinking] = useState(false);
  const [thinkingFor, setThinkingFor] = useState('');

  const ask = useCallback(async (questionOrOpts, maybeOpts) => {
    // Support three call shapes:
    //   ask({followUp: true})                 — from Composer buttons
    //   ask("question text")                   — from EmptyHero chip
    //   ask("question text", {followUp: true}) — from Related cards
    const opts = typeof questionOrOpts === 'object' && questionOrOpts !== null
      ? questionOrOpts
      : (maybeOpts || {});
    const override = typeof questionOrOpts === 'string'
      ? questionOrOpts
      : opts.override;
    const q = (override ?? composer).trim();
    if (!q) return;
    const followUp = !!opts.followUp;
    setThinking(true);
    setThinkingFor(q);
    // Keep `current` visible until the new answer arrives when threading so the
    // user doesn't lose their place. Clear it on a fresh ask.
    if (!followUp) setCurrent(null);
    setComposer('');
    // Build history payload: the last 2 completed turns from `history` state.
    // Chronological (oldest first) as the server expects.
    const historyPayload = followUp
      ? history.slice(0, 2).reverse().filter(e => e.answer && e.answer.verdict)
          .map(e => ({ question: e.question, answer: (e.answer.verdict + '\n\n' + (e.answer.body || '')).trim() }))
      : [];
    try {
      const resp = await fetch('/api/ask', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          question: q,
          backend: settings?.backend,
          model: settings?.modelId,
          history: historyPayload,
        }),
      });
      if (!resp.ok) {
        const err = await resp.json().catch(() => ({}));
        throw new Error(err.detail || `HTTP ${resp.status}`);
      }
      const data = await resp.json();
      const split = window.splitAnswer(data.answer);
      const { verdict, body, related } = split;
      const entry = {
        id: 'q_' + Math.random().toString(36).slice(2, 9),
        ts: Date.now(),
        question: q,
        answer: {
          verdict, body, related,
          tokens: { retrieved: 30, rules: 30, cards: 0 },
          modelUsed: data.model,
          backendUsed: data.backend,
        },
      };
      setHistory(prev => [entry, ...prev]);
      setCurrent(entry);
      // Speak the answer if TTS is enabled. Kokoro (server-side, local) for
      // quality; browser speechSynthesis as zero-setup offline fallback.
      if (settings?.tts) {
        const spoken = window.moxPlainText(verdict + '. ' + body);
        const provider = settings.ttsProvider || 'kokoro';
        const speakViaBrowser = () => {
          if (typeof window === 'undefined' || !window.speechSynthesis) return;
          try {
            window.speechSynthesis.cancel();
            const utter = new SpeechSynthesisUtterance(spoken);
            utter.rate = 1.0; utter.pitch = 1.0;
            window.speechSynthesis.speak(utter);
          } catch (e) { console.error('browser TTS failed', e); }
        };
        if (provider === 'browser') {
          speakViaBrowser();
        } else {
          // Kokoro via /api/voice/speak. Falls back to browser if it errors.
          (async () => {
            try {
              const r = await fetch('/api/voice/speak', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ text: spoken, voice: settings.ttsVoice || 'am_michael' }),
              });
              if (!r.ok) throw new Error(`HTTP ${r.status}`);
              const blob = await r.blob();
              const url = URL.createObjectURL(blob);
              const audio = new Audio(url);
              audio.onended = () => URL.revokeObjectURL(url);
              audio.play().catch(err => {
                console.error('audio play blocked or failed', err);
                speakViaBrowser();
              });
            } catch (err) {
              console.error('Kokoro TTS failed, falling back to browser:', err);
              speakViaBrowser();
            }
          })();
        }
      }
    } catch (e) {
      const entry = {
        id: 'q_' + Math.random().toString(36).slice(2, 9),
        ts: Date.now(),
        question: q,
        answer: {
          verdict: `**Error** — ${e.message}`,
          body: 'The server reported an error. Check that ANTHROPIC_API_KEY is set in `.env` and the server is reachable at `/api/ask`.',
          tokens: { retrieved: 0, rules: 0, cards: 0 },
        },
      };
      setHistory(prev => [entry, ...prev]);
      setCurrent(entry);
    } finally {
      setThinking(false);
      setThinkingFor('');
    }
  }, [composer, setHistory, setCurrent, settings, history]);

  // If we crash during thinking, clean up
  useEffect(() => () => setThinking(false), []);

  const openFromHistory = (item) => {
    setCurrent(item);
    setPaletteOpen(false);
  };

  // Prefer the inline answer attached by the real /api/ask path. Fall back to
  // the sample-answer table for the seeded demo questions in data.js.
  const answer = current
    ? (current.answer || window.MOX_ANSWERS[current.answerKey] || null)
    : null;

  return h('div', { className: 'main' },
    h('div', { className: 'scroll' },

      // Error banner only when status is error
      status.state === 'error' && h('div', { className: 'errbar' },
        h('span', { className: 'errbar-icon' }, h(window.Icons.Alert, null)),
        h('div', { className: 'errbar-body' },
          h('div', { className: 'errbar-title' }, 'Cannot reach ONYX server'),
          h('div', { className: 'errbar-sub' }, 'Is `python server.py` running on :8080? Answers will not work until the server is reachable.')
        )
      ),

      thinking
        ? h(EmptyHero, { onPick: (q) => ask(q), thinking: true, thinkingFor })
        : current && answer
          ? h(AnswerCard, {
              entry: current, answer,
              onAsk: (q) => ask(q, { followUp: true }),
            })
          : h(EmptyHero, { onPick: (q) => ask(q) })
    ),

    h(Composer, {
      value: composer,
      setValue: setComposer,
      onSend: (opts) => ask(opts),
      disabled: thinking,
      hasCurrent: !!current,
    })
  );
}

// ────────────────────────────────────────────────────────────
// Card popover — image + Oracle text + Scryfall link
// ────────────────────────────────────────────────────────────
function CardPopover({ cardName, onClose }) {
  const [data, setData] = useState({ loading: true });
  useEffect(() => {
    let cancelled = false;
    fetch(`/api/card/${encodeURIComponent(cardName)}`)
      .then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.detail || `HTTP ${r.status}`); }))
      .then(d => !cancelled && setData({ loading: false, ...d }))
      .catch(e => !cancelled && setData({ loading: false, error: e.message }));
    return () => { cancelled = true; };
  }, [cardName]);
  useEffect(() => {
    const onKey = e => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  return h('div', { className: 'rule-backdrop', onClick: onClose, role: 'dialog', 'aria-modal': 'true' },
    h('div', { className: 'card-pop', onClick: e => e.stopPropagation() },
      h('header', { className: 'rule-pop-head' },
        h('span', { className: 'rule-pop-num' }, data.name || cardName),
        h('button', { className: 'rule-pop-close', onClick: onClose, title: 'Close (Esc)', 'aria-label': 'Close' }, '×')
      ),
      h('div', { className: 'card-pop-body' },
        data.loading && h('div', { className: 'rule-pop-loading' }, 'Looking up card…'),
        data.error && h('div', { className: 'rule-pop-err' }, data.error),
        data.kind === 'candidates' && h('div', null,
          h('div', { className: 'rule-pop-hint' }, `No exact match for "${cardName}". Did you mean:`),
          h('ul', { className: 'card-pop-candidates' },
            data.matches.map(c =>
              h('li', { key: c.name },
                h('button', {
                  className: 'card-pop-cand',
                  onClick: () => window.MoxShowCard(c.name),
                }, c.name)
              )
            )
          )
        ),
        data.kind === 'exact' && h(React.Fragment, null,
          data.image && h('img', {
            src: data.image,
            alt: data.name,
            className: 'card-pop-img',
            loading: 'lazy',
          }),
          (data.faces && data.faces.length > 1) && h('div', { className: 'card-pop-faces' },
            data.faces.map(f =>
              h('div', { key: f.name, className: 'card-pop-face' },
                f.image && h('img', { src: f.image, alt: f.name, className: 'card-pop-img' }),
                h('div', { className: 'card-pop-face-label' }, f.name)
              )
            )
          ),
          h('div', { className: 'card-pop-meta' },
            data.type_line && h('div', { className: 'card-pop-type' }, data.type_line),
            data.mana_cost && h('div', { className: 'card-pop-mana' }, data.mana_cost),
          ),
          data.oracle_text && h('pre', { className: 'card-pop-oracle' }, data.oracle_text),
          data.scryfall_uri && h('a', {
            href: data.scryfall_uri,
            target: '_blank',
            rel: 'noopener noreferrer',
            className: 'card-pop-link',
          }, 'Open on Scryfall →'),
        )
      )
    )
  );
}

// ────────────────────────────────────────────────────────────
// Rule popover — opened from clicked citations
// ────────────────────────────────────────────────────────────
function RulePopover({ ruleNumber, onClose }) {
  const [data, setData] = useState({ loading: true });
  useEffect(() => {
    let cancelled = false;
    fetch(`/api/rule/${encodeURIComponent(ruleNumber)}`)
      .then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.detail || `HTTP ${r.status}`); }))
      .then(d => !cancelled && setData({ loading: false, ...d }))
      .catch(e => !cancelled && setData({ loading: false, error: e.message }));
    return () => { cancelled = true; };
  }, [ruleNumber]);
  useEffect(() => {
    const onKey = e => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  return h('div', { className: 'rule-backdrop', onClick: onClose, role: 'dialog', 'aria-modal': 'true' },
    h('div', { className: 'rule-pop', onClick: e => e.stopPropagation() },
      h('header', { className: 'rule-pop-head' },
        h('span', { className: 'rule-pop-num' }, 'CR ' + ruleNumber),
        h('button', { className: 'rule-pop-close', onClick: onClose, title: 'Close (Esc)', 'aria-label': 'Close' }, '×')
      ),
      h('div', { className: 'rule-pop-body' },
        data.loading && h('div', { className: 'rule-pop-loading' }, 'Looking up the Comprehensive Rules…'),
        data.error && h('div', { className: 'rule-pop-err' }, data.error),
        data.text && h('pre', { className: 'rule-pop-text' }, data.text),
        data.kind === 'prefix' && h('div', { className: 'rule-pop-hint' },
          `Matched ${data.matches.length} sub-rules under CR ${ruleNumber}.`),
      )
    )
  );
}

// ────────────────────────────────────────────────────────────
// Root
// ────────────────────────────────────────────────────────────
function App() {
  const [view, setView] = useState('judge');
  const [history, setHistory] = useLocalStorage('mox.history', window.MOX_SEED_HISTORY);
  const [current, setCurrent] = useLocalStorage('mox.current', null);
  // Boot phase drives a single cross-fade: the gem is rendered at its final
  // position from frame 0 and never moves. Surrounding chrome (header, title,
  // subtitle, composer) fades IN while the big "ONYX" boot wordmark fades OUT.
  // Plays once per tab session.
  const [bootPhase, setBootPhase] = useState(() => {
    try { return sessionStorage.getItem('onyx.booted') === '1' ? 'done' : 'boot'; } catch { return 'boot'; }
  });
  useEffect(() => {
    if (bootPhase !== 'boot') return;
    const freeze = new URLSearchParams(window.location.search).get('splash') === 'freeze';
    if (freeze) return;
    const t = setTimeout(() => {
      setBootPhase('done');
      try { sessionStorage.setItem('onyx.booted', '1'); } catch {}
    }, 1400);
    return () => clearTimeout(t);
  }, [bootPhase]);
  const [paletteOpen, setPaletteOpen] = useState(false);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [voiceOpen, setVoiceOpen] = useState(false);
  const [rulePopover, setRulePopover] = useState(null);
  const [cardPopover, setCardPopover] = useState(null);

  // Expose global handlers so markdown.js citation buttons can open popovers.
  useEffect(() => {
    window.MoxShowRule = (num) => setRulePopover(num);
    window.MoxShowCard = (name) => setCardPopover(name);
    return () => {
      delete window.MoxShowRule;
      delete window.MoxShowCard;
    };
  }, []);

  // iOS Safari 100dvh is unreliable (URL bar auto-hide flickers layout, "pull
  // down to snap into place" symptom). Drive the app height from the actual
  // window.innerHeight so every element sizes to the real visible viewport.
  // Also measure the header's actual rendered height so the splash can align
  // its gem to exactly where the hero gem will land (no Y-jump on fade-out).
  useEffect(() => {
    const setH = () => {
      document.documentElement.style.setProperty('--app-h', window.innerHeight + 'px');
      const hdr = document.querySelector('.hdr');
      if (hdr) {
        document.documentElement.style.setProperty('--hdr-h', hdr.getBoundingClientRect().height + 'px');
      }
    };
    setH();
    // Re-measure on next frame in case initial layout changes after fonts/icons render.
    requestAnimationFrame(setH);
    window.addEventListener('resize', setH);
    window.addEventListener('orientationchange', setH);
    return () => {
      window.removeEventListener('resize', setH);
      window.removeEventListener('orientationchange', setH);
    };
  }, []);

  const [settings, setSettings] = useLocalStorage('mox.settings', {
    // Default on first-ever startup: Qwen 3.6 local on the Studio.
    model: 'qwen-3-6',
    backend: 'local',
    modelId: 'Qwen3.6-35B-A3B-UD-Q4_K_M.gguf',
    expansion: true,
    tts: false,
    wake: false,
    // Hide Anthropic cloud models by default — Phase A bench showed Qwen
    // matches or beats Haiku on correctness at zero per-query cost. Advanced
    // users can flip this to surface the cloud options.
    showAdvancedModels: false,
  });

  const [tweaks, setTweaks] = useLocalStorage('mox.tweaks', {
    thinking: 'stencil',
    citeStyle: 'underline',
    gold: 'warm',
    status: 'ready',
  });

  // Edit mode (Tweaks toggle via the toolbar)
  const [editMode, setEditMode] = useState(false);
  useEffect(() => {
    const onMsg = (e) => {
      const d = e.data || {};
      if (d.type === '__activate_edit_mode') setEditMode(true);
      if (d.type === '__deactivate_edit_mode') setEditMode(false);
    };
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  // Apply tweaks to body attributes + root css vars
  useEffect(() => {
    document.body.setAttribute('data-cite-style', tweaks.citeStyle);
    const preset = GOLD_PRESETS[tweaks.gold] || GOLD_PRESETS.warm;
    for (const [k, v] of Object.entries(preset)) {
      document.documentElement.style.setProperty(k, v);
    }
  }, [tweaks]);

  // ⌘K to open history
  useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault();
        setPaletteOpen(o => !o);
      }
      if (e.key === 'Escape') {
        setPaletteOpen(false);
        setSettingsOpen(false);
        setVoiceOpen(false);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  // Real status — polls /api/health every 15s. Tweaks panel can still force a
  // demo state via `tweaks.status` for the inventory/moodboard views.
  const [realHealth, setRealHealth] = useState({ ok: null, detail: 'checking…' });
  useEffect(() => {
    let cancelled = false;
    const poll = async () => {
      try {
        const r = await fetch('/api/health');
        const h = await r.json();
        if (cancelled) return;
        if (!h.ok) {
          setRealHealth({ ok: 'warn', label: 'Loading', detail: 'Corpus still loading', model: h.model });
        } else {
          setRealHealth({ ok: 'ready', label: 'Ready', detail: 'Ready on :8765', model: h.model });
        }
      } catch (_) {
        if (!cancelled) setRealHealth({ ok: 'error', label: 'Server down', detail: 'Cannot reach /api/health', model: '—' });
      }
    };
    poll();
    const id = setInterval(poll, 15000);
    return () => { cancelled = true; clearInterval(id); };
  }, []);

  const status = useMemo(() => {
    // Tweaks panel overrides the real health for demo purposes.
    const base = { model: realHealth.model || settings.model };
    if (tweaks.status === 'error') return { ...base, state: 'error', label: 'Server down', detail: 'Cannot reach /api/health' };
    if (tweaks.status === 'warn')  return { ...base, state: 'warn',  label: 'No key',       detail: 'ANTHROPIC_API_KEY not set' };
    if (realHealth.ok === 'error') return { ...base, state: 'error', label: realHealth.label, detail: realHealth.detail };
    if (realHealth.ok === 'warn')  return { ...base, state: 'warn',  label: realHealth.label, detail: realHealth.detail };
    if (realHealth.ok === null)    return { ...base, state: 'warn',  label: 'Connecting…', detail: 'Waiting on /api/health' };
    return { ...base, state: 'ok', label: 'Ready', detail: realHealth.detail };
  }, [tweaks.status, realHealth, settings.model]);

  return h('div', { className: 'app', 'data-boot': bootPhase },
    h(Header, {
      view, setView, status,
      onOpenSettings: () => setSettingsOpen(true),
      onOpenHistory: () => setPaletteOpen(true),
      historyCount: history.length,
      onReset: () => {
        // Tap logo → complete reset to original screen: clear current answer,
        // close overlays, and scroll the inner container to the top so the
        // gem lands in the same position a fresh page load would show it.
        setCurrent(null);
        setPaletteOpen(false);
        setSettingsOpen(false);
        setVoiceOpen(false);
        setRulePopover(null);
        setCardPopover(null);
        setView('judge');
        requestAnimationFrame(() => {
          const s = document.querySelector('.scroll');
          if (s) s.scrollTop = 0;
        });
      },
    }),

    view === 'judge' && h(JudgeView, {
      history, setHistory, current, setCurrent, status,
      paletteOpen, setPaletteOpen, tweaks, settings,
    }),
    view === 'inventory' && h(InventoryView, null),
    view === 'moodboard' && h(MoodboardView, null),

    paletteOpen && h(HistoryPalette, {
      history,
      onClose: () => setPaletteOpen(false),
      onPick: (item) => { setCurrent(item); setView('judge'); setPaletteOpen(false); },
      onClear: () => { setHistory([]); setCurrent(null); setPaletteOpen(false); },
    }),

    settingsOpen && h(SettingsPanel, {
      onClose: () => setSettingsOpen(false),
      settings, setSettings,
    }),

    voiceOpen && h(VoicePanel, {
      onClose: () => setVoiceOpen(false),
    }),

    rulePopover && h(RulePopover, {
      ruleNumber: rulePopover,
      onClose: () => setRulePopover(null),
    }),

    cardPopover && h(CardPopover, {
      cardName: cardPopover,
      onClose: () => setCardPopover(null),
    }),

    h(TweaksPanel, {
      tweaks, setTweaks,
      visible: editMode,
      onClose: () => {
        setEditMode(false);
        window.parent.postMessage({ type: '__deactivate_edit_mode' }, '*');
      },
    }),
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(h(App));
})();
