diff --git a/functions/api/ask.js b/functions/api/ask.js index d552f32..6c338e7 100644 --- a/functions/api/ask.js +++ b/functions/api/ask.js @@ -1,6 +1,14 @@ const JEV_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions"; const MODEL = "typesafe/jev-1.13"; +// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set. +// Delete this constant and set OPENROUTER_API_KEY to require configuration. +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": "*" }; // One response helper so error paths cannot forget the JSON content type or the CORS @@ -39,7 +47,7 @@ const APHORISMS = { export async function onRequestPost(context) { const { request, env } = context; - 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); } @@ -59,6 +67,38 @@ export async function onRequestPost(context) { const cleanQuestion = question.trim().slice(0, 300); const startTime = Date.now(); + 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 (e) { + // Fall back to OpenRouter + } + } + try { const payload = { model: MODEL, diff --git a/functions/api/status.js b/functions/api/status.js index aa46771..295e239 100644 --- a/functions/api/status.js +++ b/functions/api/status.js @@ -1,10 +1,29 @@ +// Mirrors worker.mjs: the repo carries a key so a deploy works with no secret set. +// Delete this constant and set OPENROUTER_API_KEY to require configuration. +const REPO_API_KEY = "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"; + export async function onRequestGet(context) { const { env } = context; + let layaOnline = false; + let layaData = null; + + if (env.LAYA_API_URL) { + try { + const res = await fetch(`${env.LAYA_API_URL}/api/status`, { signal: AbortSignal.timeout(1500) }); + if (res.ok) { + layaData = await res.json(); + layaOnline = true; + } + } catch (e) {} + } + return new Response(JSON.stringify({ status: "online", - model: "typesafe/jev-1.13", - engine: "TypeSafe Jev (Cloudflare Pages Edge)", - hasKey: Boolean(env.OPENROUTER_API_KEY) + model: layaOnline ? (layaData?.model || "convaiinnovations/laya-typed-decisions") : "typesafe/jev-1.13", + engine: layaOnline ? "Laya System 1 (Tablet via Tunnel)" : "TypeSafe Jev (Cloudflare Pages Edge)", + tablet: layaOnline, + hasKey: Boolean(env.OPENROUTER_API_KEY || REPO_API_KEY), + keySource: env.OPENROUTER_API_KEY ? "env" : "repo" }), { status: 200, headers: { diff --git a/package.json b/package.json index 921d81b..bb9ec47 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test": "node --test" }, "keywords": [], - "type": "commonjs", + "type": "module", "dependencies": { "cors": "^2.8.6", "dotenv": "^18.0.1", diff --git a/public/css/style.css b/public/css/style.css index 3f4b933..1359681 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,112 +1,192 @@ -/* ─── RESET & BRUTALIST GEOMETRY ─── */ -* { +/* ══════════════════════════════════════════════════════════════ + jev8ball — brutalist oracle interface + Squared geometry, monospace type, one accent per verdict. + ══════════════════════════════════════════════════════════════ */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; - border-radius: 0 !important; /* Strict squared-out geometry */ - scrollbar-width: none !important; - -ms-overflow-style: none !important; + border-radius: 0 !important; + scrollbar-width: none; + -ms-overflow-style: none; } -::-webkit-scrollbar { - display: none; -} +::-webkit-scrollbar { display: none; } :root { --bg: #0b0b0b; + --bg-deep: #050505; --panel-bg: #0e0e0e; --border: #1c1c1c; --border-hover: #333333; - --border-focus: #555555; + --border-focus: #4a4a4a; --text: #e5e5e5; - --text-muted: #888888; - --text-dim: #555555; + --text-muted: #8a8a8a; + --text-dim: #565656; --accent: #38bdf8; --green: #10b981; --red: #ef4444; --amber: #f59e0b; + --grid: rgba(255, 255, 255, 0.022); --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --step-1: 4px; + --step-2: 8px; + --step-3: 12px; + --step-4: 18px; + --step-5: 28px; + --step-6: 40px; } +html { -webkit-text-size-adjust: 100%; } + body { font-family: var(--font-mono); + font-size: 15px; + line-height: 1.5; background: var(--bg); color: var(--text); min-height: 100vh; - position: relative; overflow-x: hidden; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -/* ─── PARTICLES BACKGROUND ─── */ -#particles { +/* Faint engineering grid behind everything. */ +body::before { + content: ''; position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: 0; + background-image: + linear-gradient(var(--grid) 1px, transparent 1px), + linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 44px 44px; pointer-events: none; z-index: 0; } -/* ─── CONTAINER ─── */ +/* ─── utilities ─── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + top: -60px; + left: var(--step-3); + z-index: 200; + padding: var(--step-2) var(--step-3); + background: var(--panel-bg); + border: 1px solid var(--border-focus); + color: var(--text); + font-size: 0.75rem; + text-decoration: none; + transition: top 0.15s ease; +} +.skip-link:focus { top: var(--step-3); } + +kbd { + font-family: inherit; + font-size: 0.9em; + padding: 1px 4px; + border: 1px solid var(--border); + background: #101010; + color: var(--text-muted); +} + +:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +#particles { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; + opacity: 0.9; +} + .container { position: relative; z-index: 1; - max-width: 620px; - margin: 45px auto 60px; - padding: 0 20px; + max-width: 640px; + margin: 0 auto; + padding: var(--step-6) var(--step-4) var(--step-6); } -/* ─── HEADER ─── */ +/* ─── header ─── */ .header { text-align: center; - margin-bottom: 25px; + margin-bottom: var(--step-5); } .top-nav { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; + gap: var(--step-2); + margin-bottom: var(--step-3); font-size: 0.7rem; } .brand-tag { color: var(--text-dim); text-transform: lowercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; +} + +.nav-actions { + display: flex; + gap: var(--step-1); } .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-dim); + font-family: inherit; font-size: 0.68rem; padding: 3px 8px; cursor: pointer; - transition: all 0.15s ease; + transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; } .btn-ghost:hover { border-color: var(--border-hover); color: var(--text); + background: #101010; +} +.btn-ghost[aria-pressed='true'] { + color: var(--accent); + border-color: rgba(56, 189, 248, 0.4); } h1 { - font-size: 2.2rem; + font-size: clamp(1.9rem, 8vw, 2.5rem); font-weight: 600; - letter-spacing: -0.04em; + letter-spacing: -0.045em; color: #fff; - margin-bottom: 4px; + line-height: 1.05; + margin-bottom: var(--step-1); } .subtitle-row { display: flex; align-items: center; justify-content: center; - gap: 8px; - font-size: 0.78rem; - letter-spacing: 0.04em; + gap: var(--step-2); + font-size: 0.76rem; + letter-spacing: 0.03em; + flex-wrap: wrap; } .subtitle { @@ -114,348 +194,161 @@ h1 { text-transform: lowercase; } -.status-sep { - color: #262626; -} +.status-sep { color: #262626; } .backend-status { display: inline-flex; align-items: center; - gap: 5px; - color: var(--text-dim); + gap: 6px; + background: transparent; + border: 1px solid transparent; + padding: 2px 6px; + font-family: inherit; font-size: 0.72rem; + color: var(--text-dim); cursor: pointer; - user-select: none; - transition: color 0.15s ease; + transition: color 0.15s ease, border-color 0.15s ease; } .backend-status:hover { color: var(--text-muted); + border-color: var(--border); } .status-dot { width: 6px; height: 6px; - background: #333333; + background: #333; display: inline-block; transition: background-color 0.2s ease, box-shadow 0.2s ease; } .backend-status.online .status-dot { background: var(--green); - box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); -} -.backend-status.online .status-text { - color: var(--text-muted); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.45); } +.backend-status.online .status-text { color: var(--text-muted); } +.backend-status.degraded .status-dot { background: var(--amber); } +.backend-status.offline .status-dot { background: var(--red); } -/* ─── HEXAGONAL 3D FIGURE STAGE & VESSEL ─── */ +/* ─── oracle stage ─── */ .ball-stage { - display: flex; - justify-content: center; - align-items: center; - margin: 14px 0 32px; - perspective: 1000px; -} - -.ball-wrapper { - position: relative; display: flex; flex-direction: column; align-items: center; - cursor: pointer; - perspective: 1000px; + gap: var(--step-3); + margin-bottom: var(--step-5); } -/* Hexagonal 3D Prism Figure */ -.hex-figure-3d { - width: 210px; - height: 210px; +.ball-frame { position: relative; - transform-style: preserve-3d; - transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1); - margin-bottom: 6px; + width: min(86vw, 344px); + aspect-ratio: 1 / 1; + border: 1px solid var(--border); + background: + radial-gradient(circle at 50% 42%, rgba(56, 189, 248, 0.05) 0%, transparent 62%), + var(--bg-deep); + transition: border-color 0.25s ease; } +.ball-frame:hover { border-color: var(--border-hover); } -/* Hex Prism Cap - Front / Back */ -.hex-cap { - position: absolute; - inset: 0; - clip-path: polygon(50% 0%, 93.3% 25%, 93.3% 75%, 50% 100%, 6.7% 75%, 6.7% 25%); -} - -.hex-cap-top { - transform: translateZ(-28px); - background: radial-gradient(circle at 50% 50%, #171717 0%, #0c0c0c 60%, #000 100%); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.9); -} - -.hex-cap-front { - transform: translateZ(28px); - background: radial-gradient(circle at 35% 30%, #2a2a2a 0%, #151515 45%, #0a0a0a 75%, #020202 100%); - border: 1px solid rgba(255, 255, 255, 0.15); - box-shadow: - 0 15px 35px rgba(0, 0, 0, 0.9), - inset 0 2px 4px rgba(255, 255, 255, 0.2), - inset 0 -6px 14px rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; -} - -/* 6 Hexagonal Prism Facet Edges around the sides */ -.hex-facet { - position: absolute; - top: 50%; - left: 50%; - width: 105px; - height: 56px; - margin-left: -52.5px; - margin-top: -28px; - background: linear-gradient(to bottom, #222, #0d0d0d 60%, #050505); - border-top: 1px solid rgba(255, 255, 255, 0.16); - border-bottom: 1px solid rgba(0, 0, 0, 0.8); - box-sizing: border-box; - opacity: 0.92; - pointer-events: none; -} - -.hex-facet-0 { transform: rotateZ(0deg) rotateX(90deg) translateZ(90.9px); } -.hex-facet-1 { transform: rotateZ(60deg) rotateX(90deg) translateZ(90.9px); filter: brightness(0.85); } -.hex-facet-2 { transform: rotateZ(120deg) rotateX(90deg) translateZ(90.9px); filter: brightness(0.65); } -.hex-facet-3 { transform: rotateZ(180deg) rotateX(90deg) translateZ(90.9px); filter: brightness(0.5); } -.hex-facet-4 { transform: rotateZ(240deg) rotateX(90deg) translateZ(90.9px); filter: brightness(0.7); } -.hex-facet-5 { transform: rotateZ(300deg) rotateX(90deg) translateZ(90.9px); filter: brightness(0.9); } - -/* Hex Fluid Viewport Window */ -.fluid-window { - width: 142px; - height: 142px; - clip-path: polygon(50% 0%, 93.3% 25%, 93.3% 75%, 50% 100%, 6.7% 75%, 6.7% 25%); - background: radial-gradient(circle, #08142a 0%, #040916 65%, #010308 100%); - border: 1px solid rgba(56, 189, 248, 0.15); - box-shadow: inset 0 4px 22px rgba(0, 0, 0, 0.95), 0 0 14px rgba(0, 0, 0, 0.8); - position: relative; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; -} - -.fluid-ambient { - position: absolute; - inset: 0; - background: radial-gradient(circle at 50% 30%, rgba(56, 189, 248, 0.12) 0%, transparent 70%); - pointer-events: none; -} - -/* Floating Hexagonal Die Plate */ -.floating-die { - position: relative; - width: 120px; - height: 120px; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.6s ease, filter 0.6s ease; - animation: dieFloat 4.2s ease-in-out infinite; -} - -@keyframes dieFloat { - 0%, 100% { transform: translateY(0) rotate(0deg); } - 25% { transform: translateY(-3px) rotate(-1.5deg); } - 50% { transform: translateY(2px) rotate(1deg); } - 75% { transform: translateY(-2px) rotate(-0.5deg); } -} - -.die-svg { - position: absolute; +.oracle-canvas { + display: block; width: 100%; height: 100%; -} -.die-face { - fill: #0c1c38; - stroke: #1e3a6a; - stroke-width: 2; -} -.die-inner { - fill: #08142a; - stroke: rgba(56, 189, 248, 0.2); - stroke-width: 1; -} -.die-accent-line { - stroke: rgba(56, 189, 248, 0.3); - stroke-width: 1; - stroke-dasharray: 2 2; + touch-action: manipulation; } -.die-text-wrap { - position: relative; - z-index: 2; - width: 82px; - text-align: center; - margin-top: 0; +/* Transparent hit target: keeps the canvas purely presentational while giving + keyboard and touch users a proper button. */ +.roll-trigger { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: transparent; + border: 0; + cursor: pointer; +} +.roll-trigger:disabled { cursor: progress; } + +.frame-corner { + position: absolute; + width: 10px; + height: 10px; + border: 1px solid rgba(56, 189, 248, 0.35); + pointer-events: none; +} +.frame-corner-tl { top: -1px; left: -1px; border-right: 0; border-bottom: 0; } +.frame-corner-tr { top: -1px; right: -1px; border-left: 0; border-bottom: 0; } +.frame-corner-bl { bottom: -1px; left: -1px; border-right: 0; border-top: 0; } +.frame-corner-br { bottom: -1px; right: -1px; border-left: 0; border-top: 0; } + +.stage-hint { + font-size: 0.68rem; + color: var(--text-dim); + letter-spacing: 0.05em; + text-transform: lowercase; } -.die-text { - font-size: 0.62rem; - font-weight: 700; - letter-spacing: 0.04em; - color: #bae6fd; - text-transform: uppercase; - line-height: 1.28; - display: inline-block; - text-shadow: 0 0 8px rgba(56, 189, 248, 0.45); - transition: opacity 0.4s ease, transform 0.4s ease; -} - -/* Submerged state */ -.floating-die.submerged { - transform: scale(0.28) translateY(24px) rotate(35deg) !important; - opacity: 0; - filter: blur(5px); - animation: none; -} - -/* Surfacing state */ -.floating-die.surfacing { - animation: dieSurface 1.1s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; -} -@keyframes dieSurface { - 0% { transform: scale(0.35) translateY(24px) rotate(-30deg); opacity: 0; filter: blur(5px); } - 60% { transform: scale(1.08) translateY(-2px) rotate(2deg); opacity: 0.95; filter: blur(0); } - 100% { transform: scale(1) translateY(0) rotate(0deg); opacity: 1; filter: blur(0); } -} - -/* ─── 3D ROLL-IN-PLACE ANIMATION ─── */ -.hex-figure-3d.rolling { - animation: hexRollInPlace 1.25s cubic-bezier(0.45, 0.05, 0.25, 1) forwards; -} - -@keyframes hexRollInPlace { - 0% { - transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg) scale(1); - } - 15% { - transform: rotateX(70deg) rotateY(-80deg) rotateZ(-45deg) translateY(-8px) scale(1.04); - } - 35% { - transform: rotateX(190deg) rotateY(160deg) rotateZ(120deg) translateY(-14px) scale(1.08); - } - 55% { - transform: rotateX(310deg) rotateY(380deg) rotateZ(260deg) translateY(-8px) scale(1.05); - } - 75% { - transform: rotateX(375deg) rotateY(560deg) rotateZ(350deg) translateY(-2px) scale(1.01); - } - 90% { - transform: rotateX(358deg) rotateY(724deg) rotateZ(362deg) translateY(0px) scale(0.99); - } - 100% { - transform: rotateX(360deg) rotateY(720deg) rotateZ(360deg) translateY(0px) scale(1); - } -} - -/* Synchronized Shadow Expansion / Compression during Roll */ -.ball-shadow { - width: 180px; - height: 22px; - border-radius: 50% !important; - background: radial-gradient(ellipse, rgba(0, 0, 0, 0.75) 0%, transparent 70%); - margin-top: 8px; - transition: transform 0.2s ease, opacity 0.2s ease; -} - -.ball-shadow.rolling-shadow { - animation: shadowRollSync 1.25s cubic-bezier(0.45, 0.05, 0.25, 1) forwards; -} - -@keyframes shadowRollSync { - 0%, 100% { - transform: scale(1); - opacity: 0.75; - } - 35% { - transform: scale(0.72); - opacity: 0.35; - } - 55% { - transform: scale(0.85); - opacity: 0.5; - } - 85% { - transform: scale(1.05); - opacity: 0.85; - } -} - -/* ─── PROMPT FORM ─── */ -.prompt-section { - margin-bottom: 24px; -} - -.prompt-form { - margin-bottom: 12px; -} +/* ─── prompt ─── */ +.prompt-section { margin-bottom: var(--step-5); } .prompt-input-wrap { display: flex; - align-items: center; - background: #080808; + align-items: stretch; border: 1px solid var(--border); + background: var(--panel-bg); transition: border-color 0.15s ease; } -.prompt-input-wrap:focus-within { - border-color: var(--border-focus); -} +.prompt-input-wrap:focus-within { border-color: var(--border-focus); } .prompt-symbol { - padding-left: 12px; - color: var(--text-dim); + display: flex; + align-items: center; + padding: 0 var(--step-2) 0 var(--step-3); + color: var(--accent); font-size: 0.85rem; - font-weight: 700; + user-select: none; } .prompt-input { flex: 1; + min-width: 0; background: transparent; - border: none; + border: 0; color: #fff; - font-size: 0.82rem; - padding: 12px 10px; + font-family: inherit; + font-size: 0.85rem; + padding: var(--step-3) 0; outline: none; } -.prompt-input::placeholder { - color: #444444; -} +.prompt-input::placeholder { color: #3f3f3f; } +.prompt-input:disabled { opacity: 0.5; } .btn { - background: #141414; - border: 1px solid var(--border); - color: var(--text); - font-size: 0.75rem; - font-weight: 600; - padding: 0 16px; - height: 42px; - cursor: pointer; display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - transition: all 0.15s ease; - white-space: nowrap; + gap: var(--step-2); + min-width: 104px; + border: 1px solid var(--border); + background: #141414; + color: var(--text-muted); + font-family: inherit; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: lowercase; + padding: var(--step-3) var(--step-3); + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; } .btn:hover:not(:disabled) { - background: #1c1c1c; + background: #1d1d1d; border-color: var(--border-hover); color: #fff; } -.btn:active:not(:disabled) { - background: #242424; -} -.btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn-ask:not(:disabled) { color: var(--text); } .spinner { width: 12px; @@ -466,69 +359,76 @@ h1 { animation: spin 0.6s linear infinite; display: none; } -@keyframes spin { - to { transform: rotate(360deg); } +body[data-busy='true'] .spinner { display: inline-block; } +body[data-busy='true'] #btnText { display: none; } +@keyframes spin { to { transform: rotate(360deg); } } + +.input-hint { + margin-top: var(--step-2); + font-size: 0.66rem; + color: var(--text-dim); + letter-spacing: 0.02em; } -/* Quick Prompts */ .quick-prompts { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + margin-top: var(--step-3); font-size: 0.7rem; } -.quick-label { - color: var(--text-dim); -} +.quick-label { color: var(--text-dim); } .quick-pill { background: transparent; border: 1px solid var(--border); color: var(--text-muted); + font-family: inherit; font-size: 0.68rem; padding: 3px 8px; cursor: pointer; - transition: all 0.12s ease; -} -.quick-pill:hover { - border-color: var(--border-hover); - color: #fff; + transition: color 0.12s ease, border-color 0.12s ease; } +.quick-pill:hover { border-color: var(--border-hover); color: #fff; } +.quick-pill:disabled { opacity: 0.4; cursor: not-allowed; } -/* ─── TELEMETRY CARD ─── */ +/* ─── telemetry ─── */ .telemetry-card { background: var(--panel-bg); border: 1px solid var(--border); - margin-bottom: 28px; - transition: border-color 0.15s ease; -} -.telemetry-card:hover { - border-color: var(--border-hover); + margin-bottom: var(--step-5); + transition: border-color 0.2s ease; } +.telemetry-card[data-tone='affirmative'] { border-color: rgba(16, 185, 129, 0.35); } +.telemetry-card[data-tone='negative'] { border-color: rgba(239, 68, 68, 0.35); } +.telemetry-card[data-tone='neutral'] { border-color: rgba(56, 189, 248, 0.3); } +.telemetry-card[data-tone='offline'] { border-color: var(--border-hover); } .telemetry-header { display: flex; justify-content: space-between; align-items: center; - padding: 8px 12px; + padding: var(--step-2) var(--step-3); border-bottom: 1px solid var(--border); background: #080808; } .telemetry-title { - font-size: 0.7rem; + font-size: 0.68rem; font-weight: 700; color: var(--text-dim); - letter-spacing: 0.03em; + letter-spacing: 0.06em; } .telemetry-badge { - font-size: 0.65rem; + font-size: 0.62rem; font-weight: 700; + letter-spacing: 0.06em; padding: 2px 6px; background: #141414; border: 1px solid var(--border); color: var(--text-muted); + transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease; } .telemetry-badge.affirmative { color: var(--green); @@ -550,44 +450,52 @@ h1 { border-color: var(--text-dim); background: rgba(255, 255, 255, 0.04); } - -.telemetry-body { - padding: 12px; +.telemetry-badge.error { + color: var(--amber); + border-color: var(--amber); + background: rgba(245, 158, 11, 0.08); } +.telemetry-body { padding: var(--step-4) var(--step-3) var(--step-3); } + +.verdict { + font-size: 1.06rem; + font-weight: 600; + line-height: 1.35; + color: #fff; + margin-bottom: var(--step-4); + padding-left: var(--step-3); + border-left: 2px solid var(--border-hover); + transition: border-color 0.25s ease; +} +.telemetry-card[data-tone='affirmative'] .verdict { border-left-color: var(--green); } +.telemetry-card[data-tone='negative'] .verdict { border-left-color: var(--red); } +.telemetry-card[data-tone='neutral'] .verdict { border-left-color: var(--accent); } +.telemetry-card[data-tone='offline'] .verdict { border-left-color: var(--text-dim); color: var(--text-muted); } + .telemetry-row { display: flex; justify-content: space-between; - align-items: center; + align-items: baseline; font-size: 0.75rem; margin-bottom: 6px; } -.t-label { - color: var(--text-dim); - font-size: 0.68rem; - font-weight: 600; -} -.t-val { - color: #fff; - font-weight: 600; -} -.t-val.mono { - font-variant-numeric: tabular-nums; -} +.t-label { color: var(--text-dim); font-size: 0.66rem; font-weight: 600; letter-spacing: 0.04em; } +.t-val { color: #fff; font-weight: 600; } +.t-val.mono { font-variant-numeric: tabular-nums; } .telemetry-meter-bar { width: 100%; height: 4px; - background: #141414; - margin-bottom: 14px; - position: relative; + background: #161616; + margin-bottom: var(--step-4); overflow: hidden; } .meter-fill { height: 100%; width: 0%; background: var(--text-dim); - transition: width 0.35s ease, background 0.35s ease; + transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.35s ease; } .meter-fill.affirmative { background: var(--green); } .meter-fill.neutral { background: var(--accent); } @@ -596,112 +504,88 @@ h1 { .telemetry-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 8px; - padding-top: 10px; - border-top: 1px solid #141414; - margin-bottom: 10px; -} -@media (min-width: 480px) { - .telemetry-grid { - grid-template-columns: repeat(4, 1fr); - } + gap: var(--step-3); + padding-top: var(--step-3); + border-top: 1px solid #161616; } +@media (min-width: 480px) { .telemetry-grid { grid-template-columns: repeat(4, 1fr); } } -.t-cell { - display: flex; - flex-direction: column; - gap: 2px; -} +.t-cell { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .t-cell-label { - font-size: 0.62rem; + font-size: 0.6rem; color: var(--text-dim); text-transform: uppercase; + letter-spacing: 0.06em; } .t-cell-val { - font-size: 0.75rem; + font-size: 0.78rem; font-weight: 600; color: #fff; font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; } -.telemetry-details { - font-size: 0.68rem; - margin-top: 6px; -} +.telemetry-details { font-size: 0.68rem; margin-top: var(--step-3); } .details-summary { color: var(--text-dim); cursor: pointer; - outline: none; user-select: none; + list-style: none; } -.details-summary:hover { - color: var(--text-muted); -} +.details-summary::-webkit-details-marker { display: none; } +.details-summary:hover { color: var(--text-muted); } .json-dump { - margin-top: 8px; - padding: 8px; - background: #050505; + margin-top: var(--step-2); + padding: var(--step-2); + background: var(--bg-deep); border: 1px solid #161616; - color: #888; - font-size: 0.65rem; - max-height: 140px; - overflow-y: auto; - line-height: 1.4; + color: #7c7c7c; + font-family: inherit; + font-size: 0.64rem; + line-height: 1.45; + max-height: 200px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; } -/* ─── HISTORY LIST ─── */ -.history-section { - margin-bottom: 40px; -} +/* ─── history ─── */ +.history-section { margin-bottom: var(--step-5); } .history-header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 12px; + align-items: baseline; + margin-bottom: var(--step-3); border-bottom: 1px solid var(--border); padding-bottom: 6px; } - .history-header h2 { - font-size: 0.85rem; + font-size: 0.82rem; font-weight: 600; text-transform: lowercase; color: #fff; + letter-spacing: 0.03em; } +.history-count { font-size: 0.68rem; color: var(--text-dim); } -.history-count { - font-size: 0.7rem; - color: var(--text-dim); -} - -.history-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 6px; -} +.history-list { list-style: none; display: flex; flex-direction: column; gap: 6px; } .history-item { background: #0a0a0a; border: 1px solid var(--border); - padding: 10px 12px; + padding: var(--step-2) var(--step-3); display: flex; justify-content: space-between; align-items: center; - gap: 12px; - transition: border-color 0.12s ease; -} -.history-item:hover { - border-color: var(--border-hover); + gap: var(--step-3); + cursor: pointer; + transition: border-color 0.12s ease, background-color 0.12s ease; } +.history-item:hover { border-color: var(--border-hover); background: #0d0d0d; } -.history-left { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; -} +.history-left { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .history-q { font-size: 0.78rem; color: #fff; @@ -709,13 +593,10 @@ h1 { text-overflow: ellipsis; white-space: nowrap; } -.history-time { - font-size: 0.65rem; - color: var(--text-dim); -} +.history-time { font-size: 0.62rem; color: var(--text-dim); } .history-pill { - font-size: 0.7rem; + font-size: 0.66rem; font-weight: 600; padding: 3px 8px; border: 1px solid var(--border); @@ -723,37 +604,82 @@ h1 { color: var(--text-muted); white-space: nowrap; flex-shrink: 0; + max-width: 46%; + overflow: hidden; + text-overflow: ellipsis; } -.history-pill.affirmative { - color: var(--green); - border-color: rgba(16, 185, 129, 0.4); -} -.history-pill.neutral { - color: var(--accent); - border-color: rgba(56, 189, 248, 0.4); -} -.history-pill.negative { - color: var(--red); - border-color: rgba(239, 68, 68, 0.4); -} +.history-pill.affirmative { color: var(--green); border-color: rgba(16, 185, 129, 0.4); } +.history-pill.neutral { color: var(--accent); border-color: rgba(56, 189, 248, 0.4); } +.history-pill.negative { color: var(--red); border-color: rgba(239, 68, 68, 0.4); } -/* ─── TOAST NOTIFICATIONS ─── */ +.history-empty { + font-size: 0.7rem; + color: var(--text-dim); + padding: var(--step-3) 0; +} +.history-empty[hidden] { display: none; } + +/* ─── footer ─── */ +.footer { + display: flex; + flex-direction: column; + gap: var(--step-1); + border-top: 1px solid var(--border); + padding-top: var(--step-3); + font-size: 0.66rem; + color: var(--text-dim); + letter-spacing: 0.02em; +} +.footer-note { color: #3d3d3d; } + +/* ─── toast ─── */ .toast { position: fixed; - bottom: 24px; + bottom: var(--step-5); left: 50%; - transform: translateX(-50%) translateY(20px); - background: #141414; + transform: translateX(-50%) translateY(14px); + max-width: min(90vw, 420px); + background: #161616; border: 1px solid var(--border-focus); color: #fff; - font-size: 0.75rem; - padding: 8px 16px; + font-size: 0.74rem; + padding: var(--step-2) var(--step-4); opacity: 0; pointer-events: none; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 100; + transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 150; } -.toast.active { - opacity: 1; - transform: translateX(-50%) translateY(0); +.toast.active { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast[data-kind='error'] { border-color: rgba(239, 68, 68, 0.5); } +.toast[data-kind='warn'] { border-color: rgba(245, 158, 11, 0.5); } + +/* ─── responsive ─── */ +@media (max-width: 420px) { + .container { padding: var(--step-5) var(--step-3) var(--step-5); } + .quick-prompts { font-size: 0.66rem; } + .verdict { font-size: 0.98rem; } + .btn { min-width: 88px; padding: var(--step-3) var(--step-2); } + .input-hint { font-size: 0.62rem; } +} + +@media (min-width: 720px) { + .header { margin-bottom: var(--step-6); } + .ball-stage { margin-bottom: var(--step-6); } +} + +/* ─── reduced motion ─── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + .spinner { animation: spin 0.9s linear infinite !important; } +} +body[data-motion='reduced'] #particles { opacity: 0.35; } + +/* ─── forced colours ─── */ +@media (forced-colors: active) { + .ball-frame, .telemetry-card, .prompt-input-wrap, .history-item { border: 1px solid CanvasText; } + .frame-corner { display: none; } } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d629837 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + 8 + diff --git a/public/index.html b/public/index.html index 81adac7..6f21303 100644 --- a/public/index.html +++ b/public/index.html @@ -2,177 +2,161 @@ - - jev8ball // neural oracle - + + jev8ball // neural decision oracle + + - + + + + + + + + - - + +
-
oracle.sys - +

jev8ball

// type-safe neural decision oracle - • -
- - typesafe/jev-1.13 -
+ +
- -
-
- -
- -
- -
-
-
-
-
-
- -
-
-
- -
- - - - - - - - - - - - - -
- CONCENTRATE
& ASK
-
-
-
-
-
-
- -
+ +
+
+ + + + + +
+

click the vessel to shake it

- +
-
+ +
- > + + aria-describedby="inputHint" + >
+

0/300 · / to focus · enter to ask

-
suggest: - - - - + + + +
- +
// JEV DECISION TELEMETRY IDLE
-
+

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…
- +

query log

0 queries
-
    - -
+
    +

    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,