/** * jev8ball client. * * Wires the DOM to the software-rendered 3D stage, the Jev decisions endpoint, the * telemetry readout, the local query log, and the procedural audio. Everything is a * small number of pure-ish helpers plus one async orchestration function so the * failure modes stay readable. */ import { OracleAudio } from './audio.js'; import { OracleBall } from './ball3d.js'; import { ParticleField } from './particles.js'; const MAX_HISTORY = 25; const QUESTION_LIMIT = 300; const HISTORY_KEY = 'jev_history'; const MUTED_KEY = 'jev_muted'; const MOTION_KEY = 'jev_motion'; const SENTIMENTS = ['affirmative', 'negative', 'neutral']; const TONE_HEX = { affirmative: '#10b981', negative: '#ef4444', neutral: '#38bdf8', offline: '#8f8f96', idle: '#38bdf8' }; const STAGE_HINTS = { idle: 'click the vessel to shake it', rolling: 'the oracle is considering…', ready: 'click again for another question', error: 'the oracle could not be reached — try again' }; const byId = (id) => document.getElementById(id); const dom = { canvas: byId('oracleCanvas'), rollButton: byId('rollButton'), stageHint: byId('stageHint'), form: byId('askForm'), input: byId('questionInput'), submit: byId('submitBtn'), soundToggle: byId('soundToggle'), motionToggle: byId('motionToggle'), backendStatus: byId('backendStatus'), statusText: byId('statusText'), charCount: byId('charCount'), card: byId('telemetryCard'), badge: byId('telemetryBadge'), verdict: byId('tVerdict'), noul: byId('tNoul'), noulFill: byId('noulFill'), confidence: byId('tConfidence'), latency: byId('tLatency'), cost: byId('tCost'), json: byId('jsonDump'), historyList: byId('historyList'), historyCount: byId('historyCount'), historyEmpty: byId('historyEmpty'), toast: byId('toast'), particles: byId('particles') }; const audio = new OracleAudio(MUTED_KEY); const particles = new ParticleField(dom.particles); const ball = new OracleBall(dom.canvas, { reducedMotion: prefersReducedMotion() }); let busy = false; let history = loadHistory(); let toastTimer = 0; function prefersReducedMotion() { return typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false; } function storedMotionPreference() { try { const stored = localStorage.getItem(MOTION_KEY); if (stored === 'reduce') return true; if (stored === 'full') return false; } catch (e) { /* storage unavailable: fall back to the media query */ } return prefersReducedMotion(); } function setHint(key) { dom.stageHint.textContent = STAGE_HINTS[key] || STAGE_HINTS.idle; } function showToast(message, kind = 'info', duration = 2400) { clearTimeout(toastTimer); dom.toast.textContent = message; dom.toast.dataset.kind = kind; dom.toast.classList.add('active'); toastTimer = setTimeout(() => dom.toast.classList.remove('active'), duration); } function setBusy(next) { busy = next; document.body.dataset.busy = String(next); dom.submit.disabled = next; dom.input.disabled = next; dom.rollButton.disabled = next; for (const pill of document.querySelectorAll('.quick-pill')) pill.disabled = next; } /** Reads the log defensively: a stale or hand-edited value must never break the page. */ function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed .filter( (item) => item && typeof item.question === 'string' && typeof item.answer === 'string' ) .slice(0, MAX_HISTORY) .map((item) => ({ question: item.question, answer: item.answer, sentiment: SENTIMENTS.includes(item.sentiment) ? item.sentiment : 'neutral', timestamp: typeof item.timestamp === 'string' ? item.timestamp : '', offline: Boolean(item.offline) })); } catch (e) { return []; } } function persistHistory() { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); } catch (e) { /* quota or private mode: keep the in-memory log */ } } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderHistory() { dom.historyList.innerHTML = ''; dom.historyCount.textContent = `${history.length} ${history.length === 1 ? 'query' : 'queries'}`; dom.historyEmpty.hidden = history.length > 0; history.forEach((item) => { const li = document.createElement('li'); li.className = 'history-item'; li.tabIndex = 0; li.setAttribute('role', 'button'); li.title = 'Copy this fortune'; li.innerHTML = '
' + `
${escapeHtml(item.question)}
` + `
${escapeHtml(item.timestamp)}${item.offline ? ' · offline' : ''}
` + '
' + `
${escapeHtml(item.answer)}
`; const copy = () => copyFortune(item); li.addEventListener('click', copy); li.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); copy(); } }); dom.historyList.appendChild(li); }); } async function copyFortune(item) { const text = `Q: ${item.question}\nA: ${item.answer}`; try { await navigator.clipboard.writeText(text); audio.blip(); showToast('copied fortune to clipboard'); } catch (e) { showToast('clipboard unavailable in this browser', 'warn'); } } function pushHistory(entry) { history.unshift(entry); if (history.length > MAX_HISTORY) history.length = MAX_HISTORY; persistHistory(); renderHistory(); } /* ─── telemetry ─── */ function toneFor(data) { if (data.offline) return 'offline'; return SENTIMENTS.includes(data.sentiment) ? data.sentiment : 'neutral'; } function setPending() { dom.card.dataset.tone = 'pending'; dom.badge.className = 'telemetry-badge'; dom.badge.textContent = 'QUERYING'; dom.verdict.textContent = 'the oracle is considering your question…'; dom.noul.textContent = '—'; dom.noulFill.className = 'meter-fill'; dom.noulFill.style.width = '0%'; dom.confidence.textContent = '—'; dom.latency.textContent = '—'; dom.cost.textContent = '—'; dom.json.textContent = '// awaiting decision…'; } function presentError(error) { dom.card.dataset.tone = 'error'; dom.badge.className = 'telemetry-badge error'; dom.badge.textContent = 'ERROR'; dom.verdict.textContent = error && error.message ? error.message : 'the request failed'; dom.noul.textContent = '—'; dom.noulFill.className = 'meter-fill'; dom.noulFill.style.width = '0%'; dom.confidence.textContent = '—'; dom.latency.textContent = '—'; dom.cost.textContent = '—'; dom.json.textContent = '// no decision payload'; setHint('error'); } function present(data) { const tone = toneFor(data); const offline = tone === 'offline'; ball.answer(data.answer || 'Cannot predict now', tone); audio.reveal(offline ? 'neutral' : tone); particles.setTone(TONE_HEX[tone]); setHint('ready'); dom.card.dataset.tone = tone; dom.badge.className = `telemetry-badge ${tone}`; dom.badge.textContent = offline ? 'OFFLINE' : tone.toUpperCase(); dom.verdict.textContent = data.answer || '—'; // An offline verdict is a local hash guess: never dress it up as a calibrated one. dom.noul.textContent = offline ? '—' : typeof data.noul === 'number' ? data.noul.toFixed(2) : '0.50'; dom.noulFill.className = `meter-fill ${offline ? '' : tone}`.trim(); dom.noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`; dom.confidence.textContent = offline ? '—' : `${Math.round((data.confidence || 0.8) * 100)}%`; dom.latency.textContent = `${data.latency || 0}ms`; dom.cost.textContent = offline ? '—' : typeof data.cost === 'number' && data.cost > 0 ? `$${data.cost.toFixed(6)}` : '< $0.0001'; dom.json.textContent = stringify(data.raw || data); pushHistory({ question: data.question, answer: data.answer, sentiment: offline ? 'neutral' : tone, offline, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }); if (offline) showToast('offline guess — the oracle could not be reached', 'warn', 3200); } function stringify(value) { try { return JSON.stringify(value, null, 2); } catch (e) { return '// payload could not be serialised'; } } /* ─── orchestration ─── */ function updateCharCount() { dom.charCount.textContent = String(dom.input.value.length); } function submitQuestion(raw) { if (busy) return; const question = String(raw || '').trim().slice(0, QUESTION_LIMIT); if (!question) { showToast('type a question first', 'warn'); dom.input.focus(); return; } dom.input.value = question; updateCharCount(); ask(question); } async function ask(question) { setBusy(true); setPending(); setHint('rolling'); audio.roll(ball.rollDuration); const roll = ball.roll(); try { const response = await fetch('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question }) }); let payload = null; try { payload = await response.json(); } catch (e) { payload = null; } // A non-OK status is only usable when the backend shipped a fallback with it. if (!response.ok && !(payload && payload.fallback)) { throw new Error((payload && payload.error) || `oracle returned HTTP ${response.status}`); } if (!payload) throw new Error('oracle returned an empty response'); await roll; present(payload.fallback ? { ...payload.fallback, question } : payload); } catch (error) { await roll; // let the shake finish so the interface never snaps console.error('oracle request failed:', error); audio.error(); presentError(error); showToast((error && error.message) || 'request failed', 'error', 3200); } finally { setBusy(false); } } async function checkStatus() { try { const response = await fetch('/api/status', { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); dom.backendStatus.classList.remove('online', 'offline', 'degraded'); dom.backendStatus.classList.add(data.hasKey === false ? 'degraded' : 'online'); dom.statusText.textContent = data.model || 'online'; dom.backendStatus.title = `key: ${data.keySource || 'unknown'} — click to re-check`; } catch (error) { dom.backendStatus.classList.remove('online', 'degraded'); dom.backendStatus.classList.add('offline'); dom.statusText.textContent = 'backend offline'; dom.backendStatus.title = 'Backend unreachable — click to retry'; } } /* ─── preferences ─── */ let reducedMotion = storedMotionPreference(); function applyMotion(reduced, { persist = true, announce = false } = {}) { reducedMotion = reduced; document.body.dataset.motion = reduced ? 'reduced' : 'full'; dom.motionToggle.textContent = reduced ? 'motion: reduced' : 'motion: full'; dom.motionToggle.setAttribute('aria-pressed', String(reduced)); ball.setReducedMotion(reduced); if (persist) { try { localStorage.setItem(MOTION_KEY, reduced ? 'reduce' : 'full'); } catch (e) { /* private mode: preference applies for this session only */ } } if (announce) showToast(reduced ? 'animation reduced' : 'animation restored'); } /* ─── wiring ─── */ function wire() { dom.form.addEventListener('submit', (event) => { event.preventDefault(); submitQuestion(dom.input.value); }); // The vessel itself acts as the submit button for the current question. dom.rollButton.addEventListener('click', () => submitQuestion(dom.input.value)); dom.input.addEventListener('input', updateCharCount); for (const pill of document.querySelectorAll('.quick-pill')) { pill.addEventListener('click', () => submitQuestion(pill.dataset.q)); } dom.soundToggle.addEventListener('click', () => { const muted = audio.toggleMute(); dom.soundToggle.textContent = muted ? 'sound: off' : 'sound: on'; dom.soundToggle.setAttribute('aria-pressed', String(muted)); if (!muted) audio.blip(); showToast(muted ? 'audio muted' : 'audio enabled'); }); dom.motionToggle.addEventListener('click', () => applyMotion(!reducedMotion, { announce: true }) ); dom.backendStatus.addEventListener('click', () => { dom.statusText.textContent = 'checking…'; checkStatus(); }); document.addEventListener('keydown', (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const typing = event.target instanceof HTMLInputElement; if (event.key === '/' && !typing) { event.preventDefault(); dom.input.focus(); } else if (event.key === 'r' && !typing && !busy) { event.preventDefault(); submitQuestion(dom.input.value); } else if (event.key === 'Escape' && typing) { event.target.blur(); } }); document.addEventListener('visibilitychange', () => { if (document.hidden) particles.stop(); else particles.start(); }); if (typeof window.matchMedia === 'function') { const query = window.matchMedia('(prefers-reduced-motion: reduce)'); const onChange = (event) => { let overridden = false; try { overridden = Boolean(localStorage.getItem(MOTION_KEY)); } catch (e) { overridden = false; } if (!overridden) applyMotion(event.matches, { persist: false }); }; if (query.addEventListener) query.addEventListener('change', onChange); else if (query.addListener) query.addListener(onChange); } } function init() { applyMotion(storedMotionPreference(), { persist: false }); ball.attach(); particles.setTone(TONE_HEX.idle); particles.start(); renderHistory(); updateCharCount(); setHint('idle'); wire(); checkStatus(); } init();