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,61 @@
// Auth context: who is seated at the table. Wraps the /auth REST endpoints
// and exposes { user, checking, login, register, logout } to the app.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import * as api from '../api'
import type { User } from '../api'
export type AuthApi = {
user: User | null
checking: boolean
login: (username: string, password: string) => Promise<User>
register: (username: string, password: string) => Promise<User>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthApi | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
api
.me()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setChecking(false))
}, [])
const login = useCallback(async (username: string, password: string) => {
const u = await api.login(username, password)
setUser(u)
return u
}, [])
const register = useCallback(
async (username: string, password: string) => {
await api.register(username, password)
return login(username, password)
},
[login],
)
const logout = useCallback(async () => {
await api.logout()
setUser(null)
}, [])
const value = useMemo<AuthApi>(
() => ({ user, checking, login, register, logout }),
[user, checking, login, register, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthApi {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest'
import { initialSeanceState, seanceReducer } from './seance'
import type { SeanceState } from './seance'
import type { ServerFrame, SpiritEntity, Telemetry } from '../lib/types'
const AT = 1_700_000_000_000
const entity: SpiritEntity = {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
persona: 'A soft-spoken drowned girl.',
rarity: 'rare',
voice: { voice_id: 'v-1', pitch: 0.8, rate: 0.9, noise: 0.3, echo: 0.6 },
visual: { hue: 210, form: 'wisp' },
quotes: ['the water remembers'],
contact_count: 4,
discovered_at: '2026-01-01T00:00:00.000Z',
}
function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState {
return seanceReducer(state, { type: 'frame', frame: f, at })
}
function lastRow(state: SeanceState) {
return state.transcript[state.transcript.length - 1]
}
describe('seanceReducer', () => {
it('stores the session id on a session frame', () => {
const next = frame(initialSeanceState, { type: 'session', id: 's-1' })
expect(next.sessionId).toBe('s-1')
expect(initialSeanceState.sessionId).toBeNull() // reducer is pure
})
it('sets the entity, clears status and logs a system row on entity frames', () => {
let state = frame(initialSeanceState, { type: 'status', state: 'summoning' })
expect(state.status).toBe('summoning')
state = frame(state, { type: 'entity', is_new: true, entity })
expect(state.entity).toEqual(entity)
expect(state.entityIsNew).toBe(true)
expect(state.status).toBeNull()
const row = lastRow(state)
expect(row.kind).toBe('system')
if (row.kind === 'system') {
expect(row.text).toBe('⟁ Mirela — the Drowned')
}
})
it('pairs an audio frame with its utterance in both the store and the transcript', () => {
let state = frame(initialSeanceState, {
type: 'utterance',
id: 'u-1',
kind: 'greeting',
text: 'we are here',
entity: 'Mirela',
})
expect(state.utterances).toHaveLength(1)
expect(state.utterances[0]).toMatchObject({
id: 'u-1',
kind: 'greeting',
text: 'we are here',
entity: 'Mirela',
audioUrl: null,
})
expect(lastRow(state)).toMatchObject({
kind: 'utterance',
utteranceId: 'u-1',
speaker: 'Mirela',
utKind: 'greeting',
audioUrl: null,
})
state = frame(state, { type: 'audio', id: 'u-1', url: '/audio/u-1.mp3' })
expect(state.utterances[0].audioUrl).toBe('/audio/u-1.mp3')
expect(lastRow(state)).toMatchObject({
kind: 'utterance',
utteranceId: 'u-1',
audioUrl: '/audio/u-1.mp3',
})
})
it('ignores audio frames for unknown utterances', () => {
const state = frame(initialSeanceState, { type: 'audio', id: 'ghost', url: '/audio/x.mp3' })
expect(state.utterances).toEqual([])
expect(state.transcript).toEqual([])
})
it('assembles a streaming reply and lands it as a reply utterance', () => {
let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity })
state = frame(state, { type: 'reply_start' })
expect(state.replyStreaming).toEqual({ active: true, text: '' })
state = frame(state, { type: 'reply_token', token: 'the veil ' })
state = frame(state, { type: 'reply_token', token: 'is thin' })
expect(state.replyStreaming).toEqual({ active: true, text: 'the veil is thin' })
expect(state.utterances).toEqual([]) // nothing committed while streaming
state = frame(state, { type: 'reply_end', id: 'u-9', text: 'the veil is thin' })
expect(state.replyStreaming).toEqual({ active: false, text: '' })
expect(state.utterances).toHaveLength(1)
expect(state.utterances[0]).toMatchObject({
id: 'u-9',
kind: 'reply',
text: 'the veil is thin',
entity: 'Mirela',
audioUrl: null,
})
expect(lastRow(state)).toMatchObject({
kind: 'utterance',
utteranceId: 'u-9',
utKind: 'reply',
text: 'the veil is thin',
})
})
it('falls back to a null speaker for replies without a summoned entity', () => {
let state = frame(initialSeanceState, { type: 'reply_start' })
state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' })
expect(state.utterances[0].entity).toBeNull()
})
it('stores telemetry values', () => {
const telemetry: Telemetry = {
jitter_bytes_per_s: 12.5,
latency_variance_ms: 3.2,
latency_mean_ms: 41.5,
dns_ms: 7,
}
const state = frame(initialSeanceState, { type: 'telemetry', ...telemetry })
expect(state.telemetry).toEqual(telemetry)
})
it('appends error toasts and caps them at four, dropping the oldest', () => {
let state = frame(initialSeanceState, {
type: 'error',
code: 'rate_limited',
message: 'slow down, medium',
})
expect(state.toasts).toHaveLength(1)
expect(state.toasts[0]).toMatchObject({ code: 'rate_limited', message: 'slow down, medium' })
for (let i = 0; i < 5; i++) {
state = frame(state, { type: 'error', code: `e${i}`, message: `m${i}` })
}
expect(state.toasts).toHaveLength(4)
expect(state.toasts.map((t) => t.code)).toEqual(['e1', 'e2', 'e3', 'e4'])
})
it('records local anomalies in both the anomalies list and the transcript', () => {
const state = seanceReducer(initialSeanceState, {
type: 'local_anomaly',
source: 'evp',
frequency: 1171.9,
magnitude: 12.3,
at: AT,
})
expect(state.anomalies).toHaveLength(1)
expect(state.anomalies[0]).toMatchObject({
source: 'evp',
frequency: 1171.9,
magnitude: 12.3,
at: AT,
})
expect(lastRow(state)).toMatchObject({
kind: 'anomaly',
source: 'evp',
frequency: 1171.9,
magnitude: 12.3,
})
// The server-side count only moves when anomaly_ack arrives.
expect(state.anomalyCount).toBe(0)
})
it('appends local questions to the transcript', () => {
const state = seanceReducer(initialSeanceState, {
type: 'local_question',
text: 'who are you',
at: AT,
})
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'who are you', at: AT })
})
it('tracks the server anomaly count via anomaly_ack', () => {
const state = frame(initialSeanceState, { type: 'anomaly_ack', count: 7 })
expect(state.anomalyCount).toBe(7)
})
it('caps the transcript at 400 entries, dropping the oldest', () => {
let state = initialSeanceState
for (let i = 1; i <= 410; i++) {
state = seanceReducer(state, { type: 'local_question', text: `q${i}`, at: AT + i })
}
expect(state.transcript).toHaveLength(400)
expect(state.transcript[0]).toMatchObject({ kind: 'question', text: 'q11' })
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'q410' })
})
it('caps the utterance store at 200 entries', () => {
let state = initialSeanceState
for (let i = 1; i <= 205; i++) {
state = frame(
state,
{ type: 'utterance', id: `u${i}`, kind: 'ambient', text: `t${i}`, entity: null },
AT + i,
)
}
expect(state.utterances).toHaveLength(200)
expect(state.utterances[0].id).toBe('u6')
expect(state.utterances[199].id).toBe('u205')
})
it('applies connection/mode/language/passive/speaking setters', () => {
let state = seanceReducer(initialSeanceState, { type: 'connection', state: 'open' })
expect(state.connection).toBe('open')
state = seanceReducer(state, { type: 'set_mode', mode: 'ouija' })
expect(state.mode).toBe('ouija')
state = seanceReducer(state, { type: 'set_language', language: 'es' })
expect(state.language).toBe('es')
state = seanceReducer(state, { type: 'set_passive', enabled: true })
expect(state.passive).toBe(true)
state = seanceReducer(state, { type: 'set_speaking', utteranceId: 'u-1' })
expect(state.speakingId).toBe('u-1')
})
it('applies mode and status frames, and ignores pong', () => {
let state = frame(initialSeanceState, { type: 'mode', mode: 'radio' })
expect(state.mode).toBe('radio')
state = frame(state, { type: 'status', state: 'gathering' })
expect(state.status).toBe('gathering')
const afterPong = frame(state, { type: 'pong' })
expect(afterPong).toBe(state) // same reference: pong is a no-op
})
it('dismisses toasts by id', () => {
let state = frame(initialSeanceState, { type: 'error', code: 'a', message: 'a' })
state = frame(state, { type: 'error', code: 'b', message: 'b' })
const victim = state.toasts[0].id
state = seanceReducer(state, { type: 'dismiss_toast', id: victim })
expect(state.toasts).toHaveLength(1)
expect(state.toasts[0].code).toBe('b')
})
it('reset returns to the initial state', () => {
let state = frame(initialSeanceState, { type: 'session', id: 's-1' })
state = frame(state, { type: 'entity', is_new: true, entity })
state = frame(state, { type: 'error', code: 'x', message: 'x' })
state = seanceReducer(state, { type: 'local_question', text: 'hi', at: AT })
expect(state).not.toEqual(initialSeanceState)
state = seanceReducer(state, { type: 'reset' })
expect(state).toEqual(initialSeanceState)
})
})

View File

@@ -0,0 +1,466 @@
// Séance state: React context + reducer holding the whole session model,
// plus a provider that wires the VeilSocket and the spirit-audio queue to it.
// The reducer is pure and unit-tested; side effects live in the provider.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
} from 'react'
import type { ReactNode } from 'react'
import { VeilSocket } from '../lib/ws'
import type { VeilConnectionState } from '../lib/ws'
import { SpiritAudioPlayer } from '../lib/audio'
import type {
Language,
Mode,
ServerFrame,
SessionStatus,
SpiritEntity,
Telemetry,
UtteranceKind,
} from '../lib/types'
export type Utterance = {
id: string
kind: UtteranceKind
text: string
entity: string | null
audioUrl: string | null
at: number
}
export type AnomalyEntry = {
id: number
source: 'radio' | 'evp' | 'wire'
frequency: number
magnitude: number
at: number
}
export type TranscriptEntry =
| { id: number; kind: 'anomaly'; source: string; frequency: number; magnitude: number; at: number }
| { id: number; kind: 'utterance'; utteranceId: string; speaker: string | null; utKind: UtteranceKind; text: string; audioUrl: string | null; at: number }
| { id: number; kind: 'question'; text: string; at: number }
| { id: number; kind: 'system'; text: string; at: number }
export type Toast = { id: number; code: string; message: string; at: number }
export type SeanceState = {
connection: VeilConnectionState
sessionId: string | null
mode: Mode
passive: boolean
language: Language
status: SessionStatus | null
entity: SpiritEntity | null
entityIsNew: boolean
anomalyCount: number
utterances: Utterance[]
anomalies: AnomalyEntry[]
transcript: TranscriptEntry[]
replyStreaming: { active: boolean; text: string }
telemetry: Telemetry | null
toasts: Toast[]
speakingId: string | null
}
export const initialSeanceState: SeanceState = {
connection: 'closed',
sessionId: null,
mode: 'wire',
passive: false,
language: 'en',
status: null,
entity: null,
entityIsNew: false,
anomalyCount: 0,
utterances: [],
anomalies: [],
transcript: [],
replyStreaming: { active: false, text: '' },
telemetry: null,
toasts: [],
speakingId: null,
}
// ---- Actions ----
let idSeq = 1
const nextId = () => idSeq++
const TRANSCRIPT_CAP = 400
const UTTERANCE_CAP = 200
const ANOMALY_CAP = 200
const TOAST_CAP = 4
export type SeanceAction =
| { type: 'connection'; state: VeilConnectionState }
| { type: 'frame'; frame: ServerFrame; at?: number }
| { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number; at?: number }
| { type: 'local_question'; text: string; at?: number }
| { type: 'set_mode'; mode: Mode }
| { type: 'set_language'; language: Language }
| { type: 'set_passive'; enabled: boolean }
| { type: 'set_speaking'; utteranceId: string | null }
| { type: 'dismiss_toast'; id: number }
| { type: 'reset' }
function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
const out = arr.length >= cap ? arr.slice(arr.length - cap + 1) : [...arr]
out.push(item)
return out
}
function withToast(state: SeanceState, toast: Toast): SeanceState {
const toasts = state.toasts.length >= TOAST_CAP ? state.toasts.slice(1) : [...state.toasts]
toasts.push(toast)
return { ...state, toasts }
}
export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceState {
const at = ('at' in action && action.at) || Date.now()
switch (action.type) {
case 'connection':
return { ...state, connection: action.state }
case 'local_anomaly': {
const entry: AnomalyEntry = {
id: nextId(),
source: action.source,
frequency: action.frequency,
magnitude: action.magnitude,
at,
}
return {
...state,
anomalies: pushCapped(state.anomalies, entry, ANOMALY_CAP),
transcript: pushCapped(
state.transcript,
{
id: entry.id,
kind: 'anomaly',
source: action.source,
frequency: action.frequency,
magnitude: action.magnitude,
at,
},
TRANSCRIPT_CAP,
),
}
}
case 'local_question':
return {
...state,
transcript: pushCapped(
state.transcript,
{ id: nextId(), kind: 'question', text: action.text, at },
TRANSCRIPT_CAP,
),
}
case 'set_mode':
return { ...state, mode: action.mode }
case 'set_language':
return { ...state, language: action.language }
case 'set_passive':
return { ...state, passive: action.enabled }
case 'set_speaking':
return { ...state, speakingId: action.utteranceId }
case 'dismiss_toast':
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }
case 'reset':
return { ...initialSeanceState }
case 'frame': {
const frame = action.frame
switch (frame.type) {
case 'session':
return { ...state, sessionId: frame.id }
case 'pong':
return state
case 'mode':
return { ...state, mode: frame.mode }
case 'status':
return { ...state, status: frame.state }
case 'entity': {
return {
...state,
entity: frame.entity,
entityIsNew: frame.is_new,
status: null,
transcript: pushCapped(
state.transcript,
{
id: nextId(),
kind: 'system',
text: `⟁ ${frame.entity.name} — ${frame.entity.epithet}`,
at,
},
TRANSCRIPT_CAP,
),
}
}
case 'anomaly_ack':
return { ...state, anomalyCount: frame.count }
case 'utterance': {
const ut: Utterance = {
id: frame.id,
kind: frame.kind,
text: frame.text,
entity: frame.entity,
audioUrl: null,
at,
}
return {
...state,
status: null,
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
transcript: pushCapped(
state.transcript,
{
id: nextId(),
kind: 'utterance',
utteranceId: ut.id,
speaker: ut.entity,
utKind: ut.kind,
text: ut.text,
audioUrl: null,
at,
},
TRANSCRIPT_CAP,
),
}
}
case 'audio': {
// Pair the audio URL with its utterance (both the store and the
// transcript row gain the replay button).
return {
...state,
utterances: state.utterances.map((u) =>
u.id === frame.id ? { ...u, audioUrl: frame.url } : u,
),
transcript: state.transcript.map((t) =>
t.kind === 'utterance' && t.utteranceId === frame.id
? { ...t, audioUrl: frame.url }
: t,
),
}
}
case 'reply_start':
return { ...state, replyStreaming: { active: true, text: '' } }
case 'reply_token':
return {
...state,
replyStreaming: {
active: true,
text: state.replyStreaming.text + frame.token,
},
}
case 'reply_end': {
const ut: Utterance = {
id: frame.id,
kind: 'reply',
text: frame.text,
entity: state.entity?.name ?? null,
audioUrl: null,
at,
}
return {
...state,
status: null,
replyStreaming: { active: false, text: '' },
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
transcript: pushCapped(
state.transcript,
{
id: nextId(),
kind: 'utterance',
utteranceId: ut.id,
speaker: ut.entity,
utKind: 'reply',
text: ut.text,
audioUrl: null,
at,
},
TRANSCRIPT_CAP,
),
}
}
case 'telemetry':
return {
...state,
telemetry: {
jitter_bytes_per_s: frame.jitter_bytes_per_s,
latency_variance_ms: frame.latency_variance_ms,
latency_mean_ms: frame.latency_mean_ms,
dns_ms: frame.dns_ms,
},
}
case 'error':
return withToast(state, {
id: nextId(),
code: frame.code,
message: frame.message,
at,
})
default:
return state
}
}
default:
return state
}
}
// ---- Provider ----
export type SeanceApi = {
state: SeanceState
socket: VeilSocket
audioPlayer: SpiritAudioPlayer
setMode: (mode: Mode) => void
setPassive: (enabled: boolean) => void
setLanguage: (language: Language) => void
summon: () => void
ask: (text: string) => void
sendAnomaly: (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => void
playUtterance: (utteranceId: string) => void
dismissToast: (id: number) => void
}
const SeanceContext = createContext<SeanceApi | null>(null)
export function SeanceProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
const socketRef = useRef<VeilSocket | null>(null)
const playerRef = useRef<SpiritAudioPlayer | null>(null)
const stateRef = useRef(state)
stateRef.current = state
if (!socketRef.current) {
socketRef.current = new VeilSocket()
playerRef.current = new SpiritAudioPlayer()
}
useEffect(() => {
const socket = socketRef.current!
const player = playerRef.current!
const offFrame = socket.onFrame((frame) => {
dispatch({ type: 'frame', frame, at: Date.now() })
if (frame.type === 'audio') {
// Auto-play spirit voice on arrival (queued behind anything playing).
player.enqueue(frame.id, frame.url)
}
})
const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
player.setCallbacks({
onStart: (info) => dispatch({ type: 'set_speaking', utteranceId: info.utteranceId }),
onEnd: () => dispatch({ type: 'set_speaking', utteranceId: null }),
})
socket.connect()
return () => {
offFrame()
offState()
socket.close()
player.clear()
}
}, [])
const setMode = useCallback((mode: Mode) => {
dispatch({ type: 'set_mode', mode })
socketRef.current?.send({ type: 'set_mode', mode })
}, [])
const setPassive = useCallback((enabled: boolean) => {
dispatch({ type: 'set_passive', enabled })
socketRef.current?.send({ type: 'passive', enabled })
}, [])
const setLanguage = useCallback((language: Language) => {
dispatch({ type: 'set_language', language })
socketRef.current?.send({ type: 'language', language })
}, [])
const summon = useCallback(() => {
socketRef.current?.send({ type: 'summon' })
}, [])
const ask = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed) return
dispatch({ type: 'local_question', text: trimmed, at: Date.now() })
socketRef.current?.send({ type: 'question', text: trimmed })
}, [])
const sendAnomaly = useCallback(
(source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => {
dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() })
socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude })
},
[],
)
const playUtterance = useCallback((utteranceId: string) => {
// Replay: re-queue through the same single-queue player.
const ut = stateRef.current.utterances.find((u) => u.id === utteranceId)
if (ut?.audioUrl) playerRef.current?.enqueue(ut.id, ut.audioUrl)
}, [])
const dismissToast = useCallback((id: number) => {
dispatch({ type: 'dismiss_toast', id })
}, [])
const api = useMemo<SeanceApi>(
() => ({
state,
socket: socketRef.current!,
audioPlayer: playerRef.current!,
setMode,
setPassive,
setLanguage,
summon,
ask,
sendAnomaly,
playUtterance,
dismissToast,
}),
[state, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast],
)
return <SeanceContext.Provider value={api}>{children}</SeanceContext.Provider>
}
export function useSeance(): SeanceApi {
const ctx = useContext(SeanceContext)
if (!ctx) throw new Error('useSeance must be used inside <SeanceProvider>')
return ctx
}