+
awaiting a question
+
- VERDICT:
- —
-
-
- P(FAVORABLE):
+ P(FAVORABLE)
—
-
+
- CONFIDENCE
+ confidence
—
- LATENCY
+ latency
—
- ENGINE
+ engine
TypeSafe Jev
- EST. COST
+ est. cost
—
-
> inspect raw decision payload
- // Awaiting query...
+ // awaiting query…
-
+
-
+
+ No questions asked yet. History stays in this browser.
+
+
-
+
-
+
diff --git a/public/js/app.js b/public/js/app.js
index 4b04e80..b5f6ac3 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1,468 +1,464 @@
/**
- * jev8ball // Neural Decision Oracle Client
- * Pure vanilla ES6+, HTML5 Canvas, Web Audio API
+ * 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';
-(function () {
- 'use strict';
+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'];
- // ─── AMBIENT PARTICLES (katdrop style) ───
- const canvas = document.getElementById('particles');
- const ctx = canvas.getContext('2d');
- let particles = [];
+const TONE_HEX = {
+ affirmative: '#10b981',
+ negative: '#ef4444',
+ neutral: '#38bdf8',
+ offline: '#8f8f96',
+ idle: '#38bdf8'
+};
- function resizeCanvas() {
- canvas.width = window.innerWidth;
- canvas.height = window.innerHeight;
+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 */
}
- window.addEventListener('resize', resizeCanvas);
- resizeCanvas();
+ return prefersReducedMotion();
+}
- class Particle {
- constructor() {
- this.reset();
- }
- reset() {
- this.x = Math.random() * canvas.width;
- this.y = Math.random() * canvas.height;
- this.size = Math.random() * 1.5 + 0.5;
- this.speedX = (Math.random() - 0.5) * 0.25;
- this.speedY = (Math.random() - 0.5) * 0.25;
- this.alpha = Math.random() * 0.35 + 0.1;
- }
- update() {
- this.x += this.speedX;
- this.y += this.speedY;
- if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) {
- this.reset();
+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();
}
- }
- draw() {
- ctx.fillStyle = `rgba(255, 255, 255, ${this.alpha})`;
- ctx.fillRect(this.x, this.y, this.size, this.size);
- }
+ });
+ 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 initParticles(count = 45) {
- particles = Array.from({ length: count }, () => new Particle());
+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';
}
- initParticles();
+}
- function animateParticles() {
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- for (const p of particles) {
- p.update();
- p.draw();
- }
- requestAnimationFrame(animateParticles);
+/* ─── 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;
}
- requestAnimationFrame(animateParticles);
+ dom.input.value = question;
+ updateCharCount();
+ ask(question);
+}
- // ─── AUDIO SYNTHESIZER ───
- class OracleAudio {
- constructor() {
- this.ctx = null;
- this.muted = localStorage.getItem('jev_muted') === 'true';
- }
- init() {
- if (!this.ctx) {
- const AC = window.AudioContext || window.webkitAudioContext;
- if (AC) this.ctx = new AC();
- }
- if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
- }
- toggleMute() {
- this.muted = !this.muted;
- localStorage.setItem('jev_muted', this.muted);
- return this.muted;
- }
- playTone(freq, type = 'sine', duration = 0.1, vol = 0.1) {
- if (this.muted) return;
- try {
- this.init();
- if (!this.ctx) return;
- const osc = this.ctx.createOscillator();
- const gain = this.ctx.createGain();
- osc.type = type;
- osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
- gain.gain.setValueAtTime(vol, this.ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
- osc.connect(gain);
- gain.connect(this.ctx.destination);
- osc.start();
- osc.stop(this.ctx.currentTime + duration);
- } catch (e) {}
- }
- rollSound() {
- if (this.muted) return;
- try {
- this.init();
- if (!this.ctx) return;
- // Whirring resonant gyroscopic roll sweep
- const osc = this.ctx.createOscillator();
- const gain = this.ctx.createGain();
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(60, this.ctx.currentTime);
- osc.frequency.exponentialRampToValueAtTime(240, this.ctx.currentTime + 0.4);
- osc.frequency.exponentialRampToValueAtTime(95, this.ctx.currentTime + 1.1);
+async function ask(question) {
+ setBusy(true);
+ setPending();
+ setHint('rolling');
+ audio.roll(ball.rollDuration);
+ const roll = ball.roll();
- const filter = this.ctx.createBiquadFilter();
- filter.type = 'lowpass';
- filter.frequency.setValueAtTime(450, this.ctx.currentTime);
- filter.frequency.linearRampToValueAtTime(900, this.ctx.currentTime + 0.5);
- filter.frequency.linearRampToValueAtTime(300, this.ctx.currentTime + 1.1);
+ try {
+ const response = await fetch('/api/ask', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ question })
+ });
- gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
- gain.gain.linearRampToValueAtTime(0.16, this.ctx.currentTime + 0.35);
- gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 1.15);
-
- osc.connect(filter);
- filter.connect(gain);
- gain.connect(this.ctx.destination);
- osc.start();
- osc.stop(this.ctx.currentTime + 1.15);
- } catch (e) {}
- }
- revealChime(sentiment = 'affirmative') {
- if (this.muted) return;
- if (sentiment === 'affirmative') {
- this.playTone(440, 'triangle', 0.12, 0.15);
- setTimeout(() => this.playTone(554.37, 'triangle', 0.15, 0.15), 80);
- setTimeout(() => this.playTone(659.25, 'triangle', 0.25, 0.18), 160);
- } else if (sentiment === 'negative') {
- this.playTone(329.63, 'sawtooth', 0.14, 0.12);
- setTimeout(() => this.playTone(277.18, 'sawtooth', 0.22, 0.14), 100);
- } else {
- this.playTone(392.00, 'sine', 0.18, 0.14);
- setTimeout(() => this.playTone(440.00, 'sine', 0.22, 0.14), 110);
- }
- }
- }
- const audio = new OracleAudio();
-
- // ─── DOM ELEMENTS ───
- const ballWrapper = document.getElementById('ballWrapper');
- const ballSphere = document.getElementById('ballSphere');
- const ballShadow = document.getElementById('ballShadow');
- const floatingDie = document.getElementById('floatingDie');
- const dieText = document.getElementById('dieText');
- const askForm = document.getElementById('askForm');
- const questionInput = document.getElementById('questionInput');
- const submitBtn = document.getElementById('submitBtn');
- const btnText = document.getElementById('btnText');
- const btnSpinner = document.getElementById('btnSpinner');
- const soundToggle = document.getElementById('soundToggle');
- const backendStatus = document.getElementById('backendStatus');
- const statusText = document.getElementById('statusText');
-
- const telemetryCard = document.getElementById('telemetryCard');
- const telemetryBadge = document.getElementById('telemetryBadge');
- const tVerdict = document.getElementById('tVerdict');
- const tNoul = document.getElementById('tNoul');
- const noulFill = document.getElementById('noulFill');
- const tConfidence = document.getElementById('tConfidence');
- const tLatency = document.getElementById('tLatency');
- const tCost = document.getElementById('tCost');
- const jsonDump = document.getElementById('jsonDump');
-
- const historyList = document.getElementById('historyList');
- const historyCount = document.getElementById('historyCount');
- const toast = document.getElementById('toast');
-
- let isSubmitting = false;
- let historyData = loadHistory();
-
- // Read the stored log defensively: a stale, hand-edited or truncated value must never
- // break the interface, so anything that is not a well formed entry is dropped.
- function loadHistory() {
- const sentiments = ['affirmative', 'negative', 'neutral'];
+ let payload = null;
try {
- const saved = localStorage.getItem('jev_history');
- if (!saved) return [];
- const parsed = JSON.parse(saved);
- if (!Array.isArray(parsed)) return [];
- return parsed
- .filter(
- (item) => item && typeof item.question === 'string' && typeof item.answer === 'string'
- )
- .slice(0, 25)
- .map((item) => ({
- question: item.question,
- answer: item.answer,
- sentiment: sentiments.includes(item.sentiment) ? item.sentiment : 'neutral',
- timestamp: typeof item.timestamp === 'string' ? item.timestamp : ''
- }));
+ payload = await response.json();
} catch (e) {
- return [];
+ payload = null;
}
- }
- // ─── SOUND TOGGLE ───
- function updateSoundUI() {
- soundToggle.textContent = audio.muted ? 'sound: off' : 'sound: on';
- }
- updateSoundUI();
- soundToggle.addEventListener('click', () => {
- audio.toggleMute();
- updateSoundUI();
- showToast(audio.muted ? 'Audio muted' : 'Audio enabled');
- });
-
- // ─── TOAST NOTIFICATION ───
- let toastTimer = null;
- function showToast(msg) {
- clearTimeout(toastTimer);
- toast.textContent = msg;
- toast.classList.add('active');
- toastTimer = setTimeout(() => {
- toast.classList.remove('active');
- }, 2200);
- }
-
- // ─── 3D PARALLAX TILT ON HOVER ───
- window.addEventListener('pointermove', (e) => {
- if (isSubmitting) return;
- const rect = ballWrapper.getBoundingClientRect();
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
- const deltaX = (e.clientX - centerX) / (window.innerWidth / 2);
- const deltaY = (e.clientY - centerY) / (window.innerHeight / 2);
-
- const tiltX = Math.max(-14, Math.min(14, -deltaY * 16));
- const tiltY = Math.max(-14, Math.min(14, deltaX * 16));
-
- ballSphere.style.transform = `rotateX(${tiltX}deg) rotateY(${tiltY}deg)`;
- });
-
- ballWrapper.addEventListener('mouseleave', () => {
- if (!isSubmitting) {
- ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
+ // 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');
- // Clicking hexagonal figure rolls it if input is non-empty, or prompts user
- ballWrapper.addEventListener('click', () => {
- if (isSubmitting) return;
- if (questionInput.value.trim()) {
- handleAsk(questionInput.value.trim());
- } else {
- questionInput.focus();
- showToast('Type a question to consult Jev');
- }
- });
+ 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);
+ }
+}
- // ─── STATUS CHECK ───
- async function checkStatus() {
+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 {
- const res = await fetch('/api/status');
- if (res.ok) {
- const data = await res.json();
- backendStatus.classList.add('online');
- statusText.textContent = data.model;
- } else {
- backendStatus.classList.remove('online');
- statusText.textContent = 'status error';
- }
+ localStorage.setItem(MOTION_KEY, reduced ? 'reduce' : 'full');
} catch (e) {
- backendStatus.classList.remove('online');
- statusText.textContent = 'offline';
+ /* private mode: preference applies for this session only */
}
}
- checkStatus();
- backendStatus.addEventListener('click', () => {
- showToast('Checking Jev backend status...');
+ 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();
});
- // ─── QUICK PROMPTS ───
- document.querySelectorAll('.quick-pill').forEach(pill => {
- pill.addEventListener('click', () => {
- if (isSubmitting) return;
- const q = pill.getAttribute('data-q');
- questionInput.value = q;
- handleAsk(q);
- });
+ 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();
+ }
});
- // ─── FORM SUBMISSION ───
- askForm.addEventListener('submit', (e) => {
- e.preventDefault();
- const q = questionInput.value.trim();
- if (q) handleAsk(q);
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) particles.stop();
+ else particles.start();
});
- async function handleAsk(question) {
- if (isSubmitting) return;
- isSubmitting = true;
-
- // UI Loading state
- submitBtn.disabled = true;
- questionInput.disabled = true;
- btnText.style.display = 'none';
- btnSpinner.style.display = 'inline-block';
-
- // 1. Roll Hexagonal Figure in Place & Submerge Die
- ballSphere.classList.add('rolling');
- if (ballShadow) ballShadow.classList.add('rolling-shadow');
- floatingDie.className = 'floating-die submerged';
- audio.rollSound();
-
- const minRollTime = new Promise(resolve => setTimeout(resolve, 1250));
-
- try {
- // 2. Call /api/ask
- const apiCall = fetch('/api/ask', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ question })
- }).then(async (response) => {
- 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}`
- );
- }
- return payload;
- });
-
- const [_, result] = await Promise.all([minRollTime, apiCall]);
-
- // Remove rolling classes
- ballSphere.classList.remove('rolling');
- if (ballShadow) ballShadow.classList.remove('rolling-shadow');
- ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
-
- if (!result) {
- throw new Error('Oracle returned an empty response.');
+ 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 (result.error && !result.fallback) {
- showToast(result.error || 'Decision failed');
- dieText.innerHTML = 'CANNOT
PREDICT';
- floatingDie.className = 'floating-die surfacing';
- return;
- }
-
- const data = result.fallback ? { ...result.fallback, question } : result;
- displayOracleAnswer(data);
-
- } catch (err) {
- console.error('Request failed:', err);
- ballSphere.classList.remove('rolling');
- if (ballShadow) ballShadow.classList.remove('rolling-shadow');
- ballSphere.style.transform = 'rotateX(0deg) rotateY(0deg)';
- // Never show an affirmative prophecy for a request that failed.
- dieText.innerHTML = 'CANNOT
PREDICT';
- floatingDie.className = 'floating-die surfacing';
- telemetryBadge.className = 'telemetry-badge neutral';
- telemetryBadge.textContent = 'ERROR';
- showToast(err && err.message ? err.message : 'Request failed. Please retry.');
- } finally {
- isSubmitting = false;
- submitBtn.disabled = false;
- questionInput.disabled = false;
- btnText.style.display = 'inline';
- btnSpinner.style.display = 'none';
- questionInput.focus();
- }
- }
-
- function displayOracleAnswer(data) {
- const formattedText = String(data.answer || 'Cannot predict now')
- .replace(/\s+/g, ' ')
- .toUpperCase();
- const words = formattedText.split(' ');
- let htmlAnswer = formattedText;
- if (words.length > 2) {
- const mid = Math.ceil(words.length / 2);
- htmlAnswer = words.slice(0, mid).join(' ') + '
' + words.slice(mid).join(' ');
- }
-
- dieText.innerHTML = htmlAnswer;
- floatingDie.className = 'floating-die surfacing';
-
- // Audio chime
- audio.revealChime(data.sentiment);
-
- // Update Telemetry. An offline fallback is a local hash guess, so it is labelled and
- // must not be shown with the calibrated probability and confidence of a real decision.
- const offline = Boolean(data.offline);
- const sentiment = offline ? 'neutral' : (data.sentiment || 'neutral');
- telemetryBadge.className = `telemetry-badge ${offline ? 'offline' : sentiment}`;
- telemetryBadge.textContent = offline ? 'OFFLINE' : sentiment.toUpperCase();
-
- tVerdict.textContent = data.answer;
- tNoul.textContent = offline ? '—' : (data.noul !== undefined ? data.noul.toFixed(2) : '0.50');
-
- noulFill.className = `meter-fill ${sentiment}`;
- noulFill.style.width = offline ? '0%' : `${Math.round((data.noul || 0.5) * 100)}%`;
-
- tConfidence.textContent = offline ? '—' : `${Math.round((data.confidence || 0.8) * 100)}%`;
- tLatency.textContent = `${data.latency || 320}ms`;
- tCost.textContent = offline ? '—' : data.cost ? `$${data.cost.toFixed(6)}` : '< $0.0001';
-
- jsonDump.textContent = JSON.stringify(data.raw || data, null, 2);
-
- // Append to History
- addHistoryItem({
- question: data.question,
- answer: data.answer,
- sentiment: data.sentiment,
- timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
- });
-
- if (offline) showToast('Offline guess — the oracle could not be reached');
- }
-
- function addHistoryItem(item) {
- historyData.unshift(item);
- if (historyData.length > 25) historyData.pop();
- try {
- localStorage.setItem('jev_history', JSON.stringify(historyData));
- } catch (e) {}
- renderHistory();
- }
-
- function renderHistory() {
- historyList.innerHTML = '';
- historyCount.textContent = `${historyData.length} ${historyData.length === 1 ? 'query' : 'queries'}`;
-
- historyData.forEach((item, idx) => {
- const li = document.createElement('li');
- li.className = 'history-item';
- li.innerHTML = `
-
-
${escapeHtml(item.question)}
-
${item.timestamp}
-
-
- ${escapeHtml(item.answer)}
-
- `;
- li.style.cursor = 'pointer';
- li.addEventListener('click', () => {
- navigator.clipboard.writeText(`Q: ${item.question}\nA: ${item.answer}`).then(() => {
- showToast('Copied fortune to clipboard');
- });
- });
- historyList.appendChild(li);
- });
- }
-
- function escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
+ 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();
diff --git a/public/js/audio.js b/public/js/audio.js
new file mode 100644
index 0000000..e924f15
--- /dev/null
+++ b/public/js/audio.js
@@ -0,0 +1,129 @@
+/**
+ * Procedural audio: everything is synthesised with the Web Audio API, so the page
+ * ships no audio assets. Every method is a no-op when muted or when Web Audio is
+ * unavailable, which keeps callers free of guards.
+ */
+export class OracleAudio {
+ constructor(storageKey = 'jev_muted') {
+ this.storageKey = storageKey;
+ this.context = null;
+ this.muted = readMuted(storageKey);
+ }
+
+ init() {
+ if (!this.context) {
+ const Ctor = window.AudioContext || window.webkitAudioContext;
+ if (!Ctor) return null;
+ try {
+ this.context = new Ctor();
+ } catch (e) {
+ return null;
+ }
+ }
+ if (this.context.state === 'suspended') this.context.resume().catch(() => {});
+ return this.context;
+ }
+
+ toggleMute() {
+ this.muted = !this.muted;
+ try {
+ localStorage.setItem(this.storageKey, String(this.muted));
+ } catch (e) {
+ /* storage may be unavailable; mute still applies for this session */
+ }
+ return this.muted;
+ }
+
+ /** One shaped sine/saw blip with an exponential decay. */
+ tone({ frequency, type = 'sine', duration = 0.12, gain = 0.12, delay = 0, sweepTo, filter }) {
+ if (this.muted) return;
+ const context = this.init();
+ if (!context) return;
+ try {
+ const start = context.currentTime + delay;
+ const osc = context.createOscillator();
+ const amp = context.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(frequency, start);
+ if (sweepTo) osc.frequency.exponentialRampToValueAtTime(sweepTo, start + duration);
+ amp.gain.setValueAtTime(gain, start);
+ amp.gain.exponentialRampToValueAtTime(0.0001, start + duration);
+ let tail = osc;
+ if (filter) {
+ const biquad = context.createBiquadFilter();
+ biquad.type = 'lowpass';
+ biquad.frequency.setValueAtTime(filter.from, start);
+ biquad.frequency.linearRampToValueAtTime(filter.to, start + duration);
+ osc.connect(biquad);
+ tail = biquad;
+ }
+ tail.connect(amp);
+ amp.connect(context.destination);
+ osc.start(start);
+ osc.stop(start + duration + 0.02);
+ } catch (e) {
+ /* a failed blip must never break the interaction */
+ }
+ }
+
+ /** Rising metallic sweep while the vessel is being shaken. */
+ roll(durationSeconds = 1.2) {
+ this.tone({
+ frequency: 58,
+ sweepTo: 132,
+ type: 'sawtooth',
+ duration: durationSeconds * 0.7,
+ gain: 0.07,
+ filter: { from: 420, to: 1100 }
+ });
+ this.tone({
+ frequency: 44,
+ sweepTo: 36,
+ type: 'triangle',
+ duration: durationSeconds,
+ gain: 0.05
+ });
+ }
+
+ /** Chord that lands with the verdict, coloured by sentiment. */
+ reveal(sentiment = 'affirmative') {
+ const chords = {
+ affirmative: [
+ [440.0, 0],
+ [554.37, 0.075],
+ [659.25, 0.15]
+ ],
+ negative: [
+ [329.63, 0],
+ [277.18, 0.1],
+ [196.0, 0.22]
+ ],
+ neutral: [
+ [392.0, 0],
+ [466.16, 0.11]
+ ]
+ };
+ const notes = chords[sentiment] || chords.neutral;
+ const type = sentiment === 'negative' ? 'sawtooth' : 'triangle';
+ for (const [frequency, delay] of notes) {
+ this.tone({ frequency, type, duration: 0.3, gain: 0.11, delay });
+ }
+ }
+
+ /** Short confirmation used when an answer is copied. */
+ blip() {
+ this.tone({ frequency: 880, type: 'square', duration: 0.06, gain: 0.05 });
+ }
+
+ error() {
+ this.tone({ frequency: 180, sweepTo: 120, type: 'square', duration: 0.18, gain: 0.05 });
+ }
+}
+
+function readMuted(storageKey) {
+ try {
+ return localStorage.getItem(storageKey) === 'true';
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/public/js/ball3d.js b/public/js/ball3d.js
new file mode 100644
index 0000000..54ce86a
--- /dev/null
+++ b/public/js/ball3d.js
@@ -0,0 +1,710 @@
+/**
+ * Software 3D renderer + animation state machine for the oracle vessel.
+ *
+ * Deliberately Canvas 2D rather than WebGL: the scene is a low-poly hexagonal prism
+ * plus a hexagonal plate, so painter's-algorithm depth sorting with per-face lighting
+ * costs almost nothing, and it renders identically on machines with no GL drivers
+ * (headless browsers, VMs, older devices) where a WebGL context cannot be created.
+ *
+ * The vessel is convex and the plate floats inside it, so sorting faces by camera
+ * distance and drawing back-to-front is sufficient: no z-buffer needed.
+ */
+
+import {
+ affineFromThreePoints,
+ clamp,
+ dot,
+ hexToRgb,
+ lerp,
+ mat4LookAt,
+ mat4Mul,
+ mat4Perspective,
+ mat4RotateX,
+ mat4RotateY,
+ mat4RotateZ,
+ mat4Translate,
+ mixRgb,
+ normalize,
+ projectToScreen,
+ rgbCss,
+ smoothstep,
+ transformDir,
+ transformPoint
+} from './math3d.js';
+import { createPlate, createVessel } from './meshes.js';
+
+const TAU = Math.PI * 2;
+const LIGHT = normalize([-0.42, 0.7, 0.86]);
+const VIEW = [0, 0, 1];
+
+const BODY_BASE = hexToRgb('#191919');
+const BODY_EDGE = hexToRgb('#2b2b30');
+const CHAMBER_RGB = hexToRgb('#07070b');
+const PLATE_BASE = hexToRgb('#1b1b1f');
+const SPECULAR = [236, 245, 255];
+
+const TONES = {
+ affirmative: hexToRgb('#10b981'),
+ negative: hexToRgb('#ef4444'),
+ neutral: hexToRgb('#38bdf8'),
+ offline: hexToRgb('#8f8f96'),
+ idle: hexToRgb('#38bdf8')
+};
+
+// Two slightly perturbed lights give the large faces a soft falloff without the cost
+// of per-vertex normals. Faceted faces stay flat so the silhouette reads as machined.
+const LIGHT_NEAR = normalize([LIGHT[0] - 0.35, LIGHT[1] + 0.32, LIGHT[2]]);
+const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
+const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
+
+/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
+export class OracleBall {
+ constructor(canvas, { reducedMotion = false } = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.reducedMotion = reducedMotion;
+ this.vessel = createVessel();
+ this.plate = createPlate();
+ this.radiusCss = 172;
+ this.width = 0;
+ this.height = 0;
+ this.dpr = 1;
+
+ this.clock = 0;
+ this.lastFrame = 0;
+ this.running = false;
+ this.raf = 0;
+ this.visible = true;
+
+ this.phase = 'idle';
+ this.rollStart = 0;
+ this.revealStart = 0;
+ this.holdTime = 0;
+ this.rollDuration = 1.25;
+ this.revealDuration = 0.9;
+
+ this.answerLines = ['CONCENTRATE', '& ASK'];
+ this.tone = 'idle';
+ this.pendingAnswer = null;
+
+ this.pointer = { x: 0, y: 0 };
+ this.tilt = { x: 0, y: 0 };
+ this.shake = 0;
+ this.glow = 0;
+ this.resolveRoll = null;
+ }
+
+ /* ─── public API ─── */
+
+ /** Starts the render loop, sizing, and pointer parallax. Safe to call twice. */
+ attach() {
+ if (this.running || !this.ctx) return;
+ this.running = true;
+ this._resize();
+ this._observeSize();
+ this._observeVisibility();
+ this.canvas.addEventListener('pointermove', this._onPointerMove);
+ this.canvas.addEventListener('pointerleave', this._onPointerLeave);
+ this.lastFrame = performance.now();
+ this.raf = requestAnimationFrame(this._frame);
+ }
+
+ detach() {
+ this.running = false;
+ cancelAnimationFrame(this.raf);
+ this.canvas.removeEventListener('pointermove', this._onPointerMove);
+ this.canvas.removeEventListener('pointerleave', this._onPointerLeave);
+ if (this.resizeObserver) this.resizeObserver.disconnect();
+ if (this.intersectionObserver) this.intersectionObserver.disconnect();
+ }
+
+ setReducedMotion(reduced) {
+ this.reducedMotion = reduced;
+ }
+
+ /** Resolves once the shake animation has played. */
+ roll() {
+ this.phase = 'rolling';
+ this.rollStart = this.clock;
+ this.holdTime = 0;
+ this.glow = 0;
+ this.answerLines = null;
+ return new Promise((resolve) => {
+ // Backstop so a paused render loop (hidden tab, off-screen canvas) can never
+ // leave the caller waiting forever.
+ setTimeout(() => {
+ if (this.resolveRoll === resolve) {
+ this.resolveRoll = null;
+ resolve();
+ }
+ }, Math.round(this.rollDuration * 1000) + 500);
+ this.resolveRoll = resolve;
+ });
+ }
+
+ /** Surfaces the die with the verdict text; ignored before a roll. */
+ answer(text, tone = 'neutral') {
+ const lines = wrapAnswer(text);
+ this.pendingAnswer = { lines, tone };
+ this.answerLines = lines;
+ this.tone = tone;
+ this.revealStart = this.clock;
+ this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
+ this.phase = 'revealing';
+ }
+
+ /** Returns the vessel to the resting state and clears the verdict. */
+ reset() {
+ this.answerLines = ['CONCENTRATE', '& ASK'];
+ this.tone = 'idle';
+ this.phase = 'idle';
+ this.glow = 0;
+ this.pendingAnswer = null;
+ }
+
+ /* ─── wiring ─── */
+
+ _onPointerMove = (event) => {
+ const rect = this.canvas.getBoundingClientRect();
+ this.pointer.x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
+ this.pointer.y = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
+ };
+
+ _onPointerLeave = () => {
+ this.pointer.x = 0;
+ this.pointer.y = 0;
+ };
+
+ _observeSize() {
+ if (typeof ResizeObserver === 'function') {
+ this.resizeObserver = new ResizeObserver(() => this._resize());
+ this.resizeObserver.observe(this.canvas.parentElement || this.canvas);
+ }
+ window.addEventListener('resize', this._onWindowResize);
+ }
+
+ _onWindowResize = () => this._resize();
+
+ _observeVisibility() {
+ if (typeof IntersectionObserver === 'function') {
+ this.intersectionObserver = new IntersectionObserver((entries) => {
+ this.visible = entries.some((entry) => entry.isIntersecting);
+ });
+ this.intersectionObserver.observe(this.canvas);
+ }
+ }
+
+ _resize() {
+ const rect = this.canvas.getBoundingClientRect();
+ const size = Math.max(160, Math.min(rect.width || 344, 460));
+ this.dpr = Math.min(window.devicePixelRatio || 1, 2);
+ this.width = size;
+ this.height = size;
+ this.canvas.width = Math.round(size * this.dpr);
+ this.canvas.height = Math.round(size * this.dpr);
+ this.radiusCss = size;
+ }
+
+ /* ─── per-frame simulation ─── */
+
+ _frame = (now) => {
+ if (!this.running) return;
+ const dt = Math.min((now - this.lastFrame) / 1000, 0.05);
+ this.lastFrame = now;
+ // Freeze the clock while hidden or scrolled away so nothing jumps on return.
+ if (this.visible && !document.hidden) {
+ this.clock += dt;
+ this._update(dt);
+ this._render();
+ }
+ this.raf = requestAnimationFrame(this._frame);
+ };
+
+ _update(dt) {
+ const ease = Math.min(1, dt * 6);
+ this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
+ this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
+
+ const elapsed = this.clock - this.rollStart;
+
+ if (this.phase === 'rolling') {
+ this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
+ this.shake = this.reducedMotion
+ ? 0
+ : (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
+ this.liquidSlosh = this.reducedMotion ? 0 : 1;
+ if (elapsed >= this.rollDuration) {
+ this.holdTime += dt;
+ if (this.resolveRoll) {
+ const resolve = this.resolveRoll;
+ this.resolveRoll = null;
+ resolve();
+ }
+ }
+ this.platePose = this._tumblePose(elapsed);
+ this.answerAlpha = 0;
+ this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
+ } else if (this.phase === 'revealing') {
+ const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
+ const settle = smoothstep(0, 0.72, t);
+ const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
+ // Unwind the tumble to zero so the plate always lands face-on to the camera.
+ this.platePose = {
+ rx: from.rx * (1 - settle),
+ ry: from.ry * (1 - settle),
+ rz: from.rz * (1 - settle),
+ y: lerp(from.y, 0.06, settle)
+ };
+ this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
+ this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
+ this.answerAlpha = smoothstep(0.5, 1, t);
+ this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
+ if (t >= 1) this.phase = 'ready';
+ } else {
+ const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
+ this.platePose = {
+ rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
+ ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
+ rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
+ y: 0.02 + bob
+ };
+ this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
+ this.answerAlpha = this.answerLines ? 1 : 0;
+ this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
+ }
+ }
+
+ /** Churning pose used while the vessel is being shaken. */
+ _tumblePose(elapsed) {
+ const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
+ if (this.reducedMotion) {
+ return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
+ }
+ return {
+ rx: elapsed * 5.2,
+ ry: elapsed * 3.05,
+ rz: elapsed * 2.15,
+ y: lerp(0.02, -0.5, dive)
+ };
+ }
+
+ get toneRgb() {
+ return TONES[this.tone] || TONES.idle;
+ }
+
+ /* ─── rendering ─── */
+
+ _render() {
+ const { ctx } = this;
+ const w = this.width;
+ const h = this.height;
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+
+ // Camera sits far enough that the vessel fills ~72% of the canvas height,
+ // leaving room for the shake, the rim glow, and the cast shadow.
+ const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
+ const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
+ this.viewProj = mat4Mul(proj, view);
+
+ const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
+ const shakeZ = this.shake * 0.5;
+ const vesselModel = mat4Mul(
+ mat4RotateZ(shakeZ),
+ mat4Mul(
+ mat4RotateY(this.tilt.y + this.shake * 0.28),
+ mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
+ )
+ );
+ const plateModel = mat4Mul(
+ mat4Translate(0, pose.y, 0),
+ mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
+ );
+
+ this._drawGroundGlow(w, h);
+ this._drawContactShadow(w, h);
+
+ const vessel = this._projectMesh(this.vessel.mesh, vesselModel);
+ const plate = this._projectMesh(this.plate.mesh, plateModel);
+ const windowPoly = this._projectWindow(vesselModel);
+ const liquid = windowPoly
+ ? this._liquidGeometry(vesselModel, windowPoly)
+ : null;
+
+ const tone = this.toneRgb;
+
+ // 1. shell behind the chamber: back cap first, then the side facets
+ for (const face of vessel.faces) {
+ if (face.kind === 'ring') continue;
+ const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
+ this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
+ }
+
+ // 2. chamber contents, clipped to the window opening
+ if (windowPoly && liquid) {
+ ctx.save();
+ this._clipPolygon(windowPoly);
+ this._fillLiquid(liquid.points, tone);
+ for (const face of plate.faces) {
+ const base = face.kind === 'answer' ? PLATE_BASE : BODY_EDGE;
+ this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
+ }
+ if (this.answerLines && this.answerAlpha > 0.01) {
+ this._drawAnswerText(plate, windowPoly);
+ }
+ this._overlayLiquid(liquid, tone);
+ ctx.restore();
+ }
+
+ // 3. the front frame, nearest to the camera, drawn last
+ for (const face of vessel.faces) {
+ if (face.kind !== 'ring') continue;
+ this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
+ }
+
+ if (windowPoly) this._strokeWindow(windowPoly, tone);
+ }
+
+ /** Projects a mesh and sorts its faces back-to-front. */
+ _projectMesh(mesh, model) {
+ const mvp = mat4Mul(this.viewProj, model);
+ const screen = [];
+ const depth = [];
+ for (const vertex of mesh.vertices) {
+ const clip = transformPoint(mvp, vertex);
+ depth.push(clip[3]);
+ screen.push(projectToScreen(clip, this.width, this.height));
+ }
+ const faces = [];
+ for (const face of mesh.faces) {
+ if (face.indices.some((index) => !screen[index])) continue;
+ let sum = 0;
+ for (const index of face.indices) sum += depth[index];
+ faces.push({
+ kind: face.kind,
+ smooth: face.smooth,
+ normal: transformDir(model, face.normal),
+ depth: sum / face.indices.length,
+ points: face.indices.map((index) => screen[index])
+ });
+ }
+ faces.sort((a, b) => b.depth - a.depth);
+ return { faces, screen };
+ }
+
+ /** Projects the hexagonal opening onto the screen. */
+ _projectWindow(vesselModel) {
+ const mvp = mat4Mul(this.viewProj, vesselModel);
+ const { points, z } = this.vessel.window;
+ const projected = points.map(([x, y]) =>
+ projectToScreen(transformPoint(mvp, [x, y, z]), this.width, this.height)
+ );
+ return projected.every(Boolean) ? projected : null;
+ }
+
+ /** Screen-space liquid body: the window clipped to everything below the surface. */
+ _liquidGeometry(vesselModel, windowPoly) {
+ const mvp = mat4Mul(this.viewProj, vesselModel);
+ const { radius, z } = this.vessel.window;
+ const slosh = this.liquidSlosh || 0;
+ const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
+ const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
+ const left = projectToScreen(
+ transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
+ this.width,
+ this.height
+ );
+ const right = projectToScreen(
+ transformPoint(mvp, [radius * 1.35, level - tilt, z * 0.2]),
+ this.width,
+ this.height
+ );
+ if (!left || !right) return null;
+ return { points: clipPolygonBelow(windowPoly, left, right), surface: [left, right] };
+ }
+
+ _drawGroundGlow(w, h) {
+ const { ctx } = this;
+ const cx = w / 2;
+ const cy = h * 0.52;
+ const radius = w * 0.62;
+ const intensity = 0.3 + this.glow * 0.9;
+ const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
+ gradient.addColorStop(0, rgbCss(this.toneRgb, Math.min(0.34, 0.2 * intensity)));
+ gradient.addColorStop(0.5, rgbCss(this.toneRgb, Math.min(0.12, 0.07 * intensity)));
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, w, h);
+ }
+
+ _drawContactShadow(w, h) {
+ const { ctx } = this;
+ const lift = (this.platePose ? 0 : 0) + 0.02;
+ ctx.save();
+ ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
+ ctx.scale(1, 0.2);
+ const radius = w * 0.31;
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
+ gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
+ gradient.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.beginPath();
+ ctx.arc(0, 0, radius, 0, TAU);
+ ctx.fillStyle = gradient;
+ ctx.fill();
+ ctx.restore();
+ }
+
+ /* ─── shading & paint helpers ─── */
+
+ /** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
+ _shade(base, normal, light, tone, rimStrength) {
+ const diffuse = Math.max(0, dot(normal, light));
+ const half = normalize([light[0], light[1], light[2] + 1]);
+ const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
+ const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
+ const lit = 0.13 + diffuse * 0.95;
+ const rgb = [
+ base[0] * lit + SPECULAR[0] * spec,
+ base[1] * lit + SPECULAR[1] * spec,
+ base[2] * lit + SPECULAR[2] * spec
+ ];
+ return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
+ }
+
+ /** Solid fill for faceted faces, soft gradient for the large curved ones. */
+ _faceStyle(face, base, tone, rimStrength) {
+ if (!face.smooth) {
+ return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
+ }
+ const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
+ const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
+ const bounds = boundsOf(face.points);
+ const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
+ gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
+ gradient.addColorStop(1, rgbCss(far));
+ return gradient;
+ }
+
+ _pathFromPoints(points) {
+ const ctx = this.ctx;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
+ ctx.closePath();
+ }
+
+ _clipPolygon(points) {
+ this._pathFromPoints(points);
+ this.ctx.clip();
+ }
+
+ _fillFace(face, style) {
+ if (face.points.length < 3) return;
+ const ctx = this.ctx;
+ this._pathFromPoints(face.points);
+ ctx.fillStyle = style;
+ ctx.fill();
+ // Hairline edge keeps the facets reading as machined metal rather than flat shapes.
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = 'rgba(255,255,255,0.05)';
+ ctx.stroke();
+ }
+
+ /* ─── liquid ─── */
+
+ _fillLiquid(points, tone) {
+ if (points.length < 3) return;
+ const ctx = this.ctx;
+ const bounds = boundsOf(points);
+ const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
+ gradient.addColorStop(0, rgbCss(mixRgb(hexToRgb('#050a16'), tone, 0.24)));
+ gradient.addColorStop(0.6, rgbCss(hexToRgb('#03060d')));
+ gradient.addColorStop(1, rgbCss(mixRgb(hexToRgb('#010205'), tone, 0.12)));
+ this._pathFromPoints(points);
+ ctx.fillStyle = gradient;
+ ctx.fill();
+ }
+
+ _overlayLiquid(liquid, tone) {
+ const ctx = this.ctx;
+ const points = liquid.points;
+ if (points.length >= 3) {
+ const bounds = boundsOf(points);
+ const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
+ gradient.addColorStop(0, rgbCss(tone, 0.22));
+ gradient.addColorStop(1, rgbCss(tone, 0.03));
+ this._pathFromPoints(points);
+ ctx.fillStyle = gradient;
+ ctx.fill();
+ }
+ const [a, b] = liquid.surface;
+ // drifting specular streaks just under the surface
+ for (let i = 0; i < 3; i += 1) {
+ const t = (this.clock * 0.33 + i / 3) % 1;
+ ctx.beginPath();
+ ctx.ellipse(lerp(a.x, b.x, t), lerp(a.y, b.y, t) + 3, 15, 3, 0, 0, TAU);
+ ctx.fillStyle = rgbCss(SPECULAR, 0.05);
+ ctx.fill();
+ }
+ ctx.beginPath();
+ ctx.moveTo(a.x, a.y);
+ ctx.lineTo(b.x, b.y);
+ ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.45), 0.55);
+ ctx.lineWidth = 1.4;
+ ctx.stroke();
+ }
+
+ _strokeWindow(windowPoly, tone) {
+ const ctx = this.ctx;
+ const bounds = boundsOf(windowPoly);
+ ctx.save();
+ this._pathFromPoints(windowPoly);
+ ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.3), Math.min(0.75, 0.3 + this.glow * 0.35));
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ this._pathFromPoints(windowPoly);
+ ctx.clip();
+ const cx = (bounds.minX + bounds.maxX) / 2;
+ const cy = (bounds.minY + bounds.maxY) / 2;
+ const radius = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) / 2;
+ const vignette = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius);
+ vignette.addColorStop(0, 'rgba(0,0,0,0)');
+ vignette.addColorStop(1, 'rgba(0,0,0,0.7)');
+ ctx.fillStyle = vignette;
+ ctx.fillRect(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
+ ctx.restore();
+ }
+
+ /* ─── verdict text mapped onto the die face ─── */
+
+ _drawAnswerText(plateView) {
+ const ctx = this.ctx;
+ const face = plateView.faces.find((candidate) => candidate.kind === 'answer');
+ if (!face || face.points.length < 6) return;
+ if (dot(face.normal, VIEW) < 0.3) return;
+
+ // The face keeps the plate's local vertex order, so three corners are enough to
+ // solve the affine transform from plate-local space into screen space.
+ const corners = [0, 3, 5].map((index) => ({
+ local: this.plate.points[index],
+ screen: [face.points[index].x, face.points[index].y]
+ }));
+ const matrix = affineFromThreePoints(corners[0], corners[1], corners[2]);
+ if (!matrix) return;
+
+ const lines = this.answerLines;
+ if (!lines || !lines.length) return;
+ const radius = this.plate.radius;
+ const usableWidth = radius * 1.5;
+ const maxChars = Math.max(4, ...lines.map((line) => line.length));
+ let fontSize = clamp(
+ Math.min(usableWidth / (maxChars * 0.62), (radius * 1.05) / lines.length),
+ 0.05,
+ 0.2
+ );
+ const pixelsPerUnit = Math.hypot(matrix[0], matrix[1]) / this.dpr;
+
+ ctx.save();
+ ctx.setTransform(
+ this.dpr * matrix[0],
+ this.dpr * matrix[1],
+ this.dpr * matrix[2],
+ this.dpr * matrix[3],
+ this.dpr * matrix[4],
+ this.dpr * matrix[5]
+ );
+ this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
+ ctx.clip();
+
+ // Shrink to fit if the monospace metrics run wide of the plate.
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ ctx.font = `700 ${fontSize}px ${FONT_STACK}`;
+ const widest = Math.max(...lines.map((line) => ctx.measureText(line).width));
+ if (widest <= usableWidth) break;
+ fontSize *= usableWidth / widest;
+ }
+
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.globalAlpha = this.answerAlpha;
+ ctx.fillStyle = '#f2f7ff';
+ ctx.shadowColor = rgbCss(this.toneRgb, 0.85);
+ ctx.shadowBlur = Math.max(2, fontSize * pixelsPerUnit * 0.45);
+ const lineHeight = fontSize * 1.3;
+ const top = -((lines.length - 1) * lineHeight) / 2;
+ lines.forEach((line, index) => {
+ ctx.fillText(line, 0, top + index * lineHeight);
+ });
+ ctx.restore();
+ }
+}
+
+/* ─── helpers (exported for tests) ─── */
+
+function boundsOf(points) {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const point of points) {
+ if (point.x < minX) minX = point.x;
+ if (point.x > maxX) maxX = point.x;
+ if (point.y < minY) minY = point.y;
+ if (point.y > maxY) maxY = point.y;
+ }
+ return { minX, minY, maxX, maxY };
+}
+
+/**
+ * Sutherland–Hodgman clip of a convex polygon against the half-plane below the line
+ * a→b. Screen space has y pointing down, so "below the surface line" is a positive
+ * cross product.
+ */
+export function clipPolygonBelow(points, a, b) {
+ const out = [];
+ const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
+ for (let i = 0; i < points.length; i += 1) {
+ const current = points[i];
+ const next = points[(i + 1) % points.length];
+ const currentSide = side(current);
+ const nextSide = side(next);
+ if (currentSide >= 0) out.push(current);
+ if (currentSide >= 0 !== nextSide >= 0) {
+ const t = currentSide / (currentSide - nextSide);
+ out.push({
+ x: current.x + (next.x - current.x) * t,
+ y: current.y + (next.y - current.y) * t
+ });
+ }
+ }
+ return out;
+}
+
+/** Upper-cases a verdict and wraps it into at most three plate-sized lines. */
+export function wrapAnswer(text) {
+ const words = String(text || 'Cannot predict now')
+ .toUpperCase()
+ .replace(/\s+/g, ' ')
+ .trim()
+ .split(' ')
+ .filter(Boolean);
+ if (!words.length) return ['CANNOT', 'PREDICT'];
+ const lines = [];
+ let current = '';
+ for (const word of words) {
+ const candidate = current ? `${current} ${word}` : word;
+ if (!current || candidate.length <= 11) {
+ current = candidate;
+ } else {
+ lines.push(current);
+ current = word;
+ }
+ }
+ if (current) lines.push(current);
+ if (lines.length > 3) {
+ return [...lines.slice(0, 2), lines.slice(2).join(' ').slice(0, 14)];
+ }
+ return lines;
+}
diff --git a/public/js/math3d.js b/public/js/math3d.js
new file mode 100644
index 0000000..3fbecb5
--- /dev/null
+++ b/public/js/math3d.js
@@ -0,0 +1,246 @@
+/**
+ * Minimal 3D math: vectors are [x, y, z] arrays, matrices are column-major
+ * Float32Array(16) (m[col * 4 + row]), matching the usual graphics convention.
+ *
+ * Everything here is pure so it can be unit tested outside a browser.
+ */
+
+export function vec3(x = 0, y = 0, z = 0) {
+ return [x, y, z];
+}
+
+export function add(a, b) {
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+}
+
+export function sub(a, b) {
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
+}
+
+export function mulScalar(a, s) {
+ return [a[0] * s, a[1] * s, a[2] * s];
+}
+
+export function dot(a, b) {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+export function cross(a, b) {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0]
+ ];
+}
+
+export function length(a) {
+ return Math.hypot(a[0], a[1], a[2]);
+}
+
+export function normalize(a) {
+ const len = length(a);
+ return len === 0 ? [0, 0, 0] : [a[0] / len, a[1] / len, a[2] / len];
+}
+
+export function lerp(a, b, t) {
+ return a + (b - a) * t;
+}
+
+/** Reflection of incident vector `d` about normal `n`. */
+export function reflect(d, n) {
+ const k = 2 * dot(n, d);
+ return [d[0] - k * n[0], d[1] - k * n[1], d[2] - k * n[2]];
+}
+
+export function clamp(value, min, max) {
+ return value < min ? min : value > max ? max : value;
+}
+
+/** Smooth 0..1 ramp, used for animation easing. */
+export function smoothstep(edge0, edge1, x) {
+ const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+
+export function mat4Identity() {
+ const m = new Float32Array(16);
+ m[0] = m[5] = m[10] = m[15] = 1;
+ return m;
+}
+
+/** Returns a ∘ b (b applied first, then a). */
+export function mat4Mul(a, b) {
+ const out = new Float32Array(16);
+ for (let col = 0; col < 4; col += 1) {
+ for (let row = 0; row < 4; row += 1) {
+ let sum = 0;
+ for (let k = 0; k < 4; k += 1) {
+ sum += a[k * 4 + row] * b[col * 4 + k];
+ }
+ out[col * 4 + row] = sum;
+ }
+ }
+ return out;
+}
+
+export function mat4Translate(x, y, z) {
+ const m = mat4Identity();
+ m[12] = x;
+ m[13] = y;
+ m[14] = z;
+ return m;
+}
+
+export function mat4Scale(x, y = x, z = x) {
+ const m = mat4Identity();
+ m[0] = x;
+ m[5] = y;
+ m[10] = z;
+ return m;
+}
+
+export function mat4RotateX(rad) {
+ const m = mat4Identity();
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ m[5] = c;
+ m[6] = s;
+ m[9] = -s;
+ m[10] = c;
+ return m;
+}
+
+export function mat4RotateY(rad) {
+ const m = mat4Identity();
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ m[0] = c;
+ m[2] = -s;
+ m[8] = s;
+ m[10] = c;
+ return m;
+}
+
+export function mat4RotateZ(rad) {
+ const m = mat4Identity();
+ const c = Math.cos(rad);
+ const s = Math.sin(rad);
+ m[0] = c;
+ m[1] = s;
+ m[4] = -s;
+ m[5] = c;
+ return m;
+}
+
+export function mat4Perspective(fovY, aspect, near, far) {
+ const f = 1 / Math.tan(fovY / 2);
+ const m = new Float32Array(16);
+ m[0] = f / aspect;
+ m[5] = f;
+ m[10] = (far + near) / (near - far);
+ m[11] = -1;
+ m[14] = (2 * far * near) / (near - far);
+ return m;
+}
+
+export function mat4LookAt(eye, target, up) {
+ const zAxis = normalize(sub(eye, target));
+ const xAxis = normalize(cross(up, zAxis));
+ const yAxis = cross(zAxis, xAxis);
+ const m = mat4Identity();
+ m[0] = xAxis[0];
+ m[1] = yAxis[0];
+ m[2] = zAxis[0];
+ m[4] = xAxis[1];
+ m[5] = yAxis[1];
+ m[6] = zAxis[1];
+ m[8] = xAxis[2];
+ m[9] = yAxis[2];
+ m[10] = zAxis[2];
+ m[12] = -dot(xAxis, eye);
+ m[13] = -dot(yAxis, eye);
+ m[14] = -dot(zAxis, eye);
+ return m;
+}
+
+/** Transforms a point (w = 1) and returns the full homogeneous result. */
+export function transformPoint(m, p) {
+ return [
+ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
+ m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
+ m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
+ m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]
+ ];
+}
+
+/** Transforms a direction (w = 0, ignores translation), then normalizes. */
+export function transformDir(m, v) {
+ return normalize([
+ m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
+ m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
+ m[2] * v[0] + m[6] * v[1] + m[10] * v[2]
+ ]);
+}
+
+/**
+ * Projects a homogeneous point into CSS-pixel screen space.
+ * Returns null when the point sits on or behind the camera plane.
+ */
+export function projectToScreen(clip, width, height) {
+ const w = clip[3];
+ if (w <= 1e-6) return null;
+ const ndcX = clip[0] / w;
+ const ndcY = clip[1] / w;
+ return {
+ x: (ndcX * 0.5 + 0.5) * width,
+ y: (0.5 - ndcY * 0.5) * height,
+ depth: clip[2] / w
+ };
+}
+
+/**
+ * Solves the 2D affine transform taking local (x, y) to screen (X, Y) from three
+ * point correspondences, returned as [a, b, c, d, e, f] for setTransform.
+ * Used to lay the answer text onto the projected die face.
+ */
+export function affineFromThreePoints(p1, p2, p3) {
+ const [x1, y1] = p1.local;
+ const [x2, y2] = p2.local;
+ const [x3, y3] = p3.local;
+ const den = (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
+ if (Math.abs(den) < 1e-9) return null;
+ const s1 = p1.screen;
+ const s2 = p2.screen;
+ const s3 = p3.screen;
+ const a = ((s1[0] - s3[0]) * (y2 - y3) - (s2[0] - s3[0]) * (y1 - y3)) / den;
+ const c = ((x1 - x3) * (s2[0] - s3[0]) - (x2 - x3) * (s1[0] - s3[0])) / den;
+ const b = ((s1[1] - s3[1]) * (y2 - y3) - (s2[1] - s3[1]) * (y1 - y3)) / den;
+ const d = ((x1 - x3) * (s2[1] - s3[1]) - (x2 - x3) * (s1[1] - s3[1])) / den;
+ return [a, b, c, d, s1[0] - a * x1 - c * y1, s1[1] - b * x1 - d * y1];
+}
+
+/** Mixes two [r, g, b] colours (0..255) by t. */
+export function mixRgb(from, to, t) {
+ return [
+ from[0] + (to[0] - from[0]) * t,
+ from[1] + (to[1] - from[1]) * t,
+ from[2] + (to[2] - from[2]) * t
+ ];
+}
+
+export function rgbCss(rgb, alpha = 1) {
+ const r = Math.round(clamp(rgb[0], 0, 255));
+ const g = Math.round(clamp(rgb[1], 0, 255));
+ const b = Math.round(clamp(rgb[2], 0, 255));
+ return alpha >= 1 ? `rgb(${r},${g},${b})` : `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`;
+}
+
+/** Parses '#rrggbb' into [r, g, b]. */
+export function hexToRgb(hex) {
+ const value = hex.replace('#', '');
+ return [
+ parseInt(value.slice(0, 2), 16),
+ parseInt(value.slice(2, 4), 16),
+ parseInt(value.slice(4, 6), 16)
+ ];
+}
diff --git a/public/js/meshes.js b/public/js/meshes.js
new file mode 100644
index 0000000..46a808a
--- /dev/null
+++ b/public/js/meshes.js
@@ -0,0 +1,101 @@
+/**
+ * Mesh builders for the oracle vessel. Pure data in, pure data out: every mesh is
+ * `{ vertices: [[x, y, z]], faces: [{ indices, normal, kind, smooth }] }`, which the
+ * renderer walks generically.
+ */
+
+/** Point-up hexagon vertices (matches the CSS clip-path used elsewhere). */
+export function hexagon(radius, rotation = Math.PI / 2) {
+ return Array.from({ length: 6 }, (_, i) => {
+ const angle = rotation + (i * Math.PI) / 3;
+ return [Math.cos(angle) * radius, Math.sin(angle) * radius];
+ });
+}
+
+function centroid(vertices, indices) {
+ const sum = [0, 0, 0];
+ for (const index of indices) {
+ const v = vertices[index];
+ sum[0] += v[0];
+ sum[1] += v[1];
+ sum[2] += v[2];
+ }
+ const n = indices.length || 1;
+ return [sum[0] / n, sum[1] / n, sum[2] / n];
+}
+
+/**
+ * The outer shell: a hexagonal prism whose front cap has a hexagonal opening so the
+ * liquid chamber inside is visible through the frame.
+ */
+export function createVessel({ outerRadius = 1, depth = 0.52, windowRadius = 0.63 } = {}) {
+ const half = depth / 2;
+ const inner = hexagon(windowRadius);
+ const vertices = [];
+ const push = (x, y, z) => vertices.push([x, y, z]) - 1;
+ const points = hexagon(outerRadius);
+
+ const outerBack = points.map(([x, y]) => push(x, y, -half));
+ const outerFront = points.map(([x, y]) => push(x, y, half));
+ const innerFront = inner.map(([x, y]) => push(x, y, half));
+
+ const faces = [
+ { indices: [...outerBack].reverse(), normal: [0, 0, -1], kind: 'back', smooth: true }
+ ];
+
+ for (let i = 0; i < 6; i += 1) {
+ const j = (i + 1) % 6;
+ const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
+ faces.push({
+ indices: [outerBack[i], outerBack[j], outerFront[j], outerFront[i]],
+ normal: [Math.cos(mid), Math.sin(mid), 0],
+ kind: 'side',
+ smooth: true
+ });
+ faces.push({
+ indices: [outerFront[i], outerFront[j], innerFront[j], innerFront[i]],
+ normal: [0, 0, 1],
+ kind: 'ring',
+ smooth: false
+ });
+ }
+
+ return {
+ mesh: { vertices, faces },
+ window: { radius: windowRadius, z: half, points: inner },
+ extents: { outerRadius, depth, windowRadius }
+ };
+}
+
+/** The floating die: a flat hexagonal plate that can settle face-on to the answer. */
+export function createPlate({ radius = 0.44, thickness = 0.16 } = {}) {
+ const half = thickness / 2;
+ const points = hexagon(radius);
+ const vertices = [];
+ const push = (x, y, z) => vertices.push([x, y, z]) - 1;
+
+ const front = points.map(([x, y]) => push(x, y, half));
+ const back = points.map(([x, y]) => push(x, y, -half));
+
+ const faces = [
+ { indices: front, normal: [0, 0, 1], kind: 'answer', smooth: false },
+ { indices: [...back].reverse(), normal: [0, 0, -1], kind: 'plate-back', smooth: false }
+ ];
+
+ for (let i = 0; i < 6; i += 1) {
+ const j = (i + 1) % 6;
+ const mid = Math.PI / 2 + ((i + 0.5) * Math.PI) / 3;
+ faces.push({
+ indices: [back[i], back[j], front[j], front[i]],
+ normal: [Math.cos(mid), Math.sin(mid), 0],
+ kind: 'plate-rim',
+ smooth: false
+ });
+ }
+
+ return { mesh: { vertices, faces }, points, radius, thickness };
+}
+
+export function faceCentroid(vertices, indices) {
+ return centroid(vertices, indices);
+}
diff --git a/public/js/particles.js b/public/js/particles.js
new file mode 100644
index 0000000..eee3ca6
--- /dev/null
+++ b/public/js/particles.js
@@ -0,0 +1,104 @@
+/**
+ * Ambient particle field behind the interface.
+ *
+ * Cheap by construction: a small fixed particle count, device-pixel-ratio aware,
+ * paused whenever the tab is hidden, and drawn as plain rects so there is no
+ * per-particle path allocation.
+ */
+export class ParticleField {
+ constructor(canvas, { count = 46, tone = '#38bdf8' } = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.count = count;
+ this.tone = tone;
+ this.particles = [];
+ this.running = false;
+ this.raf = 0;
+ this.dpr = 1;
+ this.width = 0;
+ this.height = 0;
+ this.clock = 0;
+ this.last = 0;
+
+ this.resize();
+ window.addEventListener('resize', this.resize);
+ }
+
+ resize = () => {
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+ this.dpr = Math.min(window.devicePixelRatio || 1, 2);
+ this.width = width;
+ this.height = height;
+ this.canvas.width = Math.round(width * this.dpr);
+ this.canvas.height = Math.round(height * this.dpr);
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+ this.seed();
+ };
+
+ seed() {
+ this.particles = Array.from({ length: this.count }, () => ({
+ x: Math.random() * this.width,
+ y: Math.random() * this.height,
+ size: Math.random() * 1.6 + 0.5,
+ vx: (Math.random() - 0.5) * 12,
+ vy: (Math.random() - 0.5) * 12,
+ alpha: Math.random() * 0.3 + 0.08,
+ phase: Math.random() * Math.PI * 2
+ }));
+ }
+
+ setTone(tone) {
+ this.tone = tone;
+ }
+
+ start() {
+ if (this.running) return;
+ this.running = true;
+ this.last = performance.now();
+ this.raf = requestAnimationFrame(this.frame);
+ }
+
+ stop() {
+ this.running = false;
+ cancelAnimationFrame(this.raf);
+ }
+
+ frame = (now) => {
+ if (!this.running) return;
+ const dt = Math.min((now - this.last) / 1000, 0.05);
+ this.last = now;
+ if (!document.hidden) {
+ this.clock += dt;
+ this.update(dt);
+ this.draw();
+ }
+ this.raf = requestAnimationFrame(this.frame);
+ };
+
+ update(dt) {
+ for (const particle of this.particles) {
+ particle.x += particle.vx * dt;
+ particle.y += particle.vy * dt;
+ if (particle.x < -4) particle.x = this.width + 4;
+ if (particle.x > this.width + 4) particle.x = -4;
+ if (particle.y < -4) particle.y = this.height + 4;
+ if (particle.y > this.height + 4) particle.y = -4;
+ }
+ }
+
+ draw() {
+ const ctx = this.ctx;
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ ctx.fillStyle = this.tone;
+ for (const particle of this.particles) {
+ const twinkle = 0.72 + Math.sin(this.clock * 0.9 + particle.phase) * 0.28;
+ ctx.globalAlpha = particle.alpha * twinkle;
+ ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
+ }
+ ctx.globalAlpha = 1;
+ }
+}
diff --git a/server.js b/server.js
index 5d39ed3..cb5c2a3 100644
--- a/server.js
+++ b/server.js
@@ -1,20 +1,28 @@
-require('dotenv').config();
-const express = require('express');
-const cors = require('cors');
-const path = require('path');
+import 'dotenv/config';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import cors from 'cors';
+import express from 'express';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.disable('x-powered-by');
const PORT = parseInt(process.env.PORT, 10) || 3000;
-const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
+
+// The repo intentionally carries a key so a fresh clone (or deploy) works with no
+// secret configured. Delete this constant and set OPENROUTER_API_KEY to require one.
+const REPO_API_KEY = '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';
+
+const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || REPO_API_KEY;
const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
const MODEL = 'typesafe/jev-1.13';
+const LAYA_API_URL = process.env.LAYA_API_URL || 'http://192.168.0.103:8888';
-if (!OPENROUTER_API_KEY) {
- console.warn(
- '[jev-8ball] OPENROUTER_API_KEY is not set — /api/ask will answer 503. Add it to .env (see .env.example).'
- );
+if (!process.env.OPENROUTER_API_KEY) {
+ console.warn('[jev-8ball] OPENROUTER_API_KEY is not set — using the in-repo key.');
}
app.use(cors());
@@ -48,12 +56,31 @@ const APHORISMS = {
};
// GET /api/status
-app.get('/api/status', (req, res) => {
+app.get('/api/status', async (req, res) => {
+ let layaOnline = false;
+ let layaData = null;
+
+ try {
+ const layaRes = await fetch(`${LAYA_API_URL}/api/status`, {
+ signal: AbortSignal.timeout(1200)
+ });
+ if (layaRes.ok) {
+ layaData = await layaRes.json();
+ layaOnline = Boolean(layaData.status === 'online' || layaData.ready);
+ }
+ } catch (err) {
+ layaOnline = false;
+ }
+
res.json({
status: 'online',
- model: MODEL,
- engine: 'TypeSafe Jev (Non-autoregressive System One)',
- hasKey: Boolean(OPENROUTER_API_KEY)
+ model: layaOnline ? (layaData?.model || 'convaiinnovations/laya-typed-decisions') : MODEL,
+ engine: layaOnline ? 'Laya System 1 Decision Model (Android Tablet)' : 'TypeSafe Jev (Cloud Decision Engine)',
+ tablet: layaOnline,
+ layaUrl: LAYA_API_URL,
+ layaInfo: layaData,
+ hasKey: Boolean(OPENROUTER_API_KEY),
+ keySource: process.env.OPENROUTER_API_KEY ? 'env' : 'repo'
});
});
@@ -71,6 +98,44 @@ app.post('/api/ask', async (req, res) => {
const cleanQuestion = question.trim().slice(0, 300);
const startTime = performance.now();
+ // 1. Attempt inference on Android Tablet running Laya System 1
+ try {
+ const layaRes = await fetch(`${LAYA_API_URL}/api/ask`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ question: cleanQuestion }),
+ signal: AbortSignal.timeout(6500)
+ });
+
+ if (layaRes.ok) {
+ const data = await layaRes.json();
+ const latency = Math.round(performance.now() - startTime);
+ return res.json({
+ question: cleanQuestion,
+ answer: data.answer,
+ sentiment: data.sentiment,
+ noul: data.noul,
+ confidence: data.confidence,
+ probabilities: data.probabilities || {},
+ aphorismKey: data.aphorismKey || 'signs_yes',
+ latency,
+ cost: 0,
+ usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
+ model: data.model || 'convaiinnovations/laya-typed-decisions',
+ engine: 'Laya System 1 (Android Tablet ARM64)',
+ tablet: true,
+ raw: data
+ });
+ }
+ } catch (layaErr) {
+ // Graceful fallback to OpenRouter Jev cloud API
+ console.warn(`[jev-8ball] Tablet Laya not reachable (${layaErr.message}), falling back to OpenRouter Jev`);
+ }
+
+ if (!OPENROUTER_API_KEY) {
+ return res.status(503).json({ error: 'Oracle is not configured. Set OPENROUTER_API_KEY.' });
+ }
+
try {
const payload = {
model: MODEL,
@@ -223,5 +288,6 @@ app.use((req, res) => {
app.listen(PORT, () => {
console.log(`[jev-8ball] Server running at http://localhost:${PORT}`);
- console.log(`[jev-8ball] Model: ${MODEL} via OpenRouter Decisions API`);
+ console.log(`[jev-8ball] Primary Engine: Laya on Android Tablet (${LAYA_API_URL})`);
+ console.log(`[jev-8ball] Fallback Engine: ${MODEL} via OpenRouter Decisions API`);
});
diff --git a/test/frontend.test.mjs b/test/frontend.test.mjs
new file mode 100644
index 0000000..2baca22
--- /dev/null
+++ b/test/frontend.test.mjs
@@ -0,0 +1,203 @@
+/**
+ * Unit tests for the hand-rolled 3D layer. These are pure functions, so they run in
+ * Node with no browser: `npm test`.
+ */
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
+import {
+ affineFromThreePoints,
+ clamp,
+ cross,
+ dot,
+ hexToRgb,
+ mat4LookAt,
+ mat4Mul,
+ mat4Perspective,
+ mat4RotateZ,
+ mat4Translate,
+ mixRgb,
+ normalize,
+ projectToScreen,
+ rgbCss,
+ smoothstep,
+ transformDir,
+ transformPoint
+} from '../public/js/math3d.js';
+import { createPlate, createVessel, hexagon } from '../public/js/meshes.js';
+
+const near = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
+const nearVec = (a, b, tolerance = 1e-5) =>
+ a.every((value, index) => near(value, b[index], tolerance));
+
+test('vector helpers behave', () => {
+ assert.ok(nearVec(normalize([3, 0, 0]), [1, 0, 0]));
+ assert.ok(nearVec(normalize([0, 0, 0]), [0, 0, 0]));
+ assert.equal(dot([1, 2, 3], [4, -5, 6]), 12);
+ assert.ok(nearVec(cross([1, 0, 0], [0, 1, 0]), [0, 0, 1]));
+ assert.equal(clamp(5, 0, 1), 1);
+ assert.equal(smoothstep(0, 1, 0.5), 0.5);
+ assert.equal(smoothstep(0, 1, -3), 0);
+ assert.equal(smoothstep(0, 1, 3), 1);
+ assert.deepEqual(hexToRgb('#10b981'), [16, 185, 129]);
+ assert.equal(rgbCss([300, -5, 10.4]), 'rgb(255,0,10)');
+ assert.equal(rgbCss([10, 20, 30], 0.5), 'rgba(10,20,30,0.5)');
+ assert.ok(nearVec(mixRgb([0, 0, 0], [10, 20, 30], 0.5), [5, 10, 15]));
+});
+
+test('matrices compose in the documented order', () => {
+ const translated = mat4Mul(mat4Translate(1, 2, 3), mat4Translate(10, 20, 30));
+ assert.ok(nearVec(transformPoint(translated, [0, 0, 0]).slice(0, 3), [11, 22, 33]));
+
+ const rotated = mat4RotateZ(Math.PI / 2);
+ assert.ok(nearVec(transformPoint(rotated, [1, 0, 0]).slice(0, 3), [0, 1, 0], 1e-6));
+ assert.ok(nearVec(transformDir(rotated, [1, 0, 0]), [0, 1, 0], 1e-6));
+});
+
+test('camera projection keeps the vessel inside the viewport', () => {
+ const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
+ const projection = mat4Mul(mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40), view);
+ const size = 344;
+
+ const centre = projectToScreen(transformPoint(projection, [0, 0, 0]), size, size);
+ assert.ok(near(centre.x, size / 2) && near(centre.y, size / 2));
+
+ // A point on the vessel's silhouette should land at ~72% of the half-height, which
+ // is what leaves room for the shake and the cast shadow.
+ const edge = projectToScreen(transformPoint(projection, [1, 0, 0]), size, size);
+ const fraction = (edge.x - size / 2) / (size / 2);
+ assert.ok(fraction > 0.68 && fraction < 0.76, `silhouette fraction was ${fraction}`);
+
+ // Far side of the vessel must sort behind the origin for the painter's algorithm,
+ // and perspective must foreshorten the same height as depth increases.
+ const back = projectToScreen(transformPoint(projection, [0, 0, -1]), size, size);
+ assert.ok(back.depth > centre.depth, 'NDC depth must grow with distance');
+
+ const nearTop = projectToScreen(transformPoint(projection, [0, 1, 0]), size, size);
+ const farTop = projectToScreen(transformPoint(projection, [0, 1, -1]), size, size);
+ assert.ok(nearTop.y < size / 2, 'a raised point must project above the centre row');
+ assert.ok(farTop.y > nearTop.y, 'the same height must foreshorten when it is further away');
+});
+
+test('affine solve maps local points onto screen points', () => {
+ const matrix = affineFromThreePoints(
+ { local: [0, 0], screen: [10, 20] },
+ { local: [1, 0], screen: [12, 20] },
+ { local: [0, 1], screen: [10, 22] }
+ );
+ assert.ok(nearVec(matrix, [2, 0, 0, 2, 10, 20]));
+ // Degenerate (collinear) inputs must not produce a usable transform.
+ assert.equal(
+ affineFromThreePoints(
+ { local: [0, 0], screen: [0, 0] },
+ { local: [1, 1], screen: [1, 1] },
+ { local: [2, 2], screen: [2, 2] }
+ ),
+ null
+ );
+});
+
+test('hexagon vertices are point-up and evenly spaced', () => {
+ const points = hexagon(2);
+ assert.equal(points.length, 6);
+ assert.ok(nearVec(points[0], [0, 2], 1e-9));
+ for (const point of points) {
+ assert.ok(near(Math.hypot(point[0], point[1]), 2));
+ }
+ // Neighbouring vertices must be exactly one radius apart on a regular hexagon.
+ assert.ok(near(Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]), 2));
+});
+
+test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
+ const { mesh, window: opening } = createVessel();
+ assert.equal(mesh.vertices.length, 18);
+ assert.equal(mesh.faces.length, 13); // back cap + 6 sides + 6 frame pieces
+
+ for (const face of mesh.faces) {
+ assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
+ for (const index of face.indices) {
+ assert.ok(index >= 0 && index < mesh.vertices.length, 'face index out of range');
+ }
+ assert.ok(near(Math.hypot(...face.normal), 1), 'normal must be unit length');
+ }
+
+ // Side facets must face away from the axis, caps must face along ±z.
+ for (const face of mesh.faces) {
+ if (face.kind === 'side') {
+ const centre = face.indices
+ .map((index) => mesh.vertices[index])
+ .reduce((sum, vertex) => [sum[0] + vertex[0], sum[1] + vertex[1]], [0, 0]);
+ assert.ok(dot(face.normal, [centre[0], centre[1], 0]) > 0, 'side normal points inward');
+ }
+ if (face.kind === 'back') assert.deepEqual(face.normal, [0, 0, -1]);
+ if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
+ }
+
+ // The opening is smaller than the body and sits on the front plane.
+ assert.ok(opening.radius > 0 && opening.radius < 1);
+ assert.ok(opening.z > 0);
+ assert.equal(opening.points.length, 6);
+});
+
+test('plate mesh exposes an ordered answer face for text projection', () => {
+ const plate = createPlate();
+ assert.equal(plate.mesh.vertices.length, 12);
+ assert.equal(plate.mesh.faces.length, 8); // 8 faces, like the eight-ball
+
+ const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
+ assert.deepEqual(answer.normal, [0, 0, 1]);
+ assert.equal(answer.indices.length, 6);
+
+ // _drawAnswerText pairs face.points[i] with plate.points[i], so vertex order matters.
+ answer.indices.forEach((index, position) => {
+ const vertex = plate.mesh.vertices[index];
+ const point = plate.points[position];
+ assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
+ assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
+ assert.ok(near(vertex[2], plate.thickness / 2));
+ });
+});
+
+test('clipPolygonBelow keeps only the part under the surface line', () => {
+ const square = [
+ { x: 0, y: 0 },
+ { x: 10, y: 0 },
+ { x: 10, y: 10 },
+ { x: 0, y: 10 }
+ ];
+ const clipped = clipPolygonBelow(square, { x: 0, y: 5 }, { x: 10, y: 5 });
+ assert.ok(clipped.length >= 3);
+ for (const point of clipped) {
+ assert.ok(point.y >= 5 - 1e-9, `point above the surface survived: ${point.y}`);
+ }
+ let area = 0;
+ for (let i = 0; i < clipped.length; i += 1) {
+ const current = clipped[i];
+ const next = clipped[(i + 1) % clipped.length];
+ area += current.x * next.y - next.x * current.y;
+ }
+ assert.ok(near(Math.abs(area / 2), 50, 1e-6), `half the square should remain, got ${area / 2}`);
+
+ // A line entirely below the polygon leaves the polygon untouched.
+ assert.equal(clipPolygonBelow(square, { x: 0, y: 50 }, { x: 10, y: 50 }).length, 0);
+ assert.equal(clipPolygonBelow(square, { x: 0, y: -10 }, { x: 10, y: -10 }).length, 4);
+});
+
+test('wrapAnswer breaks verdicts into plate-sized lines', () => {
+ assert.deepEqual(wrapAnswer('It is certain.'), ['IT IS', 'CERTAIN.']);
+ assert.deepEqual(wrapAnswer('My sources say no.'), ['MY SOURCES', 'SAY NO.']);
+ assert.deepEqual(wrapAnswer('Very doubtful.'), ['VERY', 'DOUBTFUL.']);
+ assert.deepEqual(wrapAnswer('Concentrate and ask again.'), [
+ 'CONCENTRATE',
+ 'AND ASK',
+ 'AGAIN.'
+ ]);
+ assert.ok(wrapAnswer('').length >= 1);
+ assert.ok(wrapAnswer(' ').length >= 1);
+ const long = wrapAnswer('a really extremely unreasonably long prophecy string');
+ assert.ok(long.length <= 3, 'never more than three lines');
+ for (const line of long) {
+ assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
+ }
+});
diff --git a/test/worker.test.mjs b/test/worker.test.mjs
index 21182b1..266dcd2 100644
--- a/test/worker.test.mjs
+++ b/test/worker.test.mjs
@@ -25,17 +25,29 @@ test.afterEach(() => {
globalThis.fetch = realFetch;
});
-test('a missing key is reported instead of silently using a bundled one', async () => {
- const response = await worker.fetch(ask({ question: 'Will it deploy?' }), {}, {});
- assert.equal(response.status, 503);
- assert.match((await response.json()).error, /not configured/i);
+test('status reports where the key came from', async () => {
+ globalThis.fetch = () => {
+ throw new Error('status must not call upstream');
+ };
+ const viaRepo = await (await worker.fetch(status(), {}, {})).json();
+ assert.equal(viaRepo.hasKey, true);
+ assert.equal(viaRepo.keySource, 'repo');
+
+ const viaEnv = await (
+ await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {})
+ ).json();
+ assert.equal(viaEnv.hasKey, true);
+ assert.equal(viaEnv.keySource, 'env');
});
-test('status reports hasKey straight from the environment', async () => {
- const without = await worker.fetch(status(), {}, {});
- assert.equal((await without.json()).hasKey, false);
- const withKey = await worker.fetch(status(), { OPENROUTER_API_KEY: 'test-key' }, {});
- assert.equal((await withKey.json()).hasKey, true);
+test('a configured key always wins over the in-repo one', async () => {
+ let authorization = null;
+ globalThis.fetch = async (url, init) => {
+ authorization = init.headers.Authorization;
+ return new Response(JSON.stringify({ answers: {} }), { status: 200 });
+ };
+ await worker.fetch(ask({ question: 'Will it deploy?' }), { OPENROUTER_API_KEY: 'env-key' }, {});
+ assert.equal(authorization, 'Bearer env-key');
});
test('invalid input is rejected before any upstream call', async () => {
@@ -46,6 +58,13 @@ test('invalid input is rejected before any upstream call', async () => {
assert.equal((await worker.fetch(ask('{ not json'), env, {})).status, 400);
assert.equal((await worker.fetch(ask({ question: ' ' }), env, {})).status, 400);
assert.equal((await worker.fetch(ask({}), env, {})).status, 400);
+ // Oversized questions are trimmed rather than rejected.
+ globalThis.fetch = async () =>
+ new Response(JSON.stringify({ answers: {}, model: 'typesafe/jev-1.13' }), { status: 200 });
+ const long = await (
+ await worker.fetch(ask({ question: 'x'.repeat(900) }), env, {})
+ ).json();
+ assert.equal(long.question.length, 300);
});
test('a decisive verdict replaces a hazy aphorism', async () => {
diff --git a/worker.mjs b/worker.mjs
index 9a26cd8..90d60cb 100644
--- a/worker.mjs
+++ b/worker.mjs
@@ -1,6 +1,14 @@
const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
const MODEL = "typesafe/jev-1.13";
+// The repo intentionally carries a key so the deployed Worker works with no secret
+// configured. Delete this constant and set the OPENROUTER_API_KEY secret to require one.
+const REPO_API_KEY = "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";
+
+function resolveApiKey(env) {
+ return env.OPENROUTER_API_KEY || REPO_API_KEY || null;
+}
+
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
// Route every response through one helper so error paths cannot forget the JSON content
@@ -59,13 +67,14 @@ export default {
status: "online",
model: MODEL,
engine: "TypeSafe Jev (Cloudflare Edge Worker)",
- hasKey: Boolean(env.OPENROUTER_API_KEY)
+ hasKey: Boolean(resolveApiKey(env)),
+ keySource: env.OPENROUTER_API_KEY ? "env" : "repo"
});
}
// POST /api/ask
if (url.pathname === "/api/ask" && request.method === "POST") {
- const apiKey = env.OPENROUTER_API_KEY;
+ const apiKey = resolveApiKey(env);
if (!apiKey) {
return json({ error: "Oracle is not configured. Set the OPENROUTER_API_KEY secret." }, 503);
}
@@ -85,6 +94,39 @@ export default {
const cleanQuestion = question.trim().slice(0, 300);
const startTime = Date.now();
+ // Check if tablet Laya URL is configured in Worker environment
+ if (env.LAYA_API_URL) {
+ try {
+ const layaRes = await fetch(`${env.LAYA_API_URL}/api/ask`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ question: cleanQuestion }),
+ signal: AbortSignal.timeout(6000)
+ });
+ if (layaRes.ok) {
+ const data = await layaRes.json();
+ return json({
+ question: cleanQuestion,
+ answer: data.answer,
+ sentiment: data.sentiment,
+ noul: data.noul,
+ confidence: data.confidence,
+ probabilities: data.probabilities || {},
+ aphorismKey: data.aphorismKey || "signs_yes",
+ latency: Date.now() - startTime,
+ cost: 0,
+ usage: { prompt_tokens: data.raw?.usage?.input_tokens ?? 38, completion_tokens: 0 },
+ model: data.model || "convaiinnovations/laya-typed-decisions",
+ engine: "Laya System 1 (Tablet via Tunnel)",
+ tablet: true,
+ raw: data
+ });
+ }
+ } catch (layaErr) {
+ // Fall back to OpenRouter
+ }
+ }
+
try {
const payload = {
model: MODEL,