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