feat: complete Quantumancy web app — full frontend + docs

Frontend (React 18 + TS + Vite):
- Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits
- Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive),
  Ouija planchette board spelling utterances, transcript with TTS replay,
  entity dossier, direct contact streaming, passive/active listening
- Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB
  RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact
- Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete
- State: VeilSocket (reconnect/backoff), seance reducer, auth context
- 72 vitest tests green; served by FastAPI at :7777

Docs: README + as-built plans 3-7
This commit is contained in:
Indiana
2026-07-20 21:11:49 +00:00
parent 1ec9ea5863
commit 6edbbbbc2a
48 changed files with 10494 additions and 168 deletions

View File

@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next'
import type { SpiritEntity } from '../lib/types'
import { GhostGlyph } from './GhostGlyph'
export type EntityCardProps = {
entity: SpiritEntity | null
isNew: boolean
}
const RARITY_CLASS: Record<string, string> = {
common: 'rarity-common',
uncommon: 'rarity-uncommon',
rare: 'rarity-rare',
mythic: 'rarity-mythic',
}
/** Occult dossier of the currently-summoned spirit, wax-seal vibe. */
export function EntityCard({ entity, isNew }: EntityCardProps) {
const { t } = useTranslation()
if (!entity) {
return (
<aside className="entity-card empty">
<h3 className="ec-title">{t('seance.entity.title')}</h3>
<div className="ec-none">
<div className="ec-none-sigil">⟁</div>
<p>{t('seance.entity.none')}</p>
<p className="dim">{t('seance.entity.noneHint')}</p>
</div>
</aside>
)
}
return (
<aside className="entity-card">
<div className="ec-wax" aria-hidden="true">
{entity.name.slice(0, 1).toUpperCase()}
</div>
<h3 className="ec-title">{t('seance.entity.title')}</h3>
<div className="ec-head">
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={64} />
<div>
<div className="ec-name glitch-hover">{entity.name}</div>
<div className="ec-epithet">{entity.epithet}</div>
<span className={`rarity-badge ${RARITY_CLASS[entity.rarity] ?? ''}`}>
{t(`seance.entity.rarity.${entity.rarity}`)}
</span>
</div>
</div>
<div className="ec-contact">
{isNew ? t('seance.entity.isNew') : t('seance.entity.known', { count: entity.contact_count })}
</div>
<div className="ec-section">
<h4>{t('seance.entity.persona')}</h4>
<p className="ec-persona">{entity.persona}</p>
</div>
{entity.quotes.length > 0 && (
<div className="ec-section">
<h4>{t('seance.entity.quotes')}</h4>
<ul className="ec-quotes">
{entity.quotes.slice(0, 4).map((q, i) => (
<li key={i}>“{q}”</li>
))}
</ul>
</div>
)}
<div className="ec-section">
<h4>{t('seance.entity.voice')}</h4>
<div className="ec-voice">
<span>id {entity.voice.voice_id}</span>
<span>pitch {entity.voice.pitch.toFixed(2)}</span>
<span>rate {entity.voice.rate.toFixed(2)}</span>
<span>noise {entity.voice.noise.toFixed(2)}</span>
<span>echo {entity.voice.echo.toFixed(2)}</span>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,63 @@
// Mini ghost glyph: an SVG spirit silhouette tinted by visual.hue,
// with small per-form variations (fairy motes, banshee veil, shade dark).
import type { GhostForm } from '../lib/types'
export type GhostGlyphProps = {
hue: number
form: GhostForm
size?: number
className?: string
}
export function GhostGlyph({ hue, form, size = 48, className }: GhostGlyphProps) {
const body = `hsl(${hue} 75% ${form === 'shade' ? 22 : 62}%)`
const glow = `hsl(${hue} 90% 72%)`
return (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
className={`ghost-glyph ${className ?? ''}`}
role="img"
aria-hidden="true"
>
<defs>
<radialGradient id={`gg-${hue}-${form}`} cx="50%" cy="35%" r="70%">
<stop offset="0%" stopColor={glow} stopOpacity="0.95" />
<stop offset="100%" stopColor={body} stopOpacity={form === 'shade' ? 0.9 : 0.25} />
</radialGradient>
</defs>
{form === 'banshee' && (
<path
d="M22 30 C14 44 12 54 10 62 M42 30 C50 44 52 54 54 62"
stroke={body}
strokeWidth="2.5"
fill="none"
opacity="0.55"
/>
)}
<path
d={
form === 'fairy'
? 'M32 8 C22 8 18 18 18 28 C18 40 22 50 24 56 C27 52 29 54 32 56 C35 54 37 52 40 56 C42 50 46 40 46 28 C46 18 42 8 32 8 Z'
: 'M32 6 C21 6 16 17 16 29 C16 42 21 52 24 58 C27 54 29 56 32 58 C35 56 37 54 40 58 C43 52 48 42 48 29 C48 17 43 6 32 6 Z'
}
fill={`url(#gg-${hue}-${form})`}
stroke={glow}
strokeWidth="1"
opacity={form === 'shade' ? 0.95 : 0.9}
/>
<circle cx="26" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
<circle cx="38" cy="27" r="2.2" fill={form === 'shade' ? glow : '#0a0a12'} />
{form === 'fairy' && (
<>
<circle cx="12" cy="16" r="1.6" fill={glow} />
<circle cx="52" cy="12" r="1.3" fill={glow} />
<circle cx="55" cy="40" r="1.8" fill={glow} />
<circle cx="9" cy="44" r="1.2" fill={glow} />
</>
)}
</svg>
)
}

View File

@@ -0,0 +1,315 @@
// Gothic Ouija board rendered on canvas 2D: arc of letters, numbers,
// YES / NO / GOODBYE, and a planchette (triangle with lens) that drifts
// idly with a smoke-particle trail, then glides letter-by-letter to spell
// queued words (driven by a PlanchetteMachine from src/lib/planchette.ts).
import { useEffect, useRef } from 'react'
import { PlanchetteMachine } from '../lib/planchette'
type Point = { x: number; y: number }
type Layout = {
w: number
h: number
letters: Map<string, Point>
numbers: Map<string, Point>
yes: Point
no: Point
goodbye: Point
center: Point
}
function computeLayout(w: number, h: number): Layout {
const letters = new Map<string, Point>()
const numbers = new Map<string, Point>()
const cx = w / 2
const cy = h * 0.52
const arc = (chars: string, radius: number, yOff: number) => {
const span = Math.PI * 0.78
const start = Math.PI + (Math.PI - span) / 2
for (let i = 0; i < chars.length; i++) {
const a = start + (span * i) / (chars.length - 1)
letters.set(chars[i], {
x: cx + Math.cos(a) * radius,
y: cy + yOff + Math.sin(a) * radius * 0.72,
})
}
}
arc('ABCDEFGHIJKLM', Math.min(w * 0.4, h * 0.52), 0)
arc('NOPQRSTUVWXYZ', Math.min(w * 0.4, h * 0.52) * 0.74, h * 0.06)
const row = '1234567890'
const spacing = Math.min(w * 0.07, 46)
for (let i = 0; i < row.length; i++) {
numbers.set(row[i], {
x: cx + (i - (row.length - 1) / 2) * spacing,
y: h * 0.74,
})
}
return {
w,
h,
letters,
numbers,
yes: { x: w * 0.18, y: h * 0.13 },
no: { x: w * 0.82, y: h * 0.13 },
goodbye: { x: cx, y: h * 0.9 },
center: { x: cx, y: h * 0.45 },
}
}
type Smoke = { x: number; y: number; vx: number; vy: number; life: number; size: number }
/** Cheap layered-sine wander, perlin-ish enough for idle drift. */
function wander(t: number, seed: number): number {
return (
Math.sin(t * 0.31 + seed) * 0.5 +
Math.sin(t * 0.73 + seed * 1.7) * 0.3 +
Math.sin(t * 1.31 + seed * 2.3) * 0.2
)
}
export type PlanchetteBoardProps = {
machine: PlanchetteMachine
/** Tint hue (degrees) for glow accents. */
hue?: number
}
export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const machineRef = useRef(machine)
machineRef.current = machine
const hueRef = useRef(hue)
hueRef.current = hue
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let raf = 0
let last = performance.now()
let layout: Layout | null = null
const pos: Point = { x: 0, y: 0 }
let initialized = false
const smoke: Smoke[] = []
const resize = () => {
const parent = canvas.parentElement
if (!parent) return
const dpr = Math.min(window.devicePixelRatio, 2)
const w = parent.clientWidth
const h = parent.clientHeight
if (w === 0 || h === 0) return
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
layout = computeLayout(w, h)
if (!initialized) {
pos.x = layout.center.x
pos.y = layout.center.y
initialized = true
}
}
resize()
const ro = new ResizeObserver(resize)
if (canvas.parentElement) ro.observe(canvas.parentElement)
const letterTarget = (ch: string): Point | null => {
if (!layout) return null
if (layout.letters.has(ch)) return layout.letters.get(ch)!
if (layout.numbers.has(ch)) return layout.numbers.get(ch)!
return null
}
const draw = (now: number) => {
raf = requestAnimationFrame(draw)
const dt = Math.min(64, now - last)
last = now
if (!layout) return
const { w, h } = layout
const snap = machineRef.current.tick(dt)
const hueDeg = hueRef.current
const accent = `hsl(${hueDeg} 90% 72%)`
const t = now / 1000
// --- target: spelled letter, else idle wander ---
let target: Point | null = null
if (snap.current) target = letterTarget(snap.current)
if (!target) {
target = {
x: layout.center.x + wander(t, 1.3) * w * 0.16,
y: layout.center.y + wander(t * 0.83, 4.1) * h * 0.14,
}
}
const speed = snap.current ? 0.16 : 0.03
pos.x += (target.x - pos.x) * speed
pos.y += (target.y - pos.y) * speed
// --- smoke trail ---
const moving = Math.abs(target.x - pos.x) + Math.abs(target.y - pos.y) > 2
if ((moving || snap.current) && smoke.length < 220) {
smoke.push({
x: pos.x + (Math.random() - 0.5) * 8,
y: pos.y + (Math.random() - 0.5) * 8,
vx: (Math.random() - 0.5) * 0.3,
vy: -0.25 - Math.random() * 0.4,
life: 1,
size: 3 + Math.random() * 7,
})
}
for (let i = smoke.length - 1; i >= 0; i--) {
const s = smoke[i]
s.x += s.vx * dt * 0.06
s.y += s.vy * dt * 0.06
s.life -= dt * 0.0006
if (s.life <= 0) smoke.splice(i, 1)
}
// ================= draw =================
ctx.clearRect(0, 0, w, h)
// Board panel.
const grad = ctx.createRadialGradient(w / 2, h * 0.45, 20, w / 2, h * 0.45, Math.max(w, h) * 0.7)
grad.addColorStop(0, 'rgba(20, 18, 34, 0.85)')
grad.addColorStop(1, 'rgba(7, 7, 13, 0.35)')
ctx.fillStyle = grad
roundRect(ctx, w * 0.03, h * 0.04, w * 0.94, h * 0.92, 18)
ctx.fill()
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.25)`
ctx.lineWidth = 1
ctx.stroke()
// Corner ornaments.
ctx.strokeStyle = `hsla(${hueDeg} 70% 60% / 0.35)`
for (const [ox, oy] of [
[w * 0.05, h * 0.07],
[w * 0.95, h * 0.07],
[w * 0.05, h * 0.93],
[w * 0.95, h * 0.93],
] as Array<[number, number]>) {
ctx.beginPath()
ctx.arc(ox, oy, 6, 0, Math.PI * 2)
ctx.stroke()
}
// YES / NO / GOODBYE.
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.9)`
ctx.font = `600 ${Math.max(13, h * 0.035)}px "IBM Plex Mono", monospace`
ctx.fillText('YES', layout.yes.x, layout.yes.y)
ctx.fillText('NO', layout.no.x, layout.no.y)
ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace`
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)`
ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
// Letters + numbers.
const hovered = snap.current
ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
for (const [ch, p] of layout.letters) {
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
}
ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
for (const [ch, p] of layout.numbers) {
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
}
// Smoke (additive).
ctx.globalCompositeOperation = 'lighter'
for (const s of smoke) {
ctx.beginPath()
ctx.fillStyle = `hsla(${hueDeg} 80% 70% / ${0.08 * s.life})`
ctx.arc(s.x, s.y, s.size * (1.6 - s.life * 0.6), 0, Math.PI * 2)
ctx.fill()
}
ctx.globalCompositeOperation = 'source-over'
// --- planchette: triangle with lens ---
ctx.save()
ctx.translate(pos.x, pos.y)
const tilt = Math.sin(t * 0.9) * 0.04 + (target.x - pos.x) * 0.0006
ctx.rotate(tilt)
const R = Math.max(26, h * 0.085)
ctx.beginPath()
ctx.moveTo(0, -R)
ctx.lineTo(R * 0.85, R * 0.65)
ctx.lineTo(-R * 0.85, R * 0.65)
ctx.closePath()
ctx.fillStyle = 'rgba(14, 12, 24, 0.82)'
ctx.fill()
ctx.strokeStyle = accent
ctx.lineWidth = 1.6
ctx.shadowColor = accent
ctx.shadowBlur = snap.current ? 18 : 8
ctx.stroke()
ctx.shadowBlur = 0
// Lens (window the letter shows through).
ctx.beginPath()
ctx.arc(0, R * 0.08, R * 0.3, 0, Math.PI * 2)
ctx.fillStyle = `hsla(${hueDeg} 90% 75% / ${snap.current ? 0.3 : 0.1})`
ctx.fill()
ctx.strokeStyle = `hsla(${hueDeg} 90% 80% / 0.8)`
ctx.lineWidth = 1
ctx.stroke()
ctx.restore()
}
raf = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
return (
<div className="planchette-board" style={{ position: 'relative', width: '100%', height: '100%' }}>
<canvas ref={canvasRef} />
</div>
)
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
function drawGlyph(
ctx: CanvasRenderingContext2D,
ch: string,
p: Point,
hovered: boolean,
accent: string,
hueDeg: number,
): void {
if (hovered) {
ctx.save()
ctx.shadowColor = accent
ctx.shadowBlur = 16
ctx.fillStyle = accent
ctx.fillText(ch, p.x, p.y)
ctx.restore()
ctx.beginPath()
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)`
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2)
ctx.stroke()
} else {
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`
ctx.fillText(ch, p.x, p.y)
}
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import type { Telemetry } from '../lib/types'
/** Flickering terminal readout of live wire telemetry. */
export function TelemetryReadout({ telemetry }: { telemetry: Telemetry | null }) {
const { t } = useTranslation()
const rows: Array<[string, string]> = telemetry
? [
[t('seance.telemetry.jitter'), `${telemetry.jitter_bytes_per_s.toFixed(1)} ${t('seance.telemetry.units.bytes')}`],
[t('seance.telemetry.latencyVar'), `${telemetry.latency_variance_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
[t('seance.telemetry.latencyMean'), `${telemetry.latency_mean_ms.toFixed(2)} ${t('seance.telemetry.units.ms')}`],
[t('seance.telemetry.dns'), `${telemetry.dns_ms.toFixed(1)} ${t('seance.telemetry.units.ms')}`],
]
: []
return (
<div className="telemetry">
<h4 className="telemetry-title">{t('seance.telemetry.title')}</h4>
{rows.length === 0 && <div className="telemetry-row dim">— — —</div>}
{rows.map(([label, value]) => (
<div className="telemetry-row flicker" key={label}>
<span className="telemetry-label">{label}</span>
<span className="telemetry-value">{value}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { TranscriptEntry } from '../state/seance'
export type TranscriptProps = {
entries: TranscriptEntry[]
streaming: { active: boolean; text: string }
speakingId: string | null
onReplay: (utteranceId: string) => void
}
function fmtFreq(source: string, f: number): string {
// EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
if (source === 'evp') return `${f.toFixed(0)}Hz`
if (source === 'radio') return `${f.toFixed(1)}MHz`
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
}
/** Terminal-style scrolling transmission log. */
export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) {
const { t } = useTranslation()
const boxRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const box = boxRef.current
if (box) box.scrollTop = box.scrollHeight
}, [entries.length, streaming.text])
return (
<div className="transcript" ref={boxRef} aria-live="polite">
{entries.length === 0 && !streaming.active && (
<div className="tx-row tx-system dim">{t('seance.transcriptEmpty')}</div>
)}
{entries.map((e) => {
switch (e.kind) {
case 'anomaly':
return (
<div key={e.id} className="tx-row tx-anomaly">
<span className="tx-glyph">⚡</span>
<span className="tx-src">{e.source}</span>{' '}
<span className="tx-freq">{fmtFreq(e.source, e.frequency)}</span>{' '}
<span className="tx-mag">+{e.magnitude.toFixed(1)}dB</span>
</div>
)
case 'question':
return (
<div key={e.id} className="tx-row tx-question">
<span className="tx-speaker">{t('seance.you')}</span>
<span className="tx-text"> {e.text}</span>
</div>
)
case 'system':
return (
<div key={e.id} className="tx-row tx-system">
{e.text}
</div>
)
case 'utterance': {
const speaking = speakingId === e.utteranceId
return (
<div key={e.id} className={`tx-row tx-utterance kind-${e.utKind} ${speaking ? 'speaking' : ''}`}>
<span className="tx-speaker">{e.speaker ?? '???'}</span>
<span className="tx-text"> “{e.text}”</span>
{e.audioUrl && (
<button
className="tx-replay"
title={t('common.replay')}
onClick={() => onReplay(e.utteranceId)}
>
{speaking ? '🔊' : '▶'}
</button>
)}
</div>
)
}
}
})}
{streaming.active && (
<div className="tx-row tx-streaming">
<span className="tx-stream-label">{t('seance.streaming')}</span>
<span className="tx-text smoke-text">{streaming.text}</span>
<span className="tx-cursor">▌</span>
</div>
)}
</div>
)
}