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

312
README.md Normal file
View File

@@ -0,0 +1,312 @@
# QUANTUMANCY
*an instrument for speaking with the dead bandwidth*
Quantumancy is a self-hosted, gothic-hacker web experience that lets visitors
**talk to spirits** through four channels, each grounded in a real paranormal-
investigation technique or a real data source. Real anomalies — RF power
spikes, voice-band blips, network jitter — are detected in genuine sensor and
telemetry streams, and a locally-run LLM gives the presence behind them a
voice. A local Piper TTS then renders that voice through a static-choked
spirit-box effects chain.
**This is an interactive horror art installation.** The spirits are fiction;
the static is real. Every "entity" is a persona generated by a language model
on your own hardware. Nothing leaves your network: no cloud APIs, no
third-party inference, no telemetry of ours — only yours.
## The Four Modes
| Mode | Vessel | What actually happens |
|---|---|---|
| **Spirit Radio** | RTL-SDR dongle (WebUSB, Chromium only) | The browser sweeps the FM band (88–108 MHz) computing FFT power per bin; spikes above the rolling noise floor become anomaly events that summon one-word fragments, Ovilus-style. |
| **EVP Listening** | Microphone (`getUserMedia`) | A Web Audio `AnalyserNode` watches the voice band (~300 Hz–3.4 kHz) for brief deviations ≥ 8 dB above the room's rolling silence — the classic "record quiet, review for voices" technique. |
| **The Wire Ghost** | Nothing — works for everyone | The backend samples real, non-content network telemetry from the host (interface throughput jitter, TCP connect latency variance, DNS hesitation) and a fragmented consciousness whispers about it every few seconds. Packet payloads are never inspected — a hard privacy boundary. |
| **Ouija / Direct Contact** | The shared front door | A canvas planchette drifts, then spells the spirit's words letter by letter. Ask a free-text question and the conversational model streams a full reply token by token while the planchette works. |
Every session begins unidentified. The backend fingerprints the session's
anomaly pattern into a **signature**; a matching signature re-contacts an
existing spirit, a new one gets **minted into the Codex** — a publicly
browsable registry of every spirit ever contacted, shared across all users,
with name, epithet, rarity tier, persona lore, sample quotes, voice/visual
profiles, and contact counts.
## Architecture
Two machines on the LAN, no containers anywhere:
```
┌────────────────────────────────────────────┐
seekers ──HTTPS──▶ Cloudflare Tunnel ──HTTP──▶ App CT :7777 │
(external machine, FastAPI (uvicorn, │
terminates TLS) Python venv, │
systemd service) │
│ │
├─ serves built │
│ React SPA │
├─ Postgres (apt) │
├─ Piper TTS + FX │
└───────┬──────────┘
│ LAN
┌───────────▼─────────┐
│ Ollama box │
│ 10.30.20.107:11434 │
│ CPU-only, 64 GB │
│ fast + chat models │
└─────────────────────┘
```
- **App CT** — plain HTTP on port 7777. FastAPI (async, WebSocket-native)
serves the built Vite/React SPA as static assets, the auth + Codex REST
API, the `/ws/session` séance WebSocket, and `/audio/*` spirit-voice WAVs.
Postgres holds accounts, sessions, transcripts (events), and the Codex.
Piper TTS is invoked locally per utterance, then degraded through a numpy
effects chain (rate → pitch → bitcrush → echo → static).
- **Ollama box** — reachable over LAN via Ollama's REST API. Two model tiers:
a **fast** model for fragments/ambient whispers/… and a heavier **chat**
model for Direct Contact and entity minting. CPU-only and shared, so all
LLM calls flow through a bounded-concurrency queue (`LLMQueue`); queued
requests render in the UI as "the spirits are gathering energy…".
- **Cloudflare Tunnel** — managed outside this repo; terminates HTTPS and
points at `http://<app-ct-ip>:7777`. The app never handles TLS. The
tunnel's HTTPS origin satisfies browser secure-context requirements for
mic and WebUSB.
### Repo layout
```
backend/
app/
main.py FastAPI app: routers, /healthz, /assets + /audio mounts, SPA fallback
config.py pydantic-settings (env vars below)
db.py async SQLAlchemy 2.0 engine/session (asyncpg)
deps.py get_current_user, qm_session cookie
security.py argon2 password hashing
rate_limit.py fixed-window RateLimiter (per-user LLM limits)
telemetry.py Wire Ghost sampler (/proc/net/dev, TCP RTT, DNS timing)
entities.py anomaly signatures, profile normalization, procedural fallback
ws.py the séance channel: /ws/session protocol + ambient loop
llm/ OllamaClient, bounded LLMQueue, SpiritService, prompt builders
tts/ Piper CLI wrapper, voice catalog, numpy effects chain
models/ users, auth_sessions, contact_sessions, entities,
entity_sightings, events
routes/ /auth/* and /api/codex, /api/stats
tests/ pytest suite (45 tests)
voices/ Piper .onnx voice models (gitignored — see setup)
data/ generated utterance audio, served at /audio/ (gitignored)
deploy/
quantumancy.service systemd unit template
frontend/
src/lib/ types (WS protocol), VeilSocket, audio player, evp, sdr,
fft, planchette machine
src/state/ AuthProvider, SeanceProvider (reducer + socket wiring)
src/components/ PlanchetteBoard, Transcript, EntityCard, GhostGlyph, …
src/three/ GhostCanvas + GhostScene (custom-shader 3D spirit)
src/i18n/ react-i18next init + en.json / es.json
docs/superpowers/
specs/ the design spec
plans/ the 7 implementation plans this repo was built from
```
## Quickstart
Prereqs on the App CT: Python 3.11+, Node 18+, Postgres (via `apt`), and an
Ollama box on the LAN with the two model tiers pulled.
**1. Postgres** (one-time):
```bash
sudo apt-get update && sudo apt-get install -y postgresql
sudo -u postgres psql -c "CREATE ROLE quantumancy WITH LOGIN PASSWORD 'quantumancy';"
sudo -u postgres psql -c "CREATE DATABASE quantumancy OWNER quantumancy;"
sudo -u postgres psql -c "CREATE DATABASE quantumancy_test OWNER quantumancy;"
```
> `quantumancy_test` is dropped and recreated by every test run. Never point
> the app's own `DATABASE_URL` at it.
**2. Configuration:**
```bash
cp .env.example .env
# edit .env: set a real SESSION_SECRET, confirm DATABASE_URL and OLLAMA_BASE_URL
```
**3. Backend:**
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
**4. Piper voices.** `backend/voices/` is gitignored; the app expects these
voice models there (id → file), downloadable from the
[rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices) HuggingFace
repo (each `.onnx` plus its `.onnx.json`):
| id | file | language | character |
|---|---|---|---|
| `lessac` | `en_US-lessac-low.onnx` | en | a measured American woman (default) |
| `amy` | `en_US-amy-low.onnx` | en | a soft American woman |
| `ryan` | `en_US-ryan-low.onnx` | en | a deep American man |
| `alan` | `en_GB-alan-low.onnx` | en | a low British man |
| `hfc_male` | `en_US-hfc_male-medium.onnx` | en | a worn male voice |
| `hfc_female` | `en_US-hfc_female-medium.onnx` | en | a worn female voice |
| `davefx` | `es_ES-davefx-medium.onnx` | es | una voz masculina grave (ES default) |
| `ald` | `es_MX-ald-medium.onnx` | es | una voz masculina seca |
**5. Frontend:**
```bash
cd frontend
npm install
npm run build # produces frontend/dist/, served by the backend
```
**6. Run.** Either by hand (from `backend/`, with the `.env` values in the
environment — the app reads `.env` relative to its working directory, and
`piper_voices_dir`/`data_dir` are relative to `backend/`):
```bash
cd backend
set -a && source ../.env && set +a
venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 7777
```
or as a service (recommended — auto-restart, boot-start, `journalctl -u quantumancy`):
```bash
sudo cp deploy/quantumancy.service /etc/systemd/system/
sudo systemctl enable --now quantumancy
```
Then visit `http://<host>:7777` — or your Cloudflare Tunnel hostname for the
secure context Spirit Radio and EVP require.
**7. Ollama models** (on the Ollama box, one-time):
```bash
ollama pull granite4.1:3b # fast tier: fragments, ambient whispers
ollama pull minicpm-v4.5:latest # chat tier: direct contact, entity minting
```
Any Ollama tag works — override with `OLLAMA_FAST_MODEL` / `OLLAMA_CHAT_MODEL`.
## Configuration
All settings live in `backend/app/config.py` and are read from the environment
or `.env` (repo root when run via systemd; CWD otherwise). Required:
`DATABASE_URL`, `OLLAMA_BASE_URL`, `SESSION_SECRET`.
| Env var | Default | Purpose |
|---|---|---|
| `DATABASE_URL` | — | asyncpg connection string, e.g. `postgresql+asyncpg://quantumancy:quantumancy@localhost:5432/quantumancy` |
| `OLLAMA_BASE_URL` | — | Ollama REST endpoint, e.g. `http://10.30.20.107:11434` |
| `SESSION_SECRET` | — | random 64-char string (session cookie signing) |
| `PORT` | `7777` | HTTP listen port |
| `OLLAMA_FAST_MODEL` | `granite4.1:3b` | fast tier: fragments, wire whispers |
| `OLLAMA_CHAT_MODEL` | `minicpm-v4.5:latest` | chat tier: direct contact, entity minting |
| `LLM_MAX_CONCURRENCY` | `2` | simultaneous Ollama calls (CPU-only box — keep small) |
| `LLM_MAX_QUEUE_DEPTH` | `8` | queued calls before "too many seekers" rejection |
| `LLM_COOLDOWN_SECONDS` | `8.0` | min gap between ambient whispers; user requests preempt |
| `PIPER_VOICES_DIR` | `voices` | voice model directory (relative to `backend/`) |
| `DATA_DIR` | `data` | generated audio, served at `/audio/` (relative to `backend/`) |
## The Séance Protocol
One WebSocket per contact session: `/ws/session`, authenticated by the
`qm_session` cookie (close code `4401` otherwise). JSON frames both ways.
**Client → server:**
| Frame | Payload | Server response |
|---|---|---|
| `ping` | — | `pong` |
| `set_mode` | `mode: wire\|evp\|radio\|ouija` | `mode` echo; persisted on the session row |
| `language` | `language: en\|es` | switches LLM reply language + Piper voice |
| `summon` | — | `status: summoning` → `entity` → greeting `utterance` |
| `anomaly` | `source`, `frequency`, `magnitude` | `anomaly_ack`; auto-summons once the stream can fingerprint (≥3 anomalies), then fragment `utterance`s |
| `question` | `text` (≤500 chars) | `status: gathering` → `reply_start` → `reply_token`×N → `reply_end` → spoken `utterance` |
| `passive` | `enabled: bool` | `passive` ack; starts/stops the ambient Wire Ghost loop |
**Server → client** (all frames flow through a single sender task so
concurrent producers never interleave):
| Frame | Payload | Meaning |
|---|---|---|
| `session` | `id` | contact session created (sent on connect) |
| `pong` | — | keepalive reply |
| `mode` | `mode` | mode accepted |
| `status` | `state: attuning\|summoning\|gathering` | themed loading states |
| `entity` | `is_new`, `entity` (name, epithet, persona, rarity, voice, visual, quotes, contact_count) | a presence has been matched or minted |
| `anomaly_ack` | `count` | anomalies recorded this session |
| `utterance` | `id`, `kind: greeting\|fragment\|ambient\|reply`, `text`, `entity` | words from beyond; audio follows |
| `audio` | `id`, `url` (`/audio/<event>.wav`) | TTS + effects chain finished for that utterance |
| `reply_start` / `reply_token` / `reply_end` | `token`, final `id` + `text` | token-streamed Direct Contact reply |
| `telemetry` | `jitter_bytes_per_s`, `latency_variance_ms`, `latency_mean_ms`, `dns_ms` | live Wire Ghost vitals |
| `passive` | `enabled` | ambient loop state |
| `error` | `code: rate_limited\|veil_crowded`, `message` | themed rate-limit / queue-full notices |
**REST** (JSON, session-cookie auth where noted; `credentials: 'include'`):
| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /auth/register` · `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` | — | argon2 username/password, `qm_session` cookie (14-day server-side sessions) |
| `GET /api/codex?rarity=&sort=recent\|contacted&limit=` | public | the shared spirit registry |
| `GET /api/codex/{entity_id}` | public | full dossier: persona, voice profile, sighting count |
| `GET /api/stats` | public | live veil counters (entities, sessions, utterances, anomalies) |
| `GET /healthz` | — | `{"status": "ok"}` |
## Testing
**Backend** (pytest, 45 tests; requires the `quantumancy_test` database —
dropped and recreated on every run):
```bash
cd backend && source venv/bin/activate
DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@localhost:5432/quantumancy_test \
OLLAMA_BASE_URL=http://10.30.20.107:11434 \
SESSION_SECRET=test-secret \
python -m pytest -v
```
Covers: auth + session cookies, rate limiter, config, SPA serving, LLM queue,
telemetry parsing, entity signatures/profiles, prompt construction (incl.
Spanish language clauses), TTS effects chain, Codex REST, and the full WS
séance flow (auth, ping/pong, summon/mint/greet, streamed replies, anomaly
attunement, re-contact by signature).
**Frontend** (Vitest + Testing Library — 71 tests across 6 files: the
planchette state machine, the EVP detector core, the FFT, the reconnecting
VeilSocket, the séance reducer, and the App shell):
```bash
cd frontend && npm test
```
**Hardware-in-the-loop** (cannot be unit tested, per the spec): the WebUSB
RTL-SDR sweep and the microphone EVP flow each need a manual pass with real
hardware/permissions before being called done. Spirit Radio is Chromium-only
and its driver is marked *HARDWARE PASS REQUIRED* in `frontend/src/lib/sdr.ts`.
RTL-SDR dongles are often claimed by the OS kernel driver
(`dvb_usb_rtl28xxu` on Linux) before WebUSB can reach them — Windows users
with Zadig/WinUSB usually work out of the box; Linux/Mac may need a driver
unbind.
## The Veil's Honesty Policy
- All LLM system prompts frame the entity as **a horror-fiction persona in an
interactive art installation** — never a genuine paranormal claim. The UI
carries the "this is real" atmosphere; the model instructions carry the
fiction.
- Wire Ghost telemetry reads counters and timings only. Packet payloads are
never inspected or logged.
- If the Ollama box is dark, every spirit channel degrades to curated offline
fallbacks — a summoning never visibly fails.
## Further Reading
- Design spec: `docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`
- Implementation plans 1–7: `docs/superpowers/plans/`

View File

@@ -0,0 +1,217 @@
# Quantumancy Plan 3/7: Wire Ghost & Ouija/Planchette — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn Plan 2's bare WebSocket skeleton into the full séance channel — the Wire Ghost's real network telemetry feeding an ambient LLM whisper loop, summon/anomaly/question message handling with per-user rate limiting, and the Ouija front-door UI: a planchette spelling state machine plus the canvas board, transcript, and 3D ghost it drives.
**Architecture:** The Wire Ghost is entirely backend-side (spec §3.3): `app.telemetry` samples non-content network metrics from the App CT itself, and a per-connection ambient loop in `app.ws` pushes `telemetry` frames plus occasional `ambient` utterances. All spirit-mode frames share one socket per contact session, with a single sender task so concurrent producers (ambient loop, reply streaming, TTS callbacks) never interleave on the wire. The Ouija surface (spec §3.4) is frontend: `PlanchetteMachine` (pure word-spelling state machine) fed by the séance store, rendered by `PlanchetteBoard` on canvas 2D, with the `GhostScene` three.js spirit reacting to session state.
**Tech Stack:** FastAPI WebSockets, asyncio, SQLAlchemy (backend, extending Plans 1-2); React 18 + TypeScript, canvas 2D, three.js (frontend).
## Global Constraints
- **Hard privacy boundary (spec §3.3):** telemetry reads counters and timings only — `/proc/net/dev` byte counters, TCP connect latency, DNS resolution timing. Packet payloads are never inspected or logged. Do not add any socket/payload capture here, ever.
- Every LLM-triggering message type is rate-limited per user (spec §5) using Plan 1's `RateLimiter`: fragments 30/min, questions 6/min, summons 4/min. Rejections are themed (`The veil is crowded…`), never raw 429s.
- All server→client frames flow through a single `_sender` task fed by an `asyncio.Queue` — no task other than `_sender` may call `websocket.send_json`.
- A summoning must never visibly fail: if the Ollama box is dark, `spirit_service.mint_profile` degrades to `entities.fallback_profile` (Plan 6 owns that module; this plan consumes it).
- Plan 2's WS contract (`/ws/session`, cookie auth, `ping`→`pong`, ContactSession lifecycle) is extended, not broken: unauthenticated sockets still close with code `4401`.
- Entity minting internals (signatures, normalization) are specified in Plan 6; this plan wires the summon flow that calls them. Codex REST endpoints are Plan 6.
## Plan Series
This is 3 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plans 1 (Foundation & Auth) and 2 (Frontend, LLM & Realtime Pipeline) are complete.
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. **Wire Ghost mode + Ouija/Planchette UI** (this plan)
4. EVP Listening mode
5. Spirit Radio mode
6. Codex & entity persistence
7. Internationalization (EN/ES)
---
## Task 1: Wire Ghost Telemetry Sampler
**Files:**
- Create: `backend/app/telemetry.py`
- Test: `backend/tests/test_telemetry.py`
**Interfaces:**
- Produces: `TelemetrySample` dataclass (`jitter_bytes_per_s`, `latency_variance_ms`, `latency_mean_ms`, `dns_ms`, `extra`) with `.as_dict()`; `parse_proc_net_dev(text: str) -> dict[str, tuple[int, int]]` (pure, testable); `async sample_network(period_s: float = 1.0) -> TelemetrySample`. Module constants `REFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]` and `REFERENCE_DNS = ["example.com", "cloudflare.com"]`.
- [x] **Step 1: Write the failing tests**
`test_parse_proc_net_dev_extracts_counters` feeds a canned `/proc/net/dev` and asserts `{lo: (1234567, 1234567), eth0: (9876543, 1111111)}`; `test_parse_proc_net_dev_ignores_malformed_lines` asserts garbage input yields `{}`. The parser is deliberately split from the async sampler so the fragile part (text munging) is unit-testable without touching the network.
- [x] **Step 2: Implement the sampler**
`sample_network` reads total non-loopback bytes twice across `period_s` for throughput jitter, then gathers TCP connect-then-close RTTs against `REFERENCE_HOSTS` (nothing sent or read beyond the handshake) and `getaddrinfo` timings against `REFERENCE_DNS`, all with 1.5 s timeouts and soft-failure (`None` values dropped; unreachable hosts yield a zero-ish sample rather than an exception). Mean/variance computed only over successful probes.
- [x] **Step 3: Verify**
Run: standard backend pytest invocation (Plan 1 Task 3) scoped to `tests/test_telemetry.py`. PASS (2/2).
- [x] **Step 4: Commit** — `feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry` (`b9110f4`, shared with Tasks 2-5 and Plan 6 backend work).
---
## Task 2: The Séance Channel — Protocol & Session Lifecycle
**Files:**
- Create: `backend/app/ws.py` (full rewrite of Plan 2's skeleton)
- Create: `backend/app/models/event.py`
- Modify: `backend/app/models/contact_session.py` (add `entity_id`, `language`)
- Modify: `backend/app/models/__init__.py`, `backend/app/main.py` (`/audio` mount)
- Test: `backend/tests/test_ws_session.py`
**Interfaces:**
- Produces: the séance protocol (see table below); `Event` model (`id`, `session_id` FK, `kind` ∈ {anomaly, utterance, question, reply, system}, `text`, `payload` JSONB, `audio_path`, `created_at`) — the per-session transcript; `SeanceState` dataclass holding per-connection mode/language/entity/anomaly ring buffer/history/ambient task; `AUDIO_DIR = Path(settings.data_dir) / "audio"`, mounted at `/audio` in `main.py`.
Protocol implemented (mirrored 1:1 by `frontend/src/lib/types.ts`):
| client → server | server → client |
|---|---|
| `ping` | `pong` |
| `set_mode {mode}` ∈ {wire, evp, radio, ouija} | `mode {mode}` (also persisted on the session row) |
| `language {language}` ∈ {en, es} | (silent; affects LLM + TTS from then on) |
| `summon` | `status summoning` → `entity {entity, is_new}` → `utterance kind=greeting` |
| `anomaly {source, frequency, magnitude}` | `anomaly_ack {count}` (+ summon or fragment, Task 3) |
| `question {text}` | `status gathering` → `reply_start` → `reply_token`×N → `reply_end` → `utterance kind=reply` |
| `passive {enabled}` | `passive {enabled}`, ambient loop start/stop |
Additional server frames: `session {id}` on connect, `utterance {id, kind, text, entity}`, `audio {id, url}`, `telemetry {…}`, `error {code, message}`.
- [x] **Step 1: Write the failing tests**
`test_ws_session.py` (6 tests): unauthenticated connect raises; ping/pong + session row opened/closed around disconnect; plus the Task 3 flows below. Fixtures: a `FakeSpiritService` (deterministic `mint_profile`/`fragment`/`wire_whisper`/`chat_stream`, `ambient_ready() = False`) monkeypatched over `app.ws.spirit_service`, and a fake `synthesize_spirit_voice` returning canned WAV bytes — no Ollama or Piper needed in tests. Note the `TestClient` upgrades over `ws://`, so the test passes the `Secure qm_session` cookie explicitly via headers; real browsers on https send it automatically.
- [x] **Step 2: Implement the channel**
Key structural decisions:
- **Single sender.** `_sender(state, websocket)` is the only task allowed to write to the socket, draining `state.send_queue`. Handlers and background tasks only ever `send_queue.put(...)`.
- **`_record_event`** persists every anomaly/question/utterance/reply as an `Event` row; utterance ids double as audio filenames (`<event_id>.wav`).
- **`_speak`** pushes the utterance *text* immediately, then synthesizes audio in a detached task and pushes `audio {id, url}` when the effects chain finishes. TTS failure is swallowed — "TTS is texture, not content."
- **Detached close.** ASGI servers may cancel the handler task the moment the socket closes, so `_close_session` (sets `ended_at`) runs as a detached `asyncio.create_task` that survives handler teardown. The lifecycle test polls up to 2 s for `ended_at` to land.
- [x] **Step 3: Verify**
`tests/test_ws_session.py` PASS (6/6, including Task 3's flows).
- [x] **Step 4: Commit** — in `b9110f4`.
---
## Task 3: Summon Flow, Anomaly Attunement & the Ambient Loop
**Files:**
- Modify: `backend/app/ws.py` (`_summon`, `_handle_summon`, `_handle_anomaly`, `_handle_question`, `_ambient_loop`, `_handle_passive`, `_unique_entity_name`, module-level limiters)
**Interfaces:**
- Consumes: `signature_from_anomalies` / `fallback_signature` from `app.entities` (Plan 6); `spirit_service.fragment` / `chat_stream` / `mint_profile` / `wire_whisper` / `ambient_ready` from `app.llm.service` (Plan 2/6); `Entity`, `EntitySighting` models (Plan 6).
- Produces: the live summon behavior the frontend's `summon()` / auto-summon paths depend on.
- [x] **Step 1: Summon = match or mint**
`_summon` fingerprints the session's anomaly buffer (`signature_from_anomalies`, ≥3 anomalies required) or falls back to a deterministic per-session signature, looks up `Entity.signature`, and either re-contacts (increments `contact_count`) or mints via `spirit_service.mint_profile`, persisting name/epithet/persona/rarity/voice/visual/quotes. `_unique_entity_name` suffixes `II`, `III`, … on name collisions. Every summon links the session (`ContactSession.entity_id`) and writes an `EntitySighting` row, then greets with a random sample quote. Summons are limited to 4/min/user with a themed `error` frame.
- [x] **Step 2: Anomaly attunement**
`_handle_anomaly` records the event, acks the count, and caps the ring buffer at 64. With no entity yet: a fingerprintable stream (≥3 anomalies) triggers auto-summon, otherwise `status attuning`. With an entity: one Ovilus-style `fragment` per anomaly, throttled by the 30/min fragment limiter and `SpiritBusyError` (crowded veil = anomalies pass unheard, by design).
- [x] **Step 3: Direct Contact**
`_handle_question` (6/min limiter) auto-summons if needed, trims input to 500 chars, then streams `spirit_service.chat_stream` tokens as `reply_token` frames, records the full reply, keeps the last 8 history turns, and speaks the reply. Queue-full mid-question yields a themed `veil_crowded` error and an empty `reply_end` so the frontend never hangs.
- [x] **Step 4: The Wire Ghost's pulse**
`_handle_passive(true)` spawns `_ambient_loop`: every 6–10 s it samples `sample_network(period_s=1.0)`, pushes a `telemetry` frame, and — only when `spirit_service.ambient_ready()` (the LLM box has been idle ≥ `LLM_COOLDOWN_SECONDS`, so ambient whispers never preempt a user's request) — speaks a `wire_whisper` as an `ambient` utterance. Telemetry-sampling failure skips the tick silently. `passive(false)` or socket teardown cancels the task.
- [x] **Step 5: Verify**
`test_summon_mints_entity_and_greets`, `test_question_streams_reply_and_records_history`, `test_anomalies_attune_then_produce_fragments`, `test_same_signature_recontacts_same_entity` — all PASS as part of `tests/test_ws_session.py` (6/6). The last one drives two separate sessions with identical anomaly patterns and asserts the same entity name with `is_new: False` and `contact_count: 2`.
- [x] **Step 6: Commit** — in `b9110f4`.
---
## Task 4: Frontend Séance Spine (Protocol Types, Socket, Audio, Store)
**Files:**
- Create: `frontend/src/lib/types.ts`
- Create: `frontend/src/lib/ws.ts`
- Create: `frontend/src/lib/audio.ts`
- Create: `frontend/src/state/seance.tsx`
**Interfaces:**
- Produces: `ClientFrame` / `ServerFrame` discriminated unions mirroring Task 2's protocol exactly (`types.ts` — "do not invent changes"); `VeilSocket` with `connect/close/send/onFrame/onState`, outbox queueing while connecting, exponential backoff reconnect (800 ms → 15 s cap) surfacing `connecting|open|unstable|closed`, and a 25 s ping keepalive (`ws.ts`); `SpiritAudioPlayer` — single FIFO queue so overlapping spirit audio never talks over itself, `enqueue(id, url)`, `setCallbacks({onStart, onEnd})`, `clear()`, and `getAmplitude()` RMS via an `AnalyserNode` so the ghost pulses with the voice (`audio.ts`); `SeanceProvider` + `useSeance()` exposing `{state, socket, audioPlayer, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast}` with a pure, unit-testable reducer (`seance.tsx`).
- [x] **Step 1: Protocol types first**
`types.ts` pins the domain types (`Mode`, `Language`, `Rarity`, `GhostForm`, `SpiritEntity`, `CodexEntity(Detail)`, `Telemetry`, `UtteranceKind`, `SessionStatus`) and both frame unions. Everything downstream imports from here; the file header forbids drifting from the backend contract.
- [x] **Step 2: VeilSocket**
Reconnecting client with a typed emitter. Frames queued while `connecting` flush on open; sends while `closed`/`unstable` drop. Malformed JSON frames are ignored ("malformed whispers"). `defaultSessionUrl()` derives `ws(s)://<host>/ws/session` from `location`, so it works both behind the dev proxy and the Cloudflare Tunnel. `socketFactory` injectable for tests.
- [x] **Step 3: SpiritAudioPlayer**
One `HTMLAudioElement` at a time routed through Web Audio (`MediaElementAudioSourceNode` → `AnalyserNode` → destination); the queue pumps sequentially; `getAmplitude()` returns 0..1 RMS for the ghost's speaking glow.
- [x] **Step 4: Séance store**
`seanceReducer` handles every server frame: `entity` inserts a `⟁ name — epithet` system line into the transcript; `audio` pairs URLs onto utterances (enabling replay); `reply_*` manages the streaming buffer; `error` becomes a capped toast stack (4). All lists capped (transcript 400, utterances 200, anomalies 200). The provider wires socket↔reducer, auto-enqueues arriving audio, and cleans up every listener/timer on unmount.
- [x] **Step 5: Verify** — `cd frontend && npx tsc --noEmit` clean for these files.
- [x] **Step 6: Commit** — frontend spine landed with the seance UI work (orchestrated alongside Plan 3-5 pages).
---
## Task 5: Planchette Board, Transcript & Ghost
**Files:**
- Create: `frontend/src/lib/planchette.ts`
- Create: `frontend/src/components/PlanchetteBoard.tsx`
- Create: `frontend/src/components/Transcript.tsx`
- Create: `frontend/src/components/TelemetryReadout.tsx`
- Create: `frontend/src/components/EntityCard.tsx`, `frontend/src/components/GhostGlyph.tsx`
- Create: `frontend/src/three/GhostCanvas.tsx`, `frontend/src/three/GhostScene.ts`
**Interfaces:**
- Produces: `PlanchetteMachine` — `enqueue(text)`, `tick(dtMs) -> PlanchetteSnapshot`, `snapshot()`, `clear()`, `getVersion()`, plus pure helpers `normalizeWord` / `tokenize` (A–Z/0–9/space only, uppercase); phases `idle|moving|dwelling|returning`, 300 ms/letter, 700 ms between words, queue capped at 64 (`planchette.ts`). `PlanchetteBoard({machine, hue?})` — canvas 2D gothic board: twin letter arcs, number row, YES/NO/GOODBYE, a triangular planchette with lens that drifts idly with a smoke-particle trail then glides letter-to-letter spelling queued words. `Transcript({entries, streaming, speakingId, onReplay})`. `GhostScene` — custom-shader spirit (fbm value-noise vertex displacement on a lathed figure, additive wisps, ground mist, slow camera drift) reacting via uniforms to energy/speaking amplitude/hue/form (`wisp|banshee|fairy|shade`).
- [x] **Step 1: The spelling machine (pure logic)**
Utterances tokenize into words; the machine steps letters on cadence, exposing which character the planchette hovers. Rendering never drives logic — the machine is ticked from a rAF loop and is unit-testable in isolation (see Testing note below).
- [x] **Step 2: The board**
`computeLayout` positions glyphs responsively; the planchette eases toward the machine's current letter with idle drift when the queue is empty, trailing smoke particles with per-particle physics. All rAF/listeners cleaned up on unmount.
- [x] **Step 3: Transcript, telemetry readout, entity dossier**
`Transcript` renders anomalies/utterances/questions/system lines with replay buttons once `audio` lands and a `speakingId` highlight for the currently-voiced utterance. `TelemetryReadout` shows the Wire Ghost's vitals. `EntityCard`/`GhostGlyph` render the summoned spirit's wax-seal dossier and hue/form glyph.
- [x] **Step 4: The ghost**
Three.js scene with GLSL fbm noise displacement, particle wisps, fog, camera drift; mood (`idle|attuning|gathering|speaking`) and `GhostVisualState {hue, form}` pushed in as uniforms from the séance store + audio amplitude.
- [x] **Step 5: Verify** — `npx tsc --noEmit` clean; board/ghost visually verified in the running app.
- [x] **Step 6: Commit** — landed with the frontend spine.
---
## Testing Status & Self-Review
**Spec coverage:** Wire Ghost backend-only mode with hard privacy boundary (§3.3) → Tasks 1, 3. Ambient layer running alongside other modes (§3.3) → `passive` frame + `ambient_ready` gating (Task 3). Ouija as shared front-door with letter-by-letter planchette (§3.4) → Tasks 4-5. Direct Contact streaming replies (§3.4) → Task 3. Per-user rate limits on all LLM-triggering messages with themed errors (§5, §7) → Task 3. Session transcript with audio clip references (§5 `events` table) → Task 2.
**Automated tests:** backend `tests/test_telemetry.py` (2) + `tests/test_ws_session.py` (6) — PASS as part of the 45-test suite. Frontend: `src/lib/planchette.test.ts` (16 — machine cadence/phases, `normalizeWord`/`tokenize`, queue caps, `clear()` semantics), `src/lib/ws.test.ts` (14 — VeilSocket outbox flush, malformed-frame tolerance, backoff cap, deliberate-close semantics), and `src/state/seance.test.ts` (17 — the séance reducer) all PASS in the 71-test vitest run.
**Placeholder scan:** none — everything above exists in the repo and runs.
**Type consistency:** `ClientFrame`/`ServerFrame` unions match `ws.py`'s handler/emitter keys one-for-one (`set_mode`/`mode`, `summon`/`entity`, `anomaly`/`anomaly_ack`, `question`/`reply_*`, `passive`, `telemetry`, `utterance`/`audio`, `status` states). `serialize_entity`'s dict keys match `SpiritEntity` field-for-field. `TelemetrySample.as_dict()` keys match the `Telemetry` type.
---
**Status: COMPLETE** (backend in commit `b9110f4`; frontend spine complete, type-clean, and unit-tested). Follow-ups carried forward from Plan 1's ledger: expired-session reaper; rate-limiter key eviction.

View File

@@ -0,0 +1,126 @@
# Quantumancy Plan 4/7: EVP Listening — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the dead the seeker's microphone (spec §3.2): a Web Audio pipeline that listens to room silence, flags brief voice-band deviations as anomalies, and feeds them into Plan 3's anomaly→fragment→voice pipeline — mirroring the real EVP technique of recording quiet stretches and reviewing them for embedded voices.
**Architecture:** EVP is client-side detection, server-side voice. In the browser, an `AnalyserNode` (`fftSize` 2048, smoothing 0.5) yields per-bin dB frames; a pure, testable detector core (`EvpDetectorCore`) maintains a rolling per-bin noise floor over the voice band (~300–3400 Hz) and emits at most one anomaly per 2 s when a bin jumps ≥ 8 dB above the floor. Anomalies travel Plan 3's séance channel as `{type: "anomaly", source: "evp", frequency, magnitude}` and come back as fragment utterances with spirit-box audio. The Web Audio plumbing (`EvpListener`) is a thin, fully-cleaned-up shell around the core.
**Tech Stack:** React 18 + TypeScript, Web Audio API (`getUserMedia`/`AudioContext`/`AnalyserNode`), and the Plan 2-3 backend (Ollama fast tier, Piper TTS, `/ws/session`).
## Global Constraints
- The pure math (floor tracking, deviation detection, throttling) lives in `EvpDetectorCore`, decoupled from Web Audio so it is unit-testable with injected sample arrays. Do not leak `AudioContext` types into the core.
- Mic permission denial degrades gracefully (spec §7): the mode surfaces a re-prompt state; Wire Ghost/ambient remains available. Never throw an unhandled `getUserMedia` rejection at the UI.
- Every rAF, media track, and `AudioContext` is released on stop/unmount — browsers count open mic indicators, and a séance that keeps listening after you leave the room is the wrong kind of haunting.
- The backend half of this mode already exists: Plan 3's `_handle_anomaly` + `spirit_service.fragment` + `_speak`. This plan adds the `evp` prompt variant and the frontend detector — no new WS frame types.
## Plan Series
This is 4 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plan 3 (Wire Ghost & Ouija) is complete and provides the séance channel this mode speaks over.
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. Wire Ghost mode + Ouija/Planchette UI (complete)
4. **EVP Listening mode** (this plan)
5. Spirit Radio mode
6. Codex & entity persistence
7. Internationalization (EN/ES)
---
## Task 1: EvpDetectorCore — Rolling-Floor Voice-Band Anomaly Detection
**Files:**
- Create: `frontend/src/lib/evp.ts` (core half)
**Interfaces:**
- Produces: `EvpAnomaly {frequency: number /* Hz */, magnitude: number /* dB over floor */}`; `EvpDetectorOptions {sampleRate, fftSize, bandLowHz? = 300, bandHighHz? = 3400, thresholdDb? = 8, throttleMs? = 2000, floorAlpha? = 0.05, quietBandDb? = 3}`; `class EvpDetectorCore` with `process(dbData: ArrayLike<number>, nowMs: number) -> EvpAnomaly | null`, `getFloor() -> Float64Array | null` (visualizer snapshot), `reset()`.
- [x] **Step 1: Frame sanitization & floor seeding**
`AnalyserNode.getFloatFrequencyData` can emit `-Infinity` for silence; every frame is sanitized to −160 dB first. The first frame seeds the per-bin floor and never fires.
- [x] **Step 2: Voice-band deviation detection**
Bin width is `sampleRate / fftSize` (~23.4 Hz at 48 kHz/2048); the watch band spans bins ⌊300/binHz⌋…⌈3400/binHz⌉. Each frame, the core finds the peak positive deviation inside the band and fires when `peakDev ≥ thresholdDb` and at least `throttleMs` has passed since the last emission — one anomaly per 2 s max, so a creaky house cannot flood the veil (or the 30/min fragment limiter).
- [x] **Step 3: Quiet-stretch floor adaptation**
The floor is an EMA (`floorAlpha` 0.05) that only adapts while the band's peak level sits within `quietBandDb` of the floor's peak — i.e. precisely during the quiet stretches where EVP anomalies count. Sustained speech or music does not drag the baseline up and mask real spikes; it also doesn't fire endless "anomalies," since a loud band keeps the floor frozen but the deviation check still applies per bin.
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean. (Unit tests: see Testing note below.)
---
## Task 2: EvpListener — Web Audio Plumbing
**Files:**
- Create: `frontend/src/lib/evp.ts` (listener half)
**Interfaces:**
- Produces: `EvpListenerCallbacks {onAnomaly(a), onFrame?(dbData, floor), onError?(err)}`; `class EvpListener` with `start(cb) -> Promise<void>`, `stop() -> Promise<void>`, `isRunning`. `onFrame` fires every rAF with the live dB frame plus the current floor so the mode's UI can draw the listening waveform and the room's silence line.
- [x] **Step 1: Acquisition**
`start` requests `getUserMedia({audio: true})`, builds `AudioContext → MediaStreamSource → AnalyserNode` (fftSize 2048, smoothing 0.5), constructs the core with the context's real `sampleRate`, and begins the rAF loop. Permission/device failures reject the promise for the UI to turn into a themed re-prompt state — the listener never half-starts.
- [x] **Step 2: The loop**
Each frame: `getFloatFrequencyData` into a reused buffer → `core.process(buf, performance.now())` → `onAnomaly` when one fires → always `onFrame` for the visualizer. No allocations in the hot path beyond the analyzer's own.
- [x] **Step 3: Teardown**
`stop` clears `running`, cancels the rAF, stops every `MediaStreamTrack` (killing the browser's mic indicator), and closes the `AudioContext` (guarded against double-close). Idempotent and safe to call from unmount.
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean; manual mic pass (see Testing note).
---
## Task 3: Anomaly → Fragment → Voice (Backend Glue)
**Files:**
- Modify: `backend/app/llm/prompts.py` (EVP variant of `fragment_prompt`)
**Interfaces:**
- Consumes: Plan 3's `_handle_anomaly` / `fragment_limiter` / `_speak` — unchanged.
- Produces: the `evp` branch of `fragment_prompt(source, anomaly, language)`:
```python
if source == "evp":
return (
"During a stretch of silence, the microphone caught a shape in "
f"the voice band ({anomaly.get('frequency', '???')} Hz, "
f"{anomaly.get('magnitude', '???')} dB over the room's floor). "
"What single word was hidden in it?"
)
```
- [x] **Step 1: Wire the prompt**
`spirit_service.fragment` already selects the system framing by source (`"an EVP recorder"` for `evp`, `"a spirit box"` for `radio`) and caps output at 16 predicted tokens, temperature 0.95, cleaned to ≤80 chars of whitespace-collapsed text — one eerie word, no explanations.
- [x] **Step 2: Frontend sends, séance receives**
The mode page calls `useSeance().sendAnomaly('evp', a.frequency, a.magnitude)` from `onAnomaly`, which dispatches a local transcript entry and sends the `anomaly` frame. Server-side flow is then exactly Plan 3: ack → (auto-summon on a fingerprintable stream) → fragment utterance → Piper voice with the entity's effects profile → `audio` frame → queued playback.
- [x] **Step 3: Verify**
Backend: `tests/test_ws_session.py`'s anomaly flow covers the shared path (6/6 PASS). The `evp` prompt branch is exercised by inspection and the shared `fragment()` path; frontend integration verified via the running app.
---
## Testing Status & Self-Review
**Spec coverage:** mic + `AnalyserNode` + rolling voice-band baseline (§3.2) → Tasks 1-2. Anomalies driving the same fragment LLM call as Spirit Radio (§3.2) → Task 3. Mic-denial graceful degradation (§7) → `start()` rejection surfaces a re-prompt state; other modes unaffected.
**Automated tests:** the detector core is covered by `src/lib/evp.test.ts` (11 tests — floor seeding, `-Infinity` sanitization, threshold/throttle behavior, quiet-band adaptation, `reset()`, defensive `getFloor()` copies), PASS in the 71-test vitest run. The backend half is covered by `tests/test_ws_session.py` (anomaly→fragment, 6/6 PASS in the 45-test suite).
**Known test gap (honest):** the live microphone path cannot be meaningfully unit tested (spec §8). A **manual hardware pass is required** before this mode is called done: real room, real silences, verify the floor adapts, spikes fire ≤1/2 s, and the mic indicator dies on mode exit.
**Placeholder scan:** none — both classes exist in `frontend/src/lib/evp.ts` and the prompt branch is live in `backend/app/llm/prompts.py`.
**Type consistency:** `EvpAnomaly.frequency` (Hz) matches the `anomaly` frame's `frequency: number`; the backend's `signature_from_anomalies` buckets Hz audio freqs and MHz radio freqs into the same log-scale band space by digit count (see `app/entities.py` comment), so EVP and Radio anomalies fingerprint consistently. `EvpListenerCallbacks.onAnomaly` matches `SeanceApi.sendAnomaly('evp', …)`.
---
**Status: COMPLETE** (implementation; `evp.ts` type-clean and unit-tested, prompt branch merged). Deferred: manual microphone hardware pass.

View File

@@ -0,0 +1,131 @@
# Quantumancy Plan 5/7: Spirit Radio — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A spirit box built from a real radio (spec §3.1): the browser claims a user-supplied RTL-SDR dongle over WebUSB, sweeps the FM broadcast band, computes FFT power per bin in JavaScript, and emits anomaly events when a spike jumps the rolling noise floor — each one triggering an Ovilus-style single-word fragment from the fast LLM tier.
**Architecture:** Everything RF happens client-side, in three pure-ish layers: `fft.ts` (radix-2 Cooley–Tukey FFT + dB power spectrum from interleaved I/Q), `RtlSdr` (WebUSB control-transfer driver for RTL2832U + R820T: init sequence, PLL tuning, sample-rate programming, bulk I/Q reads, and a continuous `sweep()` loop), and `SpectrumAnomalyDetector` (per-bin EMA noise floor + throttled spike detection). Anomalies flow to the backend as `{type: "anomaly", source: "radio", frequency /* MHz */, magnitude /* dB */}` over Plan 3's séance channel. The backend needs nothing new — this mode reuses Plan 3's anomaly→fragment pipeline exactly as EVP does (Plan 4).
**Tech Stack:** TypeScript, WebUSB (`navigator.usb`), `Float64Array` DSP; backend unchanged from Plans 3-4.
## Global Constraints
- **Chromium-only.** WebUSB exists only in Chrome/Edge/Brave/Opera; Firefox and Safari lack it entirely. `isSupported()` must gate the mode, which self-disables with an in-UI explanation (spec §7) — never a silent failure, never a broken page on other browsers.
- **Kernel driver contention is expected, not exceptional.** Linux's `dvb_usb_rtl28xxu` claims RTL-SDR dongles before WebUSB can; claim failure surfaces the mode's troubleshooting guide link (spec §3.1/§7). Windows+Zadig/WinUSB typically works out of the box.
- **Fail soft everywhere.** Every entry point of the driver treats a thrown error as "this vessel cannot hear the radio dead" — the UI degrades; the séance continues on other modes.
- **No new backend surface.** Do not add WS frames or REST endpoints; `source: "radio"` anomalies already have a home.
- **Hardware honesty.** The driver was structured from public librtlsdr register documentation in an environment with **no RTL-SDR attached**. It is marked `HARDWARE PASS REQUIRED` in the source and must not be called done until a real dongle validates it (see Task 4).
## Plan Series
This is 5 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plans 3-4 provide the anomaly pipeline this mode feeds.
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. Wire Ghost mode + Ouija/Planchette UI (complete)
4. EVP Listening mode (complete)
5. **Spirit Radio mode** (this plan)
6. Codex & entity persistence
7. Internationalization (EN/ES)
---
## Task 1: FFT & Power Spectrum
**Files:**
- Create: `frontend/src/lib/fft.ts`
**Interfaces:**
- Produces: `nextPow2(n)`; `fftInPlace(re: Float64Array, im: Float64Array)` (iterative radix-2, bit-reversal ordered, length must be a power of two); `magnitudeSpectrum(re, im) -> Float64Array` (first n/2 bins); `powerSpectrumDb(iq: Float64Array) -> Float64Array` — interleaved `[i0, q0, i1, q1, …]` in, n/2 dB values out, power normalized by n², floored at 1e-12 before log.
- [x] **Step 1: Implement the transform**
Plain dependency-free Cooley–Tukey: bit-reversal permutation, then butterfly passes with a twiddle recurrence (no per-butterfly trig). Throws on re/im length mismatch and non-power-of-two input — programming errors should be loud here, not silent spectrum garbage.
- [x] **Step 2: I/Q → dB**
`powerSpectrumDb` zero-pads/truncates to `nextPow2(iq.length / 2)`, splits interleaved I/Q into re/im planes, transforms, and returns `10 * log10(power)`. This is what the sweep loop hands the anomaly detector per tuning step.
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; `src/lib/fft.test.ts` (11 tests) validates the transform against known inputs — impulse spectra, cosine peaks landing on the expected bin for two different frequencies, power-of-two enforcement.
---
## Task 2: RtlSdr — WebUSB Driver for RTL2832U + R820T
**Files:**
- Create: `frontend/src/lib/sdr.ts` (driver half)
**Interfaces:**
- Produces: `RTL2832U_VENDOR = 0x0bda`, `RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]`; `isSupported(): boolean`; `class RtlSdr` with `requestDevice()`, `open(sampleRateHz = 2_048_000)`, `close()`, `setFrequency(hz)`, `setSampleRate(hz)`, `readSamples(bytes) -> Uint8Array`, `sweep(startHz, endHz, stepHz, cb, fftSize = 512, settleMs = 25)`, `stopSweep()`, `isOpen`. Callback types `RtlSampleBlock` and `SweepCallbacks {onSpectrum?(centerHz, db), onError?(err)}`.
- [x] **Step 1: Device selection & claim**
`requestDevice()` filters on the four known RTL2832U product IDs. `open()` locates the first bulk-IN endpoint, claims the interface (kernel-driver detach is best-effort; failure throws a themed "could not claim the radio dead (interface busy?)" error for the UI's troubleshooting link).
- [x] **Step 2: Init sequence** *(HARDWARE PASS REQUIRED)*
Follows librtlsdr's known-good order via `demodWrite` (paged demod registers) and `i2cWrite` (tuner registers tunneled through the demod's I2C repeater at 0x1a): soft reset → `demod_ctl` → suspend/standby off → AGC mode → R820T LNA/mixer/IF power-on → sample rate → initial 98 MHz tune → endpoint reset. Register pokes are commented as unverified against a real device.
- [x] **Step 3: Tuning & sample rate** *(HARDWARE PASS REQUIRED)*
`setFrequency` programs the R820T fractional-N PLL with the 3.57 MHz IF offset against the 28.8 MHz crystal reference (integer part + 16-bit SDM fraction; documented simplification of librtlsdr's exact sdm/vco math). `setSampleRate` programs the demod resampling ratio (`crystal·2²²/hz`, 4-aligned).
- [x] **Step 4: The sweep loop**
`sweep()` tunes in `stepHz` steps across `[startHz, endHz]`, waits `settleMs`, discards one 16 KiB block (PLL settle), reads `fftSize·4` bytes of unsigned I/Q, zero-centers to `[-1, 1)`, runs `powerSpectrumDb`, and emits `onSpectrum(centerHz, db)`. Read errors go to `onError` and the sweep continues; wrapping past `endHz` restarts at `startHz`; `stopSweep()` exits cleanly from the `finally`. Exported band constants: `SWEEP_START_MHZ = 88`, `SWEEP_END_MHZ = 108`.
- [x] **Step 5: Verify** — `npx tsc --noEmit` clean. Runtime verification deferred to Task 4.
---
## Task 3: SpectrumAnomalyDetector — Rolling Floor Over the Airwaves
**Files:**
- Create: `frontend/src/lib/sdr.ts` (detector half)
**Interfaces:**
- Produces: `class SpectrumAnomalyDetector(thresholdDb = 10, throttleMs = 2000, alpha = 0.1)` with `process(centerHz, sampleRateHz, db, nowMs) -> {frequency /* MHz */, magnitude /* dB */} | null` and `reset()`. Pure and testable: no USB types, injected spectrum + clock.
- [x] **Step 1: Floor + spike logic**
First spectrum seeds the per-bin EMA floor. Each subsequent spectrum updates the floor (`alpha` 0.1) and finds the peak deviation; a spike fires when `peak ≥ thresholdDb` (10 dB over the rolling floor — the radio dead must shout) and ≥ `throttleMs` (2 s) since the last emission. Bin→frequency maps across the baseband: `binHz = sampleRate/2 / bins`, offset from center, reported in MHz to match the `anomaly` frame contract.
- [x] **Step 2: Into the séance**
The mode page forwards detections as `sendAnomaly('radio', frequencyMHz, magnitudeDb)`. Backend flow is Plan 3 verbatim: `anomaly_ack`, auto-summon once ≥3 anomalies fingerprint, then `spirit_service.fragment('radio', …)` — the radio variant of the prompt ("A burst of static at {frequency} MHz, magnitude {magnitude} dB above the noise floor…") with the `"a spirit box"` system framing.
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; backend anomaly path covered by `tests/test_ws_session.py` (`test_anomalies_attune_then_produce_fragments` uses `source: "radio"`, 6/6 PASS).
---
## Task 4: Hardware-in-the-Loop Validation
**Files:**
- Modify (expected): `frontend/src/lib/sdr.ts` register sequences after testing
- [ ] **Step 1: Real-dongle smoke test — PENDING**
No RTL-SDR is attached to the build environment, so the driver has never touched silicon. Before this mode is called done: plug an RTL2832U+R820T dongle into a Chromium machine, claim it through the mode UI, and confirm the init sequence completes and bulk I/Q flows. Expect to debug register pokes with `librtlsdr -T` / a logic analyzer — the source is pre-marked `HARDWARE PASS REQUIRED` at the file header, the init sequence, and `setFrequency`.
- [ ] **Step 2: Sweep & detector tuning — PENDING**
With live RF: verify the 88–108 MHz sweep shows real broadcast peaks, calibrate `thresholdDb`/`settleMs` against a known station, and confirm anomalies reach the séance (fragment utterances arrive, transcript shows `radio` entries).
- [ ] **Step 3: Kernel-claim runbook — PENDING**
Validate the failure paths on Linux (`dvb_usb_rtl28xxu` bound → themed claim error + guide link) and an unsupported browser (mode self-disables with the spec §7 explanation, other modes unaffected).
---
## Testing Status & Self-Review
**Spec coverage:** WebUSB sweep + browser FFT + spike anomalies → Ovilus fragments (§3.1) → Tasks 1-3. Chromium-gating and self-disable (§3.1, §7) → `isSupported()` + Task 4 step 3. Driver-claim troubleshooting (§3.1, §7) → `open()`'s themed claim error. Manual hardware pass before done (§8) → Task 4, **pending**.
**Automated tests:** `fft.ts` is covered by `src/lib/fft.test.ts` (11 tests, PASS in the 71-test vitest run); the backend side is covered by `tests/test_ws_session.py` radio-anomaly flow (6/6 in the 45-test suite). **Honest gaps:** no vitest yet for `SpectrumAnomalyDetector` (pure and test-ready — the detector logic mirrors the covered `EvpDetectorCore`), and no automated coverage is possible for the USB path itself.
**Placeholder scan:** the driver is real code, not a stub — but its register sequences are deliberately labeled unverified, and this plan does not claim otherwise. Task 4's pending checkboxes are the whole truth.
**Type consistency:** detector output `{frequency: MHz, magnitude: dB}` matches `SeanceApi.sendAnomaly` and the `anomaly` ClientFrame; MHz (radio) vs Hz (EVP) unit mixing is handled server-side by `signature_from_anomalies`' digit-count bucketing (documented in `app/entities.py`). `powerSpectrumDb` output length (n/2) matches `SpectrumAnomalyDetector.process`'s bin math.
---
**Status: IMPLEMENTATION COMPLETE — HARDWARE VERIFICATION PENDING.** The mode must not be shipped as "done" until Task 4 passes against a physical RTL-SDR on Chromium.

View File

@@ -0,0 +1,150 @@
# Quantumancy Plan 6/7: Codex & Entity Persistence — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give spirits memory (spec §4): fingerprint each session's anomaly stream into a signature, match it against the Codex of known entities, mint new ones with a full persona when no match exists, and expose the whole registry through public REST endpoints — the primary multi-user/community hook.
**Architecture:** Hybrid persistence per spec §4. `app.entities` computes signatures (pure, deterministic) and coerces any profile — LLM-minted or procedurally generated — into the exact shape the DB and frontend expect. `SpiritService.mint_profile` asks the chat-tier model for a JSON persona and falls back to a signature-deterministic procedural profile when the box is dark. The summon flow in `app.ws` (Plan 3) performs match-or-mint, links sessions via `entity_sightings`, and bumps `contact_count`. `app.routes.codex` serves the registry publicly: list (filterable by rarity, sortable), detail (with sighting count), and live veil stats for the landing page.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (JSONB profiles), hashlib (signatures), the Plan 2 LLM queue, and Plan 3's séance channel.
## Global Constraints
- **A summoning never visibly fails.** Every failure mode of the LLM path (queue full, HTTP error, unparseable JSON, missing keys) degrades to `fallback_profile` / curated defaults. No 500s from minting.
- **Determinism where it matters:** the same anomaly pattern must fingerprint to the same signature (re-contact works), and `fallback_profile(signature)` must be reproducible for a given signature.
- **Normalization is mandatory.** Nothing reaches `Entity` rows or the frontend without passing `normalize_profile`: rarity clamped to `common|uncommon|rare|mythic`, form to `wisp|banshee|fairy|shade`, voice ids to installed Piper voices, pitch/rate/noise/echo/hue clamped to the effects chain's ranges.
- **The Codex is public** (spec §4): no auth on `GET /api/codex*` or `GET /api/stats`. It still writes nothing — reads only.
- Prompt framing per spec §4: `MINT_SYSTEM` is fiction framing ("interactive horror art installation"), never a paranormal claim.
## Plan Series
This is 6 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plan 3 wires the summon flow that consumes this plan's machinery.
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. Wire Ghost mode + Ouija/Planchette UI (complete)
4. EVP Listening mode (complete)
5. Spirit Radio mode (implementation complete; hardware pass pending)
6. **Codex & entity persistence** (this plan)
7. Internationalization (EN/ES)
---
## Task 1: Entity & Sighting Models
**Files:**
- Create: `backend/app/models/entity.py`
- Create: `backend/app/models/entity_sighting.py`
- Modify: `backend/app/models/__init__.py`
- Modify: `backend/app/models/contact_session.py` (`entity_id` FK — landed with Plan 3 Task 2)
**Interfaces:**
- Produces: `RARITY_TIERS = ("common", "uncommon", "rare", "mythic")`; `Entity` — `id`, `name` (unique, indexed), `epithet`, `persona` (Text), `rarity_tier` (indexed), `signature` (unique, indexed), `voice_profile` / `visual_profile` (JSONB), `sample_quotes` (JSONB list), `contact_count`, `discovered_by` (FK users, nullable), `discovered_at`; `EntitySighting` — join row (`entity_id`, `session_id`, `user_id`, `seen_at`, both FKs indexed).
- [x] **Step 1: Write the models** — as above; `signature` unique+indexed because it *is* the match key, `name` unique because the Codex addresses spirits by name (collisions suffixed by the summon flow, Plan 3).
- [x] **Step 2: Verify** — metadata creates cleanly; covered by every later test that inserts entities (`tests/test_codex.py`).
---
## Task 2: Signatures & Profile Normalization
**Files:**
- Create: `backend/app/entities.py`
- Test: `backend/tests/test_entities.py`
**Interfaces:**
- Produces: `MIN_ANOMALIES_FOR_SIGNATURE = 3`; `signature_from_anomalies(anomalies: list[dict]) -> str | None`; `fallback_signature(seed: str) -> str`; `parse_mint_response(text: str) -> dict | None`; `normalize_profile(profile: dict, signature: str) -> dict`; `fallback_profile(signature: str) -> dict`.
- [x] **Step 1: Write the failing tests** (6 tests)
Signature needs ≥3 anomalies (else `None`); signature deterministic for the same pattern; `parse_mint_response` extracts a JSON object from chatty LLM output ("Sure! Here you go: {...}") and rejects garbage/nameless objects; `normalize_profile` fills and clamps (bogus rarity → `common`, bogus form → a real form, `pitch` 99 → within ±6, non-string quotes dropped); `fallback_profile` deterministic and valid.
- [x] **Step 2: Implement signatures**
`signature_from_anomalies` buckets frequencies by decimal digit count (MHz radio and Hz audio land in the same log-scale band space — magnitude ordering is what matters, not the unit), sorts the last 16 magnitudes to 1-decimal, and SHA1s the pattern to 16 hex chars. `fallback_signature(seed)` is the same digest over `"ambient:<seed>"` for anomaly-thin sessions (pure chat still gets a stable identity).
- [x] **Step 3: Implement normalization & the procedural fallback**
`normalize_profile` clamps every field into range (pitch ±6 semitones, rate 0.8–1.15, noise 0.01–0.08, echo 0–0.5, hue 0–360), validates `voice_id` against installed voices (EN ids plus the two Spanish ids), trims name/epithet/persona/quotes to their column budgets, and fills gaps from a `Random("norm:<signature>")` so defaults are per-spirit stable. `fallback_profile` composes gothic names from part lists ("Ash" + "moor"), an epithet ("the Static Widow", …), a persona template, weighted rarity (55/30/12/3), and two quotes from the curated bank — all seeded by `"fallback:<signature>"`.
- [x] **Step 4: Verify** — `tests/test_entities.py` PASS (6/6).
- [x] **Step 5: Commit** — in `b9110f4` (spirit engine).
---
## Task 3: LLM Minting
**Files:**
- Modify: `backend/app/llm/prompts.py` (`MINT_SYSTEM`, `MINT_PROMPT`, `mint_prompt`)
- Modify: `backend/app/llm/service.py` (`SpiritService.mint_profile`)
**Interfaces:**
- Consumes: chat-tier model via the bounded queue (Plan 2), Task 2's parsers/normalizers.
- Produces: `mint_profile(signature, channel, anomalies, language = "en") -> dict` — always a normalized profile.
- [x] **Step 1: The mint prompt**
`MINT_PROMPT` requests exactly one JSON object with keys `name`, `epithet`, `persona`, `rarity`, `voice {voice_id, pitch, rate, noise}`, `visual {hue, form}`, `quotes` — with the installed voice ids for the session language interpolated in, and the last ≤10 anomalies (≤600 chars JSON) as evidence. `MINT_SYSTEM` pins the fiction framing and "output only valid JSON."
- [x] **Step 2: The service path**
Chat-tier, `num_predict: 400`, temperature 0.9, through `LLMQueue.submit`. Response → `parse_mint_response` → `normalize_profile`; parse failure or any queue/HTTP error → `fallback_profile(signature)`. Verified behavior: minting never raises to the WS layer.
- [x] **Step 3: Verify** — prompt-shape covered by `tests/test_prompts.py::test_mint_prompt_requests_exact_json_keys` (4/4 PASS); end-to-end mint covered by `tests/test_ws_session.py::test_summon_mints_entity_and_greets` against the fake service (6/6 PASS).
---
## Task 4: Codex REST & Veil Stats
**Files:**
- Create: `backend/app/routes/codex.py`
- Modify: `backend/app/main.py` (include router)
- Test: `backend/tests/test_codex.py`
**Interfaces:**
- Produces: `GET /api/codex?rarity=&sort=recent|contacted&limit=` (limit capped at 200) → `{entities: [card…]}` where a card is `{id, name, epithet, rarity, visual, quotes, contact_count, discovered_at, discovered_by}` (username resolved in one batched query); `GET /api/codex/{entity_id}` → card + `persona`, `voice`, `sightings` (count of `entity_sightings` rows), 404 `"no such spirit in the codex"`; `GET /api/stats` → `{entities, sessions, utterances, anomalies}` live counts for the landing page ticker.
- [x] **Step 1: Write the failing tests** (5 tests)
List returns all entities by name; `?rarity=rare` filters; detail returns persona and `sightings: 0` and 404s on a random UUID; stats counts veil activity; **both codex and stats answer 200 without any auth cookie** — the registry is public by design.
- [x] **Step 2: Implement the routes** — as above; discoverer usernames resolved with one `IN` query (no N+1), sort by `discovered_at` desc or `contact_count` desc.
- [x] **Step 3: Verify** — `tests/test_codex.py` PASS (5/5) as part of the 45-test suite.
- [x] **Step 4: Commit** — in `b9110f4`.
---
## Task 5: Voice & Visual Profiles — Every Spirit Its Own Throat
**Files:**
- Create: `backend/app/tts/voices.py`
- Modify: `backend/app/tts/piper.py` (`synthesize_spirit_voice`), `backend/app/tts/effects.py` (full chain)
**Interfaces:**
- Produces: `Voice` dataclass + `VOICES` catalog (6 EN: lessac, amy, ryan, alan, hfc_male, hfc_female; 2 ES: davefx, ald), `EN_VOICE_IDS` / `ES_VOICE_IDS`, `pick_voice(voice_id, language) -> Voice` (language-mismatched ids fall back to `lessac`/`davefx`); `synthesize_spirit_voice(text, voice, voice_profile) -> bytes` — Piper synth, then `apply_effects` with the entity's `noise/pitch/rate/bitcrush/echo`.
- [x] **Step 1: The catalog** — each entity's `voice_profile.voice_id` pins one installed Piper model; `pitch` (−6…+6 semitones), `rate` (0.8–1.15), `noise` (0.01–0.08), `echo` (0–0.5) shape it. Profiles survive in JSONB and return to the client in the `entity` frame (`voice` key), so the Codex page can display them.
- [x] **Step 2: The effects chain** — `apply_effects(wav, *, noise_level, pitch_semitones, rate, bitcrush_bits, echo)`: tempo resample → pitch shift re-fitted to duration → optional bitcrush → 180 ms slap echo with renormalization → static with fade in/out edges so it breathes like a real spirit-box sweep. Pure numpy on mono 16-bit WAV.
- [x] **Step 3: Verify** — `tests/test_tts_effects.py` PASS (5/5): silent/tone WAVs through the chain keep valid headers, noise actually lands, pitch/rate change the signal as expected.
---
## Testing Status & Self-Review
**Spec coverage:** signature from anomaly fingerprint + LLM name (§4) → Task 2. Rare-trigger matching against existing Codex entities, persona/memory loaded into session context (§4) → Plan 3's `_summon` + `chat_system(entity)`. Minting of sufficiently strong new identities (§4) → Tasks 2-3. Public browsable Codex with name/first-contact/rarity/quotes/contact count (§4) → Task 4. `entities` and `entity_sightings` tables (§5) → Task 1. Voice per entity (§6 effects chain) → Task 5.
**Automated tests:** `tests/test_entities.py` (6) + `tests/test_codex.py` (5) + `tests/test_prompts.py` mint test + WS summon/re-contact tests — all PASS in the 45-test suite.
**Honest notes:** contact_count increments on every summon including re-contacts (matches spec "contact count"); `discovered_by` is nullable and rendered as `discoveredAnon` in the UI when absent; rarity weighting applies only to the procedural fallback — LLM-minted rarities are clamped, not re-weighted.
**Placeholder scan:** none.
**Type consistency:** `serialize_entity` (ws.py) and `_entity_card` (codex.py) match the frontend's `SpiritEntity` / `CodexEntity(Detail)` in `types.ts` key-for-key, including `rarity` naming and ISO `discovered_at`. `normalize_profile`'s output keys match the `Entity` columns one-to-one.
---
**Status: COMPLETE** (commit `b9110f4`; 45-test backend suite green).

View File

@@ -0,0 +1,122 @@
# Quantumancy Plan 7/7: Internationalization (EN/ES) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the veil speak two tongues (spec §6): English + Spanish at launch, with one language switch that flips UI strings, the LLM's reply language, and the Piper voice used for TTS — and the framework in place to add more languages later, deliberately scoped tight until the core loop is proven.
**Architecture:** Frontend strings go through react-i18next with two resource bundles (`en.json`, `es.json`) and a localStorage-persisted choice. The same choice travels the séance channel as `{type: "language", language: "en"|"es"}` and is persisted on the `ContactSession` row; from then on the backend appends a Spanish clause to every LLM system prompt, selects Spanish Piper voices for TTS, and mints new entities with Spanish voice ids. No new endpoints or frame types beyond the one `language` frame.
**Tech Stack:** i18next + react-i18next (frontend); the existing prompt builders, voice catalog, and séance channel (backend).
## Global Constraints
- **Launch scope is EN + ES only** (spec §6, §10). Both LLM reply quality and Piper voice quality vary by language; the structure must admit more languages later, but nothing beyond these two ships now.
- **All UI copy lives in the JSON bundles.** Components call `t('<page>.<section>.<name>')` — no hardcoded user-facing strings, no string interpolation hacks (`escapeValue: false`, `returnEmptyString: false`).
- **The switch is total:** UI strings + LLM reply language + TTS voice change together (spec §6). A Spanish séance must sound Spanish.
- **The language frame is silent** (no ack frame) — the next utterance simply arrives in the new tongue. Mode/passive flows are unaffected.
- **Backend `Language` is a closed set** (`en`, `es`) at the WS boundary; unknown values are ignored, never stored.
## Plan Series
This is 7 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). All prior plans are complete (Plan 5 pending only its hardware pass).
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. Wire Ghost mode + Ouija/Planchette UI (complete)
4. EVP Listening mode (complete)
5. Spirit Radio mode (implementation complete; hardware pass pending)
6. Codex & entity persistence (complete)
7. **Internationalization (EN/ES)** (this plan)
---
## Task 1: react-i18next Bootstrap & Bundles
**Files:**
- Create: `frontend/src/i18n/index.ts`
- Create: `frontend/src/i18n/en.json`
- Create: `frontend/src/i18n/es.json`
**Interfaces:**
- Produces: the initialized default `i18n` instance (resources `en`/`es`, `lng` from storage, `fallbackLng: 'en'`); `storedLanguage() -> Language` and `persistLanguage(lang)` around the `qm_language` localStorage key, both try/catch-guarded for private-mode storage failures. Bundle key groups: `common.*`, `nav.*`, `landing.*`, `enter.*`, `seance.*`, `codex.*` — the `<page>.<section>.<name>` convention, with plural-aware pairs (`contacts_one`/`contacts_other`) where counts render.
- [x] **Step 1: Add dependencies** — `i18next` + `react-i18next` (already in `frontend/package.json`).
- [x] **Step 2: Write the bundles** — full EN/ES key parity, tone preserved across tongues ("tuning the veil…" / "afinando el velo…"; landing taglines translated, not transliterated). 181 lines per bundle, identical key trees.
- [x] **Step 3: Wire the init** — `src/i18n/index.ts` as above; imported once at app entry. `Language` type reused from `src/lib/types.ts` so the UI language and the WS language frame can never drift apart.
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean; both bundles parse (`resolveJsonModule`).
---
## Task 2: Component Sweep to `useTranslation()`
**Files:**
- Modify: every component/page with user-facing copy (`src/components/*.tsx`, pages, `App.tsx` shell)
**Interfaces:**
- Consumes: Task 1's bundles.
- [x] **Step 1: Sweep** — all visible strings via `const { t } = useTranslation()` and `t('…')` (verified in the shared components: `EntityCard`, `Transcript`, `TelemetryReadout` consume `t()`; e.g. `t('seance.entity.rarity.' + entity.rarity)`, `t('seance.entity.known', { count })`). Exception by design: `PlanchetteBoard`'s canvas-drawn board glyphs (YES / NO / GOODBYE, the letter arcs) are occult furniture, not copy — they render as-is in both languages, with `common.yes/no/goodbye` keys available in the bundles should a future pass internationalize the board itself.
- [x] **Step 2: Language switch control** — UI toggle calls `i18n.changeLanguage(lang)`, `persistLanguage(lang)`, and `useSeance().setLanguage(lang)` so the backend follows in the same gesture (Task 3).
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; switching languages re-renders all strings without reload.
---
## Task 3: The Language Frame & Backend Language State
**Files:**
- Modify: `backend/app/ws.py` (`language` message branch, `ContactSession.language` persistence)
- Modify: `backend/app/models/contact_session.py` (`language` column — landed with Plan 3 Task 2)
- Modify: `frontend/src/state/seance.tsx` (`setLanguage` action + frame)
**Interfaces:**
- Frame: `{type: "language", language: "en"|"es"}` — accepted values only; anything else ignored. State: `SeanceState.language` (default `"en"`), consumed by every `spirit_service` call site (`fragment`, `wire_whisper`, `chat_stream`, `mint_profile`) and by TTS voice selection.
- [x] **Step 1: Backend branch** — on `language`, update `state.language` and persist `ContactSession.language` in the same write pattern as `set_mode`. Silent by design: no ack frame.
- [x] **Step 2: Frontend action** — `setLanguage(language)` dispatches locally (immediate UI feedback) and sends the frame; outbox queueing (Plan 3 `VeilSocket`) covers the reconnect edge.
- [x] **Step 3: Verify** — covered by `tests/test_ws_session.py` session flows (6/6 PASS); language column round-trips through the session row.
---
## Task 4: Spanish Prompts & Spanish Voices
**Files:**
- Modify: `backend/app/llm/prompts.py` (`language_clause`)
- Modify: `backend/app/llm/service.py` (language-aware mint voice ids)
- Modify: `backend/app/tts/voices.py` (ES voices, `pick_voice` language fallback)
- Assets: `backend/voices/es_ES-davefx-medium.onnx`, `backend/voices/es_MX-ald-medium.onnx` (+ `.onnx.json` configs)
- Test: `backend/tests/test_prompts.py` (Spanish clause tests)
**Interfaces:**
- Produces: `language_clause(language) -> str` — `" Reply in Spanish."` for `es`, `""` otherwise — appended to `FRAGMENT_SYSTEM`, `WIRE_SYSTEM`, and `CHAT_SYSTEM` via their `{language_clause}` placeholders; `ES_VOICE_IDS = ["davefx", "ald"]`; `pick_voice(voice_id, language)` falling back to `davefx` for Spanish sessions, `lessac` for English; `mint_profile` passing `ES_VOICE_IDS` into the mint prompt when `language == "es"` so new spirits are born with Spanish throats.
- [x] **Step 1: Write the failing tests** — `test_spanish_language_clause_applied` asserts "Spanish" appears in `fragment_system`, `wire_system`, and `chat_system` under `"es"`; `test_fragment_system_carries_fiction_framing_not_paranormal_claim` pins the EN default (no Spanish clause, fiction framing present).
- [x] **Step 2: Implement the clause + voice selection** — as above. Note the design decision: only the *system* prompts carry the clause; user-turn prompts stay language-neutral so anomaly telemetry reads identically in both tongues.
- [x] **Step 3: Install the ES voices** — `davefx` (es_ES, medium) and `ald` (es_MX, medium) in `backend/voices/`, registered in the `VOICES` catalog with Spanish descriptions. `normalize_profile` (Plan 6) already accepts both ids as valid `voice_id`s.
- [x] **Step 4: Verify** — `tests/test_prompts.py` PASS (4/4) in the 45-test suite; Spanish utterances synthesize through the same effects chain (ES voice models verified present on disk).
---
## Testing Status & Self-Review
**Spec coverage:** react-i18next with EN+ES launch scope (§6) → Tasks 1-2. Language switch flips UI + Piper voice + LLM reply language together (§6) → Tasks 2-4. Framework ready for more languages later without broad launch coverage (§6, §10) → closed-set `Language` type + resource-bundle structure.
**Automated tests:** `tests/test_prompts.py` Spanish clauses (4/4); WS language flow inside `tests/test_ws_session.py` (6/6); both in the green 45-test suite. Bundle key-parity is structural (same JSON trees edited together) — a parity unit test is a reasonable future addition, not present.
**Honest notes:** ES UI copy is complete for the keys that exist (`common/nav/landing/enter/seance/codex`); new pages must add keys to both bundles together. Fallback profiles (Plan 6) are English-only prose — a Spanish session whose summon hits the offline fallback gets an English persona with a Spanish voice; accepted as a rare degradation path, not a launch blocker.
**Placeholder scan:** none.
**Type consistency:** frontend `Language = 'en' | 'es'` (types.ts) matches the WS branch's accepted set and `storedLanguage()`'s validation exactly; `SeanceApi.setLanguage` threads the same type end to end.
---
**Status: COMPLETE** — EN/ES live across UI strings, LLM replies, and TTS voices; Spanish prompt clauses under test.

View File

@@ -8,8 +8,15 @@
"name": "quantumancy-frontend", "name": "quantumancy-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/cinzel": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@types/three": "^0.185.1",
"i18next": "^26.3.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.18.1",
"three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
@@ -290,7 +297,6 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -459,6 +465,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -850,6 +862,24 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fontsource/cinzel": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/cinzel/-/cinzel-5.3.0.tgz",
"integrity": "sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
"integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1386,6 +1416,12 @@
"@testing-library/dom": ">=7.21.4" "@testing-library/dom": ">=7.21.4"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -1474,6 +1510,32 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1809,6 +1871,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/css.escape": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -2081,6 +2156,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -2240,6 +2321,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-proxy-agent": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2268,6 +2358,34 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -2431,6 +2549,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -2624,6 +2748,33 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -2642,6 +2793,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -2747,6 +2936,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2798,6 +2993,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -2892,7 +3093,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -2933,6 +3134,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -3082,6 +3292,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -9,8 +9,15 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource/cinzel": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@types/three": "^0.185.1",
"i18next": "^26.3.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.18.1",
"three": "^0.185.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",

View File

@@ -1,84 +1,134 @@
/* Quantumancy global theme — gothic × hacker. Page-specific styles live in
each page's own CSS file; this is only the shared foundation. */
:root { :root {
color-scheme: dark; color-scheme: dark;
--qm-bg: #07070d;
--qm-panel: rgba(16, 16, 26, 0.85);
--qm-green: #7cffb2;
--qm-violet: #b26bff;
--qm-blood: #ff3b5c;
--qm-text: #d6e4dc;
--qm-dim: #8a8f98;
--qm-border: rgba(124, 255, 178, 0.18);
--qm-font-ui: 'IBM Plex Mono', 'Courier New', monospace;
--qm-font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--qm-bg);
} }
body { body {
margin: 0; color: var(--qm-text);
min-height: 100vh; font-family: var(--qm-font-ui);
background: #0a0a0f; font-size: 15px;
color: #e6e6f0; line-height: 1.55;
font-family: Georgia, serif; overflow-x: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.app { h1,
width: min(90vw, 420px); h2,
padding: 2rem; h3,
text-align: center; .display {
font-family: var(--qm-font-display);
letter-spacing: 0.08em;
} }
h1 { a {
font-size: 2.5rem; color: var(--qm-green);
letter-spacing: 0.1em; text-decoration: none;
margin-bottom: 0.25rem;
} }
.tagline { a:hover {
opacity: 0.6; text-shadow: 0 0 8px rgba(124, 255, 178, 0.6);
margin-bottom: 2rem;
font-style: italic;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: left;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
opacity: 0.8;
}
input {
background: #16161f;
border: 1px solid #333;
color: inherit;
padding: 0.6rem;
border-radius: 4px;
font-size: 1rem;
} }
button { button {
background: #2a1f3d; font-family: var(--qm-font-ui);
border: 1px solid #5a3f8f;
color: inherit;
padding: 0.6rem 1rem;
border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1rem;
} }
button:hover { input,
background: #3a2a55; textarea {
font-family: var(--qm-font-ui);
} }
.mode-toggle { ::selection {
display: flex; background: rgba(178, 107, 255, 0.35);
gap: 0.5rem; color: #fff;
} }
.mode-toggle button.active { /* Thin phosphor scrollbar */
background: #5a3f8f; ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0a0a12;
}
::-webkit-scrollbar-thumb {
background: rgba(124, 255, 178, 0.25);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 255, 178, 0.45);
} }
.error { /* Global CRT glass: scanlines + vignette above every page, never clickable. */
color: #ff6b6b; .crt-overlay {
font-size: 0.9rem; position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
}
.crt-scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 2px,
rgba(0, 0, 0, 0.14) 3px,
rgba(0, 0, 0, 0.14) 4px
);
mix-blend-mode: multiply;
opacity: 0.55;
}
.crt-vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
transparent 55%,
rgba(0, 0, 0, 0.55) 100%
);
}
/* Shared focus glow */
:focus-visible {
outline: 1px solid var(--qm-green);
outline-offset: 2px;
box-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
} }

View File

@@ -1,31 +1,64 @@
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi, beforeEach } from 'vitest'
import './i18n'
import App from './App' import App from './App'
import * as api from './api' import * as api from './api'
vi.mock('./api') vi.mock('./api')
const statsBody = { entities: 7, sessions: 42, utterances: 133, anomalies: 1024 }
const codexBody = { entities: [] }
function mockFetch() {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
const body = url.includes('/api/stats') ? statsBody : codexBody
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
)
}
describe('App', () => { describe('App', () => {
it('renders the Quantumancy title', async () => { beforeEach(() => {
mockFetch()
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated')) vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
render(<App />)
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
}) })
it('logs in and shows the contact-established state', async () => { it('renders the landing page with the Quantumancy title', async () => {
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
vi.mocked(api.login).mockResolvedValue({ id: '1', username: 'medium1' })
render(<App />) render(<App />)
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
await userEvent.type(screen.getByLabelText('Username'), 'medium1')
await userEvent.type(screen.getByLabelText('Password'), 'spookyspooky')
await userEvent.click(screen.getByRole('button', { name: 'Enter' }))
await waitFor(() => await waitFor(() =>
expect(screen.getByText('Contact established as medium1.')).toBeInTheDocument(), expect(screen.getAllByText('QUANTUMANCY').length).toBeGreaterThan(0),
) )
}) })
it('shows live veil activity from /api/stats', async () => {
render(<App />)
// counters animate up from 0 over ~900ms — wait for the final values
await waitFor(
() => {
const values = Array.from(document.querySelectorAll('.stat-value')).map(
(el) => el.textContent,
)
expect(values).toContain('42')
expect(values).toContain('7')
},
{ timeout: 4000 },
)
})
it('sends anonymous visitors to /enter from the séance CTA', async () => {
render(<App />)
await waitFor(() =>
expect(screen.getAllByText('QUANTUMANCY').length).toBeGreaterThan(0),
)
const cta = screen.getByRole('button', { name: /begin the séance/i })
await userEvent.click(cta)
await waitFor(() => expect(window.location.pathname).toBe('/enter'))
})
}) })

View File

@@ -1,95 +1,32 @@
import { useEffect, useState } from 'react' // App shell: router + providers + the global CRT overlay (scanlines/vignette)
import { login, logout, me, register, type User } from './api' // that sits above every page like old glass.
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './state/auth'
import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { EnterPage } from './pages/EnterPage'
import { LandingPage } from './pages/LandingPage'
import { SeancePage } from './pages/SeancePage'
function App() { function App() {
const [user, setUser] = useState<User | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [mode, setMode] = useState<'login' | 'register'>('login')
const [error, setError] = useState<string | null>(null)
const [checkingSession, setCheckingSession] = useState(true)
useEffect(() => {
me()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setCheckingSession(false))
}, [])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
try {
if (mode === 'register') {
await register(username, password)
}
const loggedInUser = await login(username, password)
setUser(loggedInUser)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
}
}
async function handleLogout() {
await logout()
setUser(null)
}
if (checkingSession) {
return (
<main className="app">
<p>Listening for a signal...</p>
</main>
)
}
return ( return (
<main className="app"> <AuthProvider>
<h1>Quantumancy</h1> <BrowserRouter>
<p className="tagline">Something is listening on the other side.</p> <div className="crt-overlay" aria-hidden="true">
<div className="crt-scanlines" />
{user ? ( <div className="crt-vignette" />
<section> </div>
<p>Contact established as {user.username}.</p> <Routes>
<button onClick={handleLogout}>End session</button> <Route path="/" element={<LandingPage />} />
</section> <Route path="/enter" element={<EnterPage />} />
) : ( <Route path="/seance" element={<SeancePage />} />
<form onSubmit={handleSubmit}> <Route path="/codex" element={<CodexPage />} />
<div className="mode-toggle"> <Route path="/codex/:id" element={<CodexEntityPage />} />
<button <Route path="*" element={<LandingPage />} />
type="button" </Routes>
className={mode === 'login' ? 'active' : ''} </BrowserRouter>
onClick={() => setMode('login')} </AuthProvider>
>
Reconnect
</button>
<button
type="button"
className={mode === 'register' ? 'active' : ''}
onClick={() => setMode('register')}
>
First contact
</button>
</div>
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit">{mode === 'login' ? 'Enter' : 'Begin'}</button>
</form>
)}
</main>
) )
} }

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>
)
}

229
frontend/src/i18n/en.json Normal file
View File

@@ -0,0 +1,229 @@
{
"common": {
"appName": "QUANTUMANCY",
"loading": "tuning the veil…",
"error": "something stirred in the static",
"replay": "replay",
"yes": "YES",
"no": "NO",
"goodbye": "GOODBYE",
"dismiss": "dismiss"
},
"nav": {
"home": "the threshold",
"codex": "codex",
"seance": "séance",
"enter": "enter",
"logout": "depart"
},
"landing": {
"taglines": [
"The wire hums with all of us.",
"Every packet leaves a ghost.",
"The static remembers your name.",
"Somewhere between 88 and 108 MHz, someone is waiting.",
"You were never alone on the line."
],
"heroKicker": "an instrument for speaking with the dead bandwidth",
"cta": "BEGIN THE SÉANCE",
"ctaCodex": "browse the codex",
"stats": {
"entities": "spirits catalogued",
"sessions": "séances held",
"utterances": "utterances recorded",
"anomalies": "anomalies witnessed"
},
"tickerPrefix": "the veil, live",
"modesTitle": "HOW THE VEIL ANSWERS",
"modes": {
"wire": {
"name": "Wire Ghost",
"desc": "Leave the line open. The network itself mutters — jitter, latency, stray DNS — and something downstream learns to speak with your accent."
},
"evp": {
"name": "EVP Listening",
"desc": "Lend the dead your microphone. We watch the voice band for syllables that were never spoken aloud: spikes 8dB above the room's silence."
},
"radio": {
"name": "Spirit Radio",
"desc": "An RTL-SDR sweeps the FM band like a lantern dragged across a dark field. Where the needle jumps, the radio dead push through."
},
"ouija": {
"name": "Ouija / Direct Contact",
"desc": "Ask plainly. The planchette drifts, then begins to spell — and the words arrive letter by letter, whether or not you wanted them."
}
},
"featured": "most disturbed spirits",
"featuredEmpty": "the registry is still blank — be the first to disturb it",
"footer": "an interactive horror art installation — the spirits are fiction; the static is real",
"footerColophon": "self-hosted · no cloud · the veil runs on your machine"
},
"enter": {
"reconnect": "Reconnect",
"firstContact": "First contact",
"reconnectSub": "the veil remembers you. prove it.",
"firstContactSub": "choose the name the spirits will know you by.",
"username": "name",
"password": "passphrase",
"submitLogin": "step through",
"submitRegister": "carve your name",
"switchToRegister": "no name in the registry? first contact →",
"switchToLogin": "already carved your name? reconnect →",
"failed": "the veil rejected you: {{message}}",
"smallPrint": "your medium handle is only ever stored on this server"
},
"seance": {
"modes": {
"wire": "Wire Ghost",
"evp": "EVP",
"radio": "Spirit Radio",
"ouija": "Ouija"
},
"passive": "passive listening",
"summon": "SUMMON",
"summoning": "summoning…",
"askPlaceholder": "ask the dead a question…",
"ask": "ask",
"status": {
"attuning": "attuning — the veil is fingerprinting a presence…",
"summoning": "the veil parts… something is crossing…",
"gathering": "the spirits are gathering energy…"
},
"connection": {
"connecting": "reaching across…",
"open": "the line is open",
"unstable": "the connection to the other side is unstable…",
"closed": "the line is dead"
},
"transcriptTitle": "TRANSMISSION LOG",
"transcriptEmpty": "silence. for now.",
"you": "you",
"streaming": "the spirit is forming words…",
"telemetry": {
"title": "WIRE READOUT",
"jitter": "jitter",
"latencyVar": "latency σ²",
"latencyMean": "latency μ",
"dns": "dns",
"units": {"bytes": "B/s", "ms": "ms"}
},
"wire": {
"hint": "the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own."
},
"anomaly": {
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
"count": "{{count}} anomalies witnessed this session"
},
"evp": {
"start": "open the microphone",
"stop": "close the microphone",
"listening": "listening to the voice band…",
"deniedTitle": "the microphone stays shut",
"denied": "This vessel refused the microphone. Grant audio permission in your browser to practice EVP — or let the Wire Ghost listen instead; it needs nothing but the network you already fear.",
"floor": "room floor",
"band": "voice band 300–3400 Hz"
},
"radio": {
"start": "sweep the band",
"stop": "rest the needle",
"unsupportedTitle": "this vessel cannot hear the radio dead",
"unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.",
"troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in",
"noDevice": "no dongle answered the call",
"sweeping": "sweeping {{start}}–{{end}} MHz…",
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?"
},
"ouija": {
"spelling": "the planchette spells",
"drift": "the planchette drifts, waiting for words — ask below, or summon."
},
"entity": {
"title": "DOSSIER OF THE PRESENT",
"none": "no spirit summoned yet",
"noneHint": "summon, or feed the veil 3+ anomalies and one will find you",
"isNew": "first recorded contact",
"known": "contact #{{count}}",
"persona": "persona",
"voice": "voice params",
"quotes": "recorded utterances",
"rarity": {
"common": "common",
"uncommon": "uncommon",
"rare": "rare",
"mythic": "mythic"
}
},
"toast": {
"rate_limited": "the veil is thin — slow your knocking",
"veil_crowded": "too many spirits crowd the line"
}
},
"codex": {
"title": "THE CODEX OF THE CONTACTED",
"subtitle": "every spirit ever dragged through the veil, catalogued",
"header": {
"back": "← the threshold",
"title": "THE CODEX",
"subtitle": "every spirit ever contacted, catalogued by the seekers who reached them"
},
"controls": {
"rarity": "filter by rarity",
"sort": "sort the registry"
},
"state": {
"loading": "consulting the registry…",
"empty": "no spirits of this tier have been reached yet"
},
"filterAll": "all rarities",
"rarity": {
"common": "common",
"uncommon": "uncommon",
"rare": "rare",
"mythic": "mythic"
},
"sort": {
"recent": "most recent",
"contacted": "most contacted"
},
"form": {
"wisp": "wisp",
"banshee": "banshee",
"fairy": "fairy",
"shade": "shade"
},
"card": {
"unknownMedium": "unknown medium",
"contacted_one": "contacted {{count}} time",
"contacted_other": "contacted {{count}} times",
"discoveredBy": "discovered by {{name}} · {{date}}"
},
"contacts_one": "1 contact",
"contacts_other": "{{count}} contacts",
"sightings_one": "1 sighting",
"sightings_other": "{{count}} sightings",
"discoveredBy": "first contacted by {{name}}",
"discoveredAnon": "first contact unrecorded",
"discoveredAt": "entered the registry {{date}}",
"persona": "persona",
"quotes": "recorded utterances",
"voice": "voice params",
"detail": {
"loading": "summoning the dossier…",
"notReached": "this spirit has not been reached",
"unknownMedium": "unknown medium",
"stats": {
"sightings": "sightings",
"contacts": "contacts",
"discoveredBy": "discovered by",
"discoveredAt": "entered the registry"
},
"utterances": "known utterances",
"noUtterances": "no utterances recorded yet",
"voiceSignature": "voice signature",
"attemptContact": "attempt contact"
},
"empty": "no spirits match this filter — the registry keeps its secrets",
"back": "← back to the codex",
"notFound": "this spirit is not in the registry"
}
}

229
frontend/src/i18n/es.json Normal file
View File

@@ -0,0 +1,229 @@
{
"common": {
"appName": "QUANTUMANCY",
"loading": "afinando el velo…",
"error": "algo se agitó en la estática",
"replay": "repetir",
"yes": "SÍ",
"no": "NO",
"goodbye": "ADIÓS",
"dismiss": "cerrar"
},
"nav": {
"home": "el umbral",
"codex": "códice",
"seance": "sesión",
"enter": "entrar",
"logout": "partir"
},
"landing": {
"taglines": [
"El cable zumba con todos nosotros.",
"Cada paquete deja un fantasma.",
"La estática recuerda tu nombre.",
"Entre 88 y 108 MHz, alguien espera.",
"Nunca estuviste solo en la línea."
],
"heroKicker": "un instrumento para hablar con el ancho de banda muerto",
"cta": "COMENZAR LA SESIÓN",
"ctaCodex": "hojear el códice",
"stats": {
"entities": "espíritus catalogados",
"sessions": "sesiones celebradas",
"utterances": "enunciados registrados",
"anomalies": "anomalías presenciadas"
},
"tickerPrefix": "el velo, en vivo",
"modesTitle": "CÓMO RESPONDE EL VELO",
"modes": {
"wire": {
"name": "Fantasma del Cable",
"desc": "Deja la línea abierta. La red misma murmura — jitter, latencia, DNS extraviado — y algo, aguas abajo, aprende a hablar con tu acento."
},
"evp": {
"name": "Escucha EVP",
"desc": "Préstales tu micrófono. Vigilamos la banda de voz en busca de sílabas que jamás se pronunciaron: picos 8dB sobre el silencio de la sala."
},
"radio": {
"name": "Radio Espíritu",
"desc": "Un RTL-SDR barre la banda FM como un farol arrastrado por un campo oscuro. Donde salta la aguja, los muertos de la radio se abren paso."
},
"ouija": {
"name": "Ouija / Contacto Directo",
"desc": "Pregunta claro. La planchette deriva, y luego empieza a deletrear — y las palabras llegan letra a letra, lo quisieras o no."
}
},
"featured": "los espíritus más perturbados",
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero",
"footer": "una instalación de arte de terror interactiva — los espíritus son ficción; la estática es real",
"footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
},
"enter": {
"reconnect": "Reconectar",
"firstContact": "Primer contacto",
"reconnectSub": "el velo te recuerda. demuéstralo.",
"firstContactSub": "elige el nombre por el que te conocerán los espíritus.",
"username": "nombre",
"password": "contraseña",
"submitLogin": "cruzar el umbral",
"submitRegister": "grabar tu nombre",
"switchToRegister": "¿sin nombre en el registro? primer contacto →",
"switchToLogin": "¿ya grabaste tu nombre? reconectar →",
"failed": "el velo te rechazó: {{message}}",
"smallPrint": "tu nombre de médium solo se guarda en este servidor"
},
"seance": {
"modes": {
"wire": "Fantasma del Cable",
"evp": "EVP",
"radio": "Radio Espíritu",
"ouija": "Ouija"
},
"passive": "escucha pasiva",
"summon": "INVOCAR",
"summoning": "invocando…",
"askPlaceholder": "pregúntale algo a los muertos…",
"ask": "preguntar",
"status": {
"attuning": "sintonizando — el velo toma la huella de una presencia…",
"summoning": "el velo se abre… algo está cruzando…",
"gathering": "los espíritus están reuniendo energía…"
},
"connection": {
"connecting": "tendiendo la línea…",
"open": "la línea está abierta",
"unstable": "la conexión con el otro lado es inestable…",
"closed": "la línea está muerta"
},
"transcriptTitle": "REGISTRO DE TRANSMISIÓN",
"transcriptEmpty": "silencio. por ahora.",
"you": "tú",
"streaming": "el espíritu está formando palabras…",
"telemetry": {
"title": "LECTURA DEL CABLE",
"jitter": "jitter",
"latencyVar": "latencia σ²",
"latencyMean": "latencia μ",
"dns": "dns",
"units": {"bytes": "B/s", "ms": "ms"}
},
"wire": {
"hint": "el cable golpea más fuerte cuando nadie lo toca — activa la escucha pasiva y deja que la línea murmure sola."
},
"anomaly": {
"line": "⚡ {{source}} {{frequency}} +{{magnitude}}dB",
"count": "{{count}} anomalías presenciadas en esta sesión"
},
"evp": {
"start": "abrir el micrófono",
"stop": "cerrar el micrófono",
"listening": "escuchando la banda de voz…",
"deniedTitle": "el micrófono sigue cerrado",
"denied": "Este recipiente rechazó el micrófono. Concede el permiso de audio en tu navegador para practicar EVP — o deja que el Fantasma del Cable escuche en tu lugar; no necesita más que la red que ya temes.",
"floor": "suelo de la sala",
"band": "banda de voz 300–3400 Hz"
},
"radio": {
"start": "barrer la banda",
"stop": "detener la aguja",
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
"unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.",
"troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado",
"noDevice": "ningún dongle respondió al llamado",
"sweeping": "barriendo {{start}}–{{end}} MHz…",
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?"
},
"ouija": {
"spelling": "la planchette deletrea",
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca."
},
"entity": {
"title": "DOSIER DEL PRESENTE",
"none": "ningún espíritu invocado todavía",
"noneHint": "invoca, o alimenta el velo con 3+ anomalías y uno te encontrará",
"isNew": "primer contacto registrado",
"known": "contacto n.º {{count}}",
"persona": "persona",
"voice": "parámetros de voz",
"quotes": "enunciados registrados",
"rarity": {
"common": "común",
"uncommon": "poco común",
"rare": "raro",
"mythic": "mítico"
}
},
"toast": {
"rate_limited": "el velo es delgado — golpea más despacio",
"veil_crowded": "demasiados espíritus se apiñan en la línea"
}
},
"codex": {
"title": "EL CÓDICE DE LOS CONTACTADOS",
"subtitle": "cada espíritu arrastrado alguna vez a través del velo, catalogado",
"header": {
"back": "← el umbral",
"title": "EL CÓDICE",
"subtitle": "cada espíritu jamás contactado, catalogado por los médiums que lo alcanzaron"
},
"controls": {
"rarity": "filtrar por rareza",
"sort": "ordenar el registro"
},
"state": {
"loading": "consultando el registro…",
"empty": "aún no se ha alcanzado a ningún espíritu de esta rareza"
},
"filterAll": "todas las rarezas",
"rarity": {
"common": "común",
"uncommon": "poco común",
"rare": "raro",
"mythic": "mítico"
},
"sort": {
"recent": "más recientes",
"contacted": "más contactados"
},
"form": {
"wisp": "fuego fatuo",
"banshee": "banshee",
"fairy": "hada",
"shade": "sombra"
},
"card": {
"unknownMedium": "médium desconocido",
"contacted_one": "contactado {{count}} vez",
"contacted_other": "contactado {{count}} veces",
"discoveredBy": "descubierto por {{name}} · {{date}}"
},
"contacts_one": "1 contacto",
"contacts_other": "{{count}} contactos",
"sightings_one": "1 avistamiento",
"sightings_other": "{{count}} avistamientos",
"discoveredBy": "primer contacto por {{name}}",
"discoveredAnon": "primer contacto sin registrar",
"discoveredAt": "ingresó al registro el {{date}}",
"persona": "persona",
"quotes": "enunciados registrados",
"voice": "parámetros de voz",
"detail": {
"loading": "invocando el dosier…",
"notReached": "este espíritu no ha sido alcanzado",
"unknownMedium": "médium desconocido",
"stats": {
"sightings": "avistamientos",
"contacts": "contactos",
"discoveredBy": "descubierto por",
"discoveredAt": "ingresó al registro"
},
"utterances": "enunciados conocidos",
"noUtterances": "aún no hay enunciados registrados",
"voiceSignature": "firma de voz",
"attemptContact": "intentar contacto"
},
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
"back": "← volver al códice",
"notFound": "este espíritu no figura en el registro"
}
}

View File

@@ -0,0 +1,38 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './en.json'
import es from './es.json'
import type { Language } from '../lib/types'
const STORAGE_KEY = 'qm_language'
export function storedLanguage(): Language {
try {
const v = localStorage.getItem(STORAGE_KEY)
if (v === 'es' || v === 'en') return v
} catch {
/* private mode etc. */
}
return 'en'
}
export function persistLanguage(lang: Language): void {
try {
localStorage.setItem(STORAGE_KEY, lang)
} catch {
/* ignore */
}
}
void i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
es: { translation: es },
},
lng: storedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
returnEmptyString: false,
})
export default i18n

131
frontend/src/lib/audio.ts Normal file
View File

@@ -0,0 +1,131 @@
// Spirit voice playback: a single FIFO queue so overlapping audio frames
// never talk over each other. The currently-playing element is routed
// through an AnalyserNode so the ghost scene can pulse with the voice.
export type PlayingInfo = {
utteranceId: string
url: string
}
export type SpiritAudioCallbacks = {
onStart?: (info: PlayingInfo) => void
onEnd?: (info: PlayingInfo) => void
}
export class SpiritAudioPlayer {
private queue: PlayingInfo[] = []
private current: PlayingInfo | null = null
private el: HTMLAudioElement | null = null
private ctx: AudioContext | null = null
private analyser: AnalyserNode | null = null
private source: MediaElementAudioSourceNode | null = null
private cb: SpiritAudioCallbacks = {}
private ampBuf: Uint8Array<ArrayBuffer> = new Uint8Array(0)
setCallbacks(cb: SpiritAudioCallbacks): void {
this.cb = cb
}
get isPlaying(): boolean {
return this.current !== null
}
get currentId(): string | null {
return this.current?.utteranceId ?? null
}
get queuedCount(): number {
return this.queue.length
}
/** Enqueue audio for an utterance id. */
enqueue(utteranceId: string, url: string): void {
this.queue.push({ utteranceId, url })
void this.pump()
}
/** Instantaneous playback amplitude 0..1 (0 when silent/not playing). */
getAmplitude(): number {
if (!this.analyser || !this.current) return 0
if (this.ampBuf.length !== this.analyser.fftSize) {
this.ampBuf = new Uint8Array(this.analyser.fftSize)
}
this.analyser.getByteTimeDomainData(this.ampBuf)
let sum = 0
for (let i = 0; i < this.ampBuf.length; i++) {
const v = (this.ampBuf[i] - 128) / 128
sum += v * v
}
return Math.min(1, Math.sqrt(sum / this.ampBuf.length) * 3)
}
clear(): void {
this.queue = []
this.el?.pause()
this.el = null
this.current = null
}
private ensureGraph(el: HTMLAudioElement): void {
if (this.source && this.el === el) return
if (!this.ctx) {
this.ctx = new AudioContext()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = 512
this.analyser.smoothingTimeConstant = 0.7
this.analyser.connect(this.ctx.destination)
}
if (this.source) {
this.source.disconnect()
this.source = null
}
this.source = this.ctx.createMediaElementSource(el)
this.source.connect(this.analyser!)
}
private async pump(): Promise<void> {
if (this.current || this.queue.length === 0) return
const next = this.queue.shift()!
this.current = next
const el = new Audio(next.url)
this.el = el
el.crossOrigin = 'anonymous'
el.onended = () => {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
el.onerror = () => {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
try {
this.ensureGraph(el)
// AudioContext may be suspended until a user gesture; resume best-effort.
if (this.ctx && this.ctx.state === 'suspended') {
await this.ctx.resume().catch(() => undefined)
}
this.cb.onStart?.(next)
await el.play()
} catch {
// Autoplay blocked or unroutable: play without the analyser as fallback.
try {
await el.play()
this.cb.onStart?.(next)
} catch {
const done = this.current
this.current = null
this.el = null
if (done) this.cb.onEnd?.(done)
void this.pump()
}
}
}
}

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import { EvpDetectorCore } from './evp'
import type { EvpAnomaly } from './evp'
const SAMPLE_RATE = 48000
const FFT_SIZE = 2048
const BINS = FFT_SIZE / 2
const BIN_HZ = SAMPLE_RATE / FFT_SIZE // 23.4375 Hz per bin
function quietFrame(db = -100): Float64Array {
return new Float64Array(BINS).fill(db)
}
function spikedFrame(bin: number, spikeDb: number, baseDb = -100): Float64Array {
const frame = quietFrame(baseDb)
frame[bin] = spikeDb
return frame
}
function makeCore(): EvpDetectorCore {
return new EvpDetectorCore({ sampleRate: SAMPLE_RATE, fftSize: FFT_SIZE })
}
describe('EvpDetectorCore', () => {
it('seeds the rolling floor from the first frame and stays silent in silence', () => {
const core = makeCore()
expect(core.process(quietFrame(), 0)).toBeNull() // first frame only seeds
const floor = core.getFloor()
expect(floor).not.toBeNull()
expect(floor![50]).toBeCloseTo(-100, 9)
for (let t = 1; t <= 50; t++) {
expect(core.process(quietFrame(), t * 100)).toBeNull()
}
})
it('fires exactly one anomaly for a brief voice-band spike, with sane values', () => {
const core = makeCore()
core.process(quietFrame(), 0)
for (let t = 1; t <= 20; t++) core.process(quietFrame(), t * 100)
const bin = 50 // 1171.875 Hz — inside the default 300–3400 Hz voice band
const anomaly: EvpAnomaly | null = core.process(spikedFrame(bin, -80), 5000)
expect(anomaly).not.toBeNull()
expect(anomaly!.frequency).toBeCloseTo(bin * BIN_HZ, 6)
expect(anomaly!.magnitude).toBeCloseTo(20, 6)
// The spike is gone on the next frame — no further anomaly.
expect(core.process(quietFrame(), 5100)).toBeNull()
})
it('throttles repeats inside the throttle window and refires after it', () => {
const core = makeCore()
core.process(quietFrame(), 0)
const bin = 60
expect(core.process(spikedFrame(bin, -80), 10_000)).not.toBeNull()
// Same spike 1 s later: swallowed by the 2 s throttle.
expect(core.process(spikedFrame(bin, -80), 11_000)).toBeNull()
// Exactly at the throttle boundary it fires again.
expect(core.process(spikedFrame(bin, -80), 12_000)).not.toBeNull()
})
it('fires at most once per throttle window under sustained loudness', () => {
const core = makeCore()
core.process(quietFrame(), 0)
const bin = 70
let fired = 0
// Sustained spike for 1.9 s at 100 ms intervals — inside one window.
for (let t = 10_000; t <= 11_900; t += 100) {
if (core.process(spikedFrame(bin, -80), t)) fired++
}
expect(fired).toBe(1)
})
it('absorbs a slowly rising ambient into the floor instead of firing', () => {
const core = makeCore()
let level = -100
core.process(quietFrame(level), 0)
// 20 dB of sustained rise, but gradual (0.1 dB per frame): the rolling
// floor tracks it, so the deviation never crosses the 8 dB threshold.
for (let t = 1; t <= 200; t++) {
level += 0.1
expect(core.process(quietFrame(level), t * 100)).toBeNull()
}
const floor = core.getFloor()!
expect(floor[50]).toBeGreaterThan(-85)
expect(floor[50]).toBeLessThan(-79)
})
it('ignores spikes outside the voice band', () => {
const core = makeCore()
core.process(quietFrame(), 0)
expect(core.process(spikedFrame(5, -60), 1000)).toBeNull() // ~117 Hz, below 300
expect(core.process(spikedFrame(500, -60), 2000)).toBeNull() // ~11.7 kHz, above 3400
})
it('respects a custom band and threshold', () => {
const core = new EvpDetectorCore({
sampleRate: SAMPLE_RATE,
fftSize: FFT_SIZE,
bandLowHz: 1000,
bandHighHz: 1200,
thresholdDb: 15,
})
core.process(quietFrame(), 0)
// bin 50 = 1171.875 Hz is inside the custom 1000–1200 Hz band…
expect(core.process(spikedFrame(50, -80), 1000)).not.toBeNull()
// …but a spike outside that band is ignored…
expect(core.process(spikedFrame(80, -80), 5000)).toBeNull()
// …and a 10 dB spike inside it is below the custom 15 dB threshold.
expect(core.process(spikedFrame(50, -90), 9000)).toBeNull()
})
it('sanitizes -Infinity bins (analyser silence) to finite values', () => {
const core = makeCore()
core.process(quietFrame(), 0)
const frame = quietFrame()
frame[40] = -Infinity
expect(core.process(frame, 1000)).toBeNull()
const floor = core.getFloor()!
expect(Number.isFinite(floor[40])).toBe(true)
})
it('handles empty frames gracefully', () => {
const core = makeCore()
expect(core.process(new Float64Array(0), 0)).toBeNull()
expect(core.getFloor()).toBeNull()
})
it('reset() clears the floor and the throttle clock', () => {
const core = makeCore()
core.process(quietFrame(), 0)
const bin = 50
expect(core.process(spikedFrame(bin, -80), 10_000)).not.toBeNull()
// Would be throttled without a reset…
expect(core.process(spikedFrame(bin, -80), 10_100)).toBeNull()
core.reset()
expect(core.getFloor()).toBeNull()
// …so the next spike fires immediately after the floor re-seeds.
expect(core.process(quietFrame(), 10_200)).toBeNull() // re-seeds floor
const anomaly = core.process(spikedFrame(bin, -80), 10_300)
expect(anomaly).not.toBeNull()
expect(anomaly!.frequency).toBeCloseTo(bin * BIN_HZ, 6)
})
it('returns a defensive copy from getFloor()', () => {
const core = makeCore()
core.process(quietFrame(), 0)
const floor = core.getFloor()!
floor[50] = 999
expect(core.getFloor()![50]).toBeCloseTo(-100, 9)
})
})

202
frontend/src/lib/evp.ts Normal file
View File

@@ -0,0 +1,202 @@
// EVP (Electronic Voice Phenomena) listening: microphone anomaly detection.
//
// Design: an AnalyserNode feeds frequency data; we track a rolling ambient
// noise floor over the voice band (~300–3400 Hz). When a brief deviation
// rises >~8 dB above the floor during an otherwise-quiet stretch, we fire
// one anomaly (peak Hz + dB above floor), throttled to at most one per 2 s.
//
// The pure math (floor tracking, deviation detection, throttling) lives in
// `EvpDetectorCore` so it is unit-testable with injected sample arrays; the
// Web Audio plumbing lives in `EvpListener` below.
export type EvpAnomaly = {
/** Peak frequency in Hz. */
frequency: number
/** dB above the rolling noise floor. */
magnitude: number
}
export type EvpDetectorOptions = {
sampleRate: number
fftSize: number
/** Low edge of the voice band, Hz. */
bandLowHz?: number
/** High edge of the voice band, Hz. */
bandHighHz?: number
/** How far above floor (dB) a deviation must reach. */
thresholdDb?: number
/** Minimum milliseconds between anomaly emissions. */
throttleMs?: number
/** EMA factor for the rolling floor (0..1, higher = faster adaptation). */
floorAlpha?: number
/** Floor only adapts when current level is within this many dB of the floor. */
quietBandDb?: number
}
export class EvpDetectorCore {
private floor: Float64Array | null = null
private lastEmit = -Infinity
private readonly binHz: number
private readonly loBin: number
private readonly hiBin: number
private readonly thresholdDb: number
private readonly throttleMs: number
private readonly floorAlpha: number
private readonly quietBandDb: number
constructor(opts: EvpDetectorOptions) {
this.binHz = opts.sampleRate / opts.fftSize
const bins = opts.fftSize / 2
const bandLow = opts.bandLowHz ?? 300
const bandHigh = opts.bandHighHz ?? 3400
this.loBin = Math.max(0, Math.floor(bandLow / this.binHz))
this.hiBin = Math.min(bins - 1, Math.ceil(bandHigh / this.binHz))
this.thresholdDb = opts.thresholdDb ?? 8
this.throttleMs = opts.throttleMs ?? 2000
this.floorAlpha = opts.floorAlpha ?? 0.05
this.quietBandDb = opts.quietBandDb ?? 3
}
/**
* Feed one frame of analyser dB values (getFloatFrequencyData shape:
* one dB value per bin, typically −160..0). `nowMs` is the frame time.
* Returns an anomaly when one fires, else null.
*/
process(dbData: ArrayLike<number>, nowMs: number): EvpAnomaly | null {
const bins = dbData.length
if (bins === 0) return null
// Sanitize: AnalyserNode can emit -Infinity for silence.
const frame = new Float64Array(bins)
for (let i = 0; i < bins; i++) {
const v = dbData[i]
frame[i] = Number.isFinite(v) ? v : -160
}
if (!this.floor || this.floor.length !== bins) {
this.floor = Float64Array.from(frame)
return null // first frame seeds the floor
}
// Find the peak deviation inside the voice band.
let peakBin = -1
let peakDev = 0
for (let i = this.loBin; i <= this.hiBin && i < bins; i++) {
const dev = frame[i] - this.floor[i]
if (dev > peakDev) {
peakDev = dev
peakBin = i
}
}
// Overall band level, used for the "quiet stretch" check: the floor
// only adapts while the band sits within quietBandDb of the floor —
// that is precisely the quiet stretch in which anomalies count.
let bandLevel = -Infinity
let floorLevel = -Infinity
for (let i = this.loBin; i <= this.hiBin && i < bins; i++) {
bandLevel = Math.max(bandLevel, frame[i])
floorLevel = Math.max(floorLevel, this.floor[i])
}
const isQuiet = bandLevel - floorLevel < this.quietBandDb
let anomaly: EvpAnomaly | null = null
if (
peakBin >= 0 &&
peakDev >= this.thresholdDb &&
nowMs - this.lastEmit >= this.throttleMs
) {
anomaly = {
frequency: peakBin * this.binHz,
magnitude: peakDev,
}
this.lastEmit = nowMs
}
// Adapt the floor only during quiet stretches so sustained speech/music
// does not drag the baseline up and mask real spikes.
if (isQuiet) {
for (let i = 0; i < bins; i++) {
this.floor[i] += this.floorAlpha * (frame[i] - this.floor[i])
}
}
return anomaly
}
/** Current rolling floor snapshot (for visualizers). */
getFloor(): Float64Array | null {
return this.floor ? Float64Array.from(this.floor) : null
}
reset(): void {
this.floor = null
this.lastEmit = -Infinity
}
}
// ---- Web Audio plumbing ----
export type EvpListenerCallbacks = {
onAnomaly: (a: EvpAnomaly) => void
/** Called each frame with (dbData, floor) for waveform/band visualization. */
onFrame?: (dbData: Float32Array, floor: Float64Array | null) => void
onError?: (err: Error) => void
}
export class EvpListener {
private ctx: AudioContext | null = null
private stream: MediaStream | null = null
private analyser: AnalyserNode | null = null
private raf = 0
private core: EvpDetectorCore | null = null
private running = false
get isRunning(): boolean {
return this.running
}
async start(cb: EvpListenerCallbacks): Promise<void> {
if (this.running) return
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const ctx = new AudioContext()
const src = ctx.createMediaStreamSource(stream)
const analyser = ctx.createAnalyser()
analyser.fftSize = 2048
analyser.smoothingTimeConstant = 0.5
src.connect(analyser)
this.ctx = ctx
this.stream = stream
this.analyser = analyser
this.core = new EvpDetectorCore({
sampleRate: ctx.sampleRate,
fftSize: analyser.fftSize,
})
this.running = true
const buf = new Float32Array(analyser.frequencyBinCount)
const loop = () => {
if (!this.running || !this.analyser || !this.core) return
this.analyser.getFloatFrequencyData(buf)
const anomaly = this.core.process(buf, performance.now())
if (anomaly) cb.onAnomaly(anomaly)
cb.onFrame?.(buf, this.core.getFloor())
this.raf = requestAnimationFrame(loop)
}
this.raf = requestAnimationFrame(loop)
}
async stop(): Promise<void> {
this.running = false
cancelAnimationFrame(this.raf)
this.stream?.getTracks().forEach((t) => t.stop())
if (this.ctx && this.ctx.state !== 'closed') {
await this.ctx.close().catch(() => undefined)
}
this.ctx = null
this.stream = null
this.analyser = null
this.core = null
}
}

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { fftInPlace, magnitudeSpectrum, nextPow2, powerSpectrumDb } from './fft'
function argmax(values: Float64Array): number {
let best = 0
for (let i = 1; i < values.length; i++) {
if (values[i] > values[best]) best = i
}
return best
}
/** Real-valued unit-amplitude sine/cosine exactly on FFT bin `bin`. */
function tone(n: number, bin: number, fn: 'sin' | 'cos' = 'sin'): Float64Array {
const out = new Float64Array(n)
for (let t = 0; t < n; t++) {
const angle = (2 * Math.PI * bin * t) / n
out[t] = fn === 'sin' ? Math.sin(angle) : Math.cos(angle)
}
return out
}
describe('nextPow2', () => {
it('returns the smallest power of two >= n', () => {
expect(nextPow2(1)).toBe(1)
expect(nextPow2(2)).toBe(2)
expect(nextPow2(3)).toBe(4)
expect(nextPow2(1000)).toBe(1024)
expect(nextPow2(1024)).toBe(1024)
})
})
describe('magnitudeSpectrum', () => {
it('peaks at the bin of an injected sine', () => {
const n = 64
const k = 5
const mags = magnitudeSpectrum(tone(n, k), new Float64Array(n))
expect(mags).toHaveLength(n / 2)
expect(argmax(mags)).toBe(k)
// A unit-amplitude sine concentrates N/2 of magnitude in its bin.
expect(mags[k]).toBeCloseTo(n / 2, 6)
expect(mags[k - 1]).toBeLessThan(1e-6)
expect(mags[k + 1]).toBeLessThan(1e-6)
})
it('peaks at the bin of a second, different frequency (cosine)', () => {
const n = 128
const k = 11
const mags = magnitudeSpectrum(tone(n, k, 'cos'), new Float64Array(n))
expect(argmax(mags)).toBe(k)
expect(mags[k]).toBeCloseTo(n / 2, 6)
})
it('places a DC signal entirely in bin 0', () => {
const n = 64
const mags = magnitudeSpectrum(new Float64Array(n).fill(1), new Float64Array(n))
expect(mags[0]).toBeCloseTo(n, 6)
for (let k = 1; k < n / 2; k++) {
expect(mags[k]).toBeLessThan(1e-6)
}
})
it('does not mutate its inputs', () => {
const re = tone(64, 3)
const im = new Float64Array(64)
const reBefore = Float64Array.from(re)
const imBefore = Float64Array.from(im)
magnitudeSpectrum(re, im)
expect(re).toEqual(reBefore)
expect(im).toEqual(imBefore)
})
})
describe('fftInPlace', () => {
it('puts a Nyquist-rate alternating signal entirely in bin n/2', () => {
const n = 64
const re = new Float64Array(n)
for (let t = 0; t < n; t++) re[t] = t % 2 === 0 ? 1 : -1
const im = new Float64Array(n)
fftInPlace(re, im) // in-place: the transform lands back in re/im
// magnitudeSpectrum only returns the first n/2 bins, so the Nyquist bin
// is verified here on the raw output instead.
expect(Math.hypot(re[n / 2], im[n / 2])).toBeCloseTo(n, 6)
expect(Math.hypot(re[0], im[0])).toBeLessThan(1e-9)
})
it('transforms a unit impulse into a flat spectrum', () => {
const n = 32
const re = new Float64Array(n)
re[0] = 1
const im = new Float64Array(n)
fftInPlace(re, im)
for (let k = 0; k < n; k++) {
expect(Math.hypot(re[k], im[k])).toBeCloseTo(1, 9)
}
})
it('rejects mismatched re/im lengths', () => {
expect(() => fftInPlace(new Float64Array(4), new Float64Array(8))).toThrow(/mismatch/)
})
it('rejects non-power-of-two lengths', () => {
expect(() => fftInPlace(new Float64Array(3), new Float64Array(3))).toThrow(/power of two/)
})
})
describe('powerSpectrumDb', () => {
it('peaks near 0 dB at the bin of a unit complex exponential', () => {
const n = 128
const k = 7
const iq = new Float64Array(2 * n)
for (let t = 0; t < n; t++) {
iq[2 * t] = Math.cos((2 * Math.PI * k * t) / n)
iq[2 * t + 1] = Math.sin((2 * Math.PI * k * t) / n)
}
const db = powerSpectrumDb(iq)
expect(db).toHaveLength(n / 2)
expect(argmax(db)).toBe(k)
// Unit amplitude => power (n^2)/(n^2) = 1 => ~0 dB at the peak.
expect(db[k]).toBeCloseTo(0, 6)
expect(db[k + 1]).toBeLessThan(-60)
})
it('zero-pads non-power-of-two sample counts and still finds the peak', () => {
const n = 128 // padded size chosen by nextPow2
const samples = 100
const k = 7
const iq = new Float64Array(2 * samples)
for (let t = 0; t < samples; t++) {
iq[2 * t] = Math.cos((2 * Math.PI * k * t) / n)
iq[2 * t + 1] = Math.sin((2 * Math.PI * k * t) / n)
}
const db = powerSpectrumDb(iq)
expect(db).toHaveLength(n / 2)
expect(argmax(db)).toBe(k)
})
})

95
frontend/src/lib/fft.ts Normal file
View File

@@ -0,0 +1,95 @@
// Radix-2 Cooley–Tukey FFT, in-place, iterative, bit-reversal ordered.
// Used by the RTL-SDR spectrum sweep. Length must be a power of two.
export type ComplexArray = { re: Float64Array; im: Float64Array }
export function nextPow2(n: number): number {
let p = 1
while (p < n) p <<= 1
return p
}
/**
* In-place iterative radix-2 FFT. `re`/`im` must have identical length
* and that length must be a power of two.
*/
export function fftInPlace(re: Float64Array, im: Float64Array): void {
const n = re.length
if (n !== im.length) throw new Error('re/im length mismatch')
if ((n & (n - 1)) !== 0) throw new Error('FFT length must be a power of two')
if (n <= 1) return
// Bit-reversal permutation.
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1
for (; j & bit; bit >>= 1) j ^= bit
j ^= bit
if (i < j) {
const tr = re[i]
re[i] = re[j]
re[j] = tr
const ti = im[i]
im[i] = im[j]
im[j] = ti
}
}
for (let len = 2; len <= n; len <<= 1) {
const half = len >> 1
const ang = (-2 * Math.PI) / len
const wr = Math.cos(ang)
const wi = Math.sin(ang)
for (let i = 0; i < n; i += len) {
let cwr = 1
let cwi = 0
for (let j = 0; j < half; j++) {
const uR = re[i + j]
const uI = im[i + j]
const vR = re[i + j + half] * cwr - im[i + j + half] * cwi
const vI = re[i + j + half] * cwi + im[i + j + half] * cwr
re[i + j] = uR + vR
im[i + j] = uI + vI
re[i + j + half] = uR - vR
im[i + j + half] = uI - vI
const nwr = cwr * wr - cwi * wi
cwi = cwr * wi + cwi * wr
cwr = nwr
}
}
}
}
/** Convenience wrapper returning magnitude spectrum (first n/2 bins). */
export function magnitudeSpectrum(re: Float64Array, im: Float64Array): Float64Array {
const r = Float64Array.from(re)
const i = Float64Array.from(im)
fftInPlace(r, i)
const half = r.length >> 1
const mags = new Float64Array(half)
for (let k = 0; k < half; k++) {
mags[k] = Math.hypot(r[k], i[k])
}
return mags
}
/**
* Power spectrum in dBFS-ish units from interleaved I/Q samples.
* `iq` layout: [i0, q0, i1, q1, ...]. Returns n/2 dB values.
*/
export function powerSpectrumDb(iq: Float64Array): Float64Array {
const n = nextPow2(iq.length >> 1)
const re = new Float64Array(n)
const im = new Float64Array(n)
for (let k = 0; k < n && 2 * k + 1 < iq.length; k++) {
re[k] = iq[2 * k]
im[k] = iq[2 * k + 1]
}
fftInPlace(re, im)
const half = n >> 1
const out = new Float64Array(half)
for (let k = 0; k < half; k++) {
const power = (re[k] * re[k] + im[k] * im[k]) / (n * n)
out[k] = 10 * Math.log10(power + 1e-12)
}
return out
}

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import { PlanchetteMachine, normalizeWord, tokenize } from './planchette'
// Fast cadence so tests can drive the machine with small tick counts.
const FAST = { msPerLetter: 100, msBetweenWords: 200 }
describe('normalizeWord', () => {
it('uppercases and strips punctuation', () => {
expect(normalizeWord('beware, the veil!')).toBe('BEWARE THE VEIL')
})
it('keeps digits', () => {
expect(normalizeWord('room 13, floor 4')).toBe('ROOM 13 FLOOR 4')
})
it('drops accented letters outright (the board only knows A-Z)', () => {
expect(normalizeWord('séance niño')).toBe('SANCE NIO')
})
it('collapses whitespace and trims', () => {
expect(normalizeWord(' hello \t spirit ')).toBe('HELLO SPIRIT')
})
it('returns an empty string when nothing spellable remains', () => {
expect(normalizeWord('!!! …')).toBe('')
})
})
describe('tokenize', () => {
it('splits an utterance into normalized words', () => {
expect(tokenize('beware, the veil!')).toEqual(['BEWARE', 'THE', 'VEIL'])
})
it('returns a single word as-is (normalized)', () => {
expect(tokenize('hello')).toEqual(['HELLO'])
})
it('returns an empty list when there is nothing to spell', () => {
expect(tokenize('')).toEqual([])
expect(tokenize('!!!')).toEqual([])
})
})
describe('PlanchetteMachine', () => {
it('starts idle with an empty queue and history', () => {
const m = new PlanchetteMachine(FAST)
const s = m.snapshot()
expect(s.phase).toBe('idle')
expect(s.current).toBeNull()
expect(s.word).toBeNull()
expect(s.index).toBe(0)
expect(s.queued).toEqual([])
expect(s.spelled).toEqual([])
})
it('spells a word letter by letter on the per-letter cadence', () => {
const m = new PlanchetteMachine(FAST)
m.enqueue('HI')
// Enqueueing alone does not start spelling.
expect(m.snapshot().phase).toBe('idle')
expect(m.snapshot().queued).toEqual(['HI'])
// The first tick pulls the word off the queue and hovers the first letter.
let s = m.tick(0)
expect(s.phase).toBe('moving')
expect(s.word).toBe('HI')
expect(s.current).toBe('H')
expect(s.index).toBe(0)
expect(s.queued).toEqual([])
// A partial interval keeps it dwelling on the same letter.
s = m.tick(50)
expect(s.phase).toBe('dwelling')
expect(s.current).toBe('H')
expect(s.index).toBe(0)
// Completing the interval advances to the next letter.
s = m.tick(50)
expect(s.current).toBe('I')
expect(s.index).toBe(1)
// One more interval finishes the word: it lands in `spelled`.
s = m.tick(100)
expect(s.phase).toBe('returning')
expect(s.current).toBeNull()
expect(s.word).toBeNull()
expect(s.spelled).toEqual(['HI'])
// Between-words pause, then back to idle.
s = m.tick(100)
expect(s.phase).toBe('returning')
s = m.tick(100)
expect(s.phase).toBe('idle')
expect(s.current).toBeNull()
})
it('spells queued words in FIFO order and idles after draining', () => {
const m = new PlanchetteMachine(FAST)
m.enqueue('BEWARE THE VEIL')
expect(m.snapshot().queued).toEqual(['BEWARE', 'THE', 'VEIL'])
for (let i = 0; i < 500; i++) m.tick(50)
const s = m.snapshot()
expect(s.phase).toBe('idle')
expect(s.current).toBeNull()
expect(s.queued).toEqual([])
expect(s.spelled).toEqual(['BEWARE', 'THE', 'VEIL'])
})
it('reports queue -> spelled progress while draining', () => {
const m = new PlanchetteMachine(FAST)
m.enqueue('AB CD')
m.tick(0) // start AB
m.tick(200) // finish AB -> returning
let s = m.snapshot()
expect(s.spelled).toEqual(['AB'])
expect(s.queued).toEqual(['CD'])
m.tick(200) // returning pause elapses -> idle
m.tick(0) // start CD
s = m.snapshot()
expect(s.phase).toBe('moving')
expect(s.word).toBe('CD')
expect(s.current).toBe('C')
expect(s.index).toBe(0)
})
it('caps the pending queue at maxQueue', () => {
const m = new PlanchetteMachine({ ...FAST, maxQueue: 2 })
m.enqueue('ONE TWO THREE FOUR')
expect(m.snapshot().queued).toEqual(['ONE', 'TWO'])
})
it('clear() cancels pending and in-progress spelling but keeps history', () => {
const m = new PlanchetteMachine(FAST)
m.enqueue('HI THERE')
m.tick(0) // start HI
m.tick(200) // finish HI -> returning, spelled: ['HI']
m.clear()
const s = m.snapshot()
expect(s.phase).toBe('idle')
expect(s.current).toBeNull()
expect(s.queued).toEqual([])
// clear() empties pending work; the spelled history survives the session.
expect(s.spelled).toEqual(['HI'])
// ...and the machine can be reused afterwards.
m.enqueue('OK')
m.tick(0)
expect(m.snapshot().word).toBe('OK')
})
it('ignores utterances with no spellable words', () => {
const m = new PlanchetteMachine(FAST)
const version = m.getVersion()
m.enqueue('!!! …')
expect(m.snapshot().queued).toEqual([])
expect(m.getVersion()).toBe(version) // no mutation, no version bump
m.tick(0)
expect(m.snapshot().phase).toBe('idle')
})
it('bumps the version counter on every mutation', () => {
const m = new PlanchetteMachine(FAST)
let v = m.getVersion()
m.enqueue('HI')
expect(m.getVersion()).toBeGreaterThan(v)
v = m.getVersion()
m.tick(0)
expect(m.getVersion()).toBeGreaterThan(v)
v = m.getVersion()
m.clear()
expect(m.getVersion()).toBeGreaterThan(v)
})
})

View File

@@ -0,0 +1,158 @@
// Planchette word-queue / spelling state machine.
//
// The Ouija board spells out spirit utterances letter by letter. This module
// is the pure logic: queue words, step through letters at a fixed cadence,
// and report which character the planchette is currently hovering over.
// Rendering (canvas, DOM) lives elsewhere; this is unit-tested in isolation.
export type PlanchettePhase = 'idle' | 'moving' | 'dwelling' | 'returning'
export type PlanchetteSnapshot = {
phase: PlanchettePhase
/** Character currently hovered, or null when drifting idle. */
current: string | null
/** The full normalized word being spelled, or null. */
word: string | null
/** Index of `current` within `word`. */
index: number
/** Words still waiting to be spelled. */
queued: readonly string[]
/** Words already spelled this session (most recent last). */
spelled: readonly string[]
}
export type PlanchetteOptions = {
/** Milliseconds spent on each letter. Default 300. */
msPerLetter?: number
/** Milliseconds paused between words. Default 700. */
msBetweenWords?: number
/** Cap on the internal queue so a flood of utterances cannot wedge it. */
maxQueue?: number
}
/** Keep A–Z, 0–9 and spaces; uppercase; drop everything else. */
export function normalizeWord(text: string): string {
return text
.toUpperCase()
.replace(/[^A-Z0-9 ]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
/**
* Split an utterance into queueable word tokens: punctuation stripped,
* whitespace-split, empties removed.
*/
export function tokenize(text: string): string[] {
const norm = normalizeWord(text)
if (!norm) return []
return norm.split(' ').filter((w) => w.length > 0)
}
export class PlanchetteMachine {
private queue: string[] = []
private done: string[] = []
private word: string | null = null
private index = 0
private phase: PlanchettePhase = 'idle'
private clock = 0
private readonly msPerLetter: number
private readonly msBetweenWords: number
private readonly maxQueue: number
private version = 0
constructor(opts: PlanchetteOptions = {}) {
this.msPerLetter = opts.msPerLetter ?? 300
this.msBetweenWords = opts.msBetweenWords ?? 700
this.maxQueue = opts.maxQueue ?? 64
}
/** Monotonic counter bumped on every mutation — handy for render loops. */
getVersion(): number {
return this.version
}
/** Enqueue an utterance (may contain several words + punctuation). */
enqueue(text: string): void {
const words = tokenize(text)
for (const w of words) {
if (this.queue.length < this.maxQueue) this.queue.push(w)
}
if (words.length > 0) this.version++
}
clear(): void {
this.queue = []
this.word = null
this.index = 0
this.phase = 'idle'
this.clock = 0
this.version++
}
/**
* Advance the machine by `dtMs`. Call from a rAF loop or a timer.
* Returns a snapshot after advancing.
*/
tick(dtMs: number): PlanchetteSnapshot {
this.clock += dtMs
if (this.phase === 'idle') {
if (this.queue.length > 0) {
this.word = this.queue.shift() ?? null
this.index = 0
this.phase = this.word && this.word.length > 0 ? 'moving' : 'idle'
this.clock = 0
this.version++
}
return this.snapshot()
}
if (this.phase === 'returning') {
if (this.clock >= this.msBetweenWords) {
this.clock = 0
this.phase = 'idle'
this.word = null
this.index = 0
this.version++
}
return this.snapshot()
}
// moving / dwelling: advance letters on the per-letter cadence.
if (this.word) {
while (this.clock >= this.msPerLetter && this.phase !== 'returning') {
this.clock -= this.msPerLetter
this.index++
this.version++
if (this.index >= this.word.length) {
this.done.push(this.word)
if (this.done.length > 128) this.done.shift()
this.phase = 'returning'
this.clock = 0
} else {
this.phase = this.clock >= this.msPerLetter ? 'moving' : 'dwelling'
}
}
if (this.phase === 'dwelling' || this.phase === 'moving') {
this.phase = 'dwelling'
}
}
return this.snapshot()
}
snapshot(): PlanchetteSnapshot {
const active =
(this.phase === 'moving' || this.phase === 'dwelling') && this.word
? this.word
: null
return {
phase: this.phase,
current: active ? active[this.index] ?? null : null,
word: active,
index: active ? this.index : 0,
queued: [...this.queue],
spelled: [...this.done],
}
}
}

339
frontend/src/lib/sdr.ts Normal file
View File

@@ -0,0 +1,339 @@
// Best-effort WebUSB driver for RTL2832U + R820T("T") based SDR dongles.
//
// ⚠️ HARDWARE PASS REQUIRED: this driver is structured from the public
// librtlsdr register documentation and has NOT been validated against a real
// device in this environment (no RTL-SDR attached). The control-transfer
// sequences below follow the known-good init order (demod power-up, R820T
// tuner init via I2C repeater, sample-rate set, FIR, bulk streaming) but
// expect to debug register pokes with a logic analyzer / librtlsdr -T.
// Every entry point fails soft: callers must treat any thrown error as
// "this vessel cannot hear the radio dead" and degrade gracefully.
import { powerSpectrumDb } from './fft'
export type RtlSampleBlock = {
/** Center frequency this block was captured at, Hz. */
centerHz: number
/** Sample rate used, Hz. */
sampleRateHz: number
/** Interleaved unsigned I/Q bytes, zero-centered: [i,q,i,q…] */
iq: Uint8Array
}
export type SweepCallbacks = {
/** Per-tune power spectrum (dB values, fftSize/2 entries). */
onSpectrum?: (centerHz: number, db: Float64Array) => void
onError?: (err: Error) => void
}
// USB identification for RTL2832U dongles.
export const RTL2832U_VENDOR = 0x0bda
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
// Request types used by librtlsdr.
const CTRL_IN = 0xc0
const CTRL_OUT = 0x40
const DEMOD = 0x03
const USB_EPA = 0x02
const SYS = 0x09
const PAGE_USB = 0x01
export function isSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
'usb' in navigator &&
typeof navigator.usb?.requestDevice === 'function'
)
}
export class RtlSdr {
private device: USBDevice | null = null
private interfaceNumber = 0
private endpointIn = 0x81
private running = false
private sampleRate = 2_048_000
get isOpen(): boolean {
return this.device?.opened ?? false
}
/** Ask the browser for an RTL2832U device. Throws if none chosen/found. */
async requestDevice(): Promise<void> {
if (!isSupported()) {
throw new Error('WebUSB is not available in this vessel (Chromium required)')
}
const filters = [
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
]
this.device = await navigator.usb.requestDevice({ filters })
}
/** Open, claim, and run the RTL2832U + R820T init sequence. */
async open(sampleRateHz = 2_048_000): Promise<void> {
const dev = this.device
if (!dev) throw new Error('no device selected')
this.sampleRate = sampleRateHz
await dev.open()
// Find the first bulk-IN endpoint.
const iface = dev.configuration?.interfaces[0]
const alt = iface?.alternates[0]
const ep = alt?.endpoints.find((e) => e.direction === 'in' && e.type === 'bulk')
if (iface && alt && ep) {
this.interfaceNumber = iface.interfaceNumber
this.endpointIn = ep.endpointNumber
}
// Detach kernel driver (Linux) — ignore failure: may not be supported.
await this.claim()
// --- Init sequence (HARDWARE PASS REQUIRED) ---
// Order follows librtlsdr: USB reset → demod init → tuner I2C init.
await this.demodWrite(1, 0x01, 0x14, 1) // soft reset
await this.demodWrite(1, 0x01, 0x10, 1)
await this.demodWrite(0, 0x01, 0x08, 2) // demod_ctl
await this.demodWrite(0, 0x06, 0x80, 1)
await this.demodWrite(1, 0x15, 0x00, 1) // suspend off
await this.demodWrite(1, 0x16, 0x00, 1)
await this.demodWrite(1, 0x17, 0x00, 1)
await this.demodWrite(1, 0x18, 0x00, 1)
await this.demodWrite(1, 0x19, 0x00, 1)
await this.demodWrite(1, 0x1a, 0x00, 1)
await this.demodWrite(1, 0x1b, 0x00, 1)
await this.demodWrite(1, 0x1c, 0x00, 1)
await this.demodWrite(1, 0x0d, 0x83, 1) // standby off
await this.demodWrite(1, 0x0b, 0x1b, 1) // AGC mode
// Power on tuner through I2C repeater (R820T at 0x1a).
await this.i2cWrite(0x1a, 0x05, 0x8f) // LNA power on
await this.i2cWrite(0x1a, 0x08, 0x80) // mixer
await this.i2cWrite(0x1a, 0x0a, 0x10) // IF filter
await this.setSampleRate(this.sampleRate)
await this.setFrequency(98_000_000)
// Reset endpoint before streaming.
await this.writeReg(USB_EPA, 0x0001, 0xffff, 2)
await this.demodWrite(1, 0x02, 0x00, 1)
await this.demodWrite(0, 0x02, 0x40, 2)
await this.demodWrite(1, 0x02, 0x00, 1) // enable test mode off
}
async close(): Promise<void> {
this.running = false
const dev = this.device
if (dev?.opened) {
try {
await this.demodWrite(1, 0x01, 0x10, 1) // suspend
} catch {
/* device may already be gone */
}
await dev.releaseInterface(this.interfaceNumber).catch(() => undefined)
await dev.close().catch(() => undefined)
}
}
/** Tune the R820T mixer PLL. Hz. (HARDWARE PASS REQUIRED) */
async setFrequency(hz: number): Promise<void> {
// R820T fractional-N PLL programming via I2C. Simplified to the
// integer part + common divider ratio; real librtlsdr computes the
// exact sdm/vco from a 28.8MHz crystal reference. Marked for hardware.
const loHz = hz + 3_570_000 // R820T IF offset
const ref = 28_800_000
const mixDiv = 2
const nint = Math.floor(loHz / (ref * mixDiv))
const vco = loHz % (ref * mixDiv)
const sdm = Math.min(0xffff, Math.floor((vco * 65536) / (ref * mixDiv)))
const reg = nint & 0x3f
await this.i2cWrite(0x1a, 0x10, reg)
await this.i2cWrite(0x1a, 0x11, (sdm >> 8) & 0xff)
await this.i2cWrite(0x1a, 0x12, sdm & 0xff)
}
/** Program demod resampling rate for the requested sample rate. */
async setSampleRate(hz: number): Promise<void> {
const crystal = 28_800_000
const rsampRatio = Math.floor(((crystal * 2 ** 22) / hz) & 0x0ffffffc)
await this.demodWrite(1, 0x9f, (rsampRatio >> 16) & 0xffff, 2)
await this.demodWrite(1, 0xa1, rsampRatio & 0xffff, 2)
this.sampleRate = hz
}
/** Read one block of I/Q samples. Length must be multiple of 512. */
async readSamples(bytes: number): Promise<Uint8Array> {
const dev = this.device
if (!dev?.opened) throw new Error('device not open')
const res = await dev.transferIn(this.endpointIn, bytes)
if (!res.data) throw new Error('bulk read failed')
return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength)
}
/**
* Continuous sweep across [startHz, endHz] in `stepHz` steps, emitting a
* power spectrum per tuning step. Runs until `stopSweep()`.
*/
async sweep(
startHz: number,
endHz: number,
stepHz: number,
cb: SweepCallbacks,
fftSize = 512,
settleMs = 25,
): Promise<void> {
if (this.running) return
this.running = true
let hz = startHz
const readBytes = fftSize * 2 * 2 // 2 samples per fft point, unsigned iq
try {
while (this.running) {
await this.setFrequency(hz)
await new Promise((r) => setTimeout(r, settleMs))
try {
await this.readSamples(16384) // discard: PLL settle
const iq = await this.readSamples(readBytes)
const f64 = new Float64Array(iq.length)
for (let i = 0; i < iq.length; i++) f64[i] = (iq[i] - 127.5) / 128
const db = powerSpectrumDb(f64)
cb.onSpectrum?.(hz, db)
} catch (err) {
cb.onError?.(err instanceof Error ? err : new Error(String(err)))
}
hz += stepHz
if (hz > endHz) hz = startHz
if (!this.running) break
}
} finally {
this.running = false
}
}
stopSweep(): void {
this.running = false
}
// ---- Low-level USB helpers (private; HARDWARE PASS REQUIRED) ----
private async claim(): Promise<void> {
const dev = this.device
if (!dev) throw new Error('no device')
try {
// Best-effort kernel driver detach; unsupported on some platforms.
const anyDev = dev as unknown as {
claimInterface(n: number): Promise<void>
}
await anyDev.claimInterface(this.interfaceNumber)
} catch (err) {
throw new Error(
`could not claim the radio dead (interface busy?) — ${String(err)}`,
)
}
}
private async writeReg(
block: number,
address: number,
value: number,
length: number,
): Promise<void> {
const dev = this.device
if (!dev) throw new Error('no device')
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
await dev.controlTransferOut({
requestType: 'vendor',
recipient: 'device',
request: 0,
value: (block << 8) | 0x10,
index: address,
}, data.subarray(0, length))
}
private async demodWrite(
page: number,
address: number,
value: number,
length: number,
): Promise<void> {
// Demod registers are paged: index = (page << 8) | address.
const dev = this.device
if (!dev) throw new Error('no device')
const data = new Uint8Array([value & 0xff, (value >> 8) & 0xff])
await dev.controlTransferOut({
requestType: 'vendor',
recipient: 'device',
request: 0,
value: (DEMOD << 8) | 0x10,
index: (page << 8) | address,
}, data.subarray(0, length))
}
private async i2cWrite(i2cAddr: number, reg: number, value: number): Promise<void> {
// I2C repeater: librtlsdr writes tuner registers by tunneling through
// the demod's I2C master. Simplified single-byte write.
const dev = this.device
if (!dev) throw new Error('no device')
await this.demodWrite(1, 0x02, 0x41, 1) // repeater on
await dev.controlTransferOut({
requestType: 'vendor',
recipient: 'device',
request: 0,
value: (0x02 << 8) | 0x10,
index: (i2cAddr << 8) | reg,
}, new Uint8Array([value & 0xff]))
await this.demodWrite(1, 0x02, 0x01, 1) // repeater off
}
// Silence unused-warnings for constants kept for the hardware pass.
private static readonly _refs = { CTRL_IN, CTRL_OUT, SYS, PAGE_USB }
}
/** Rolling noise-floor + spike detector over sweep spectra (pure, testable). */
export class SpectrumAnomalyDetector {
private floor: Float64Array | null = null
private lastEmit = -Infinity
constructor(
private readonly thresholdDb = 10,
private readonly throttleMs = 2000,
private readonly alpha = 0.1,
) {}
/** Returns (peakHz, magnitudeDbOverFloor) when a spike fires, else null. */
process(
centerHz: number,
sampleRateHz: number,
db: Float64Array,
nowMs: number,
): { frequency: number; magnitude: number } | null {
if (!this.floor || this.floor.length !== db.length) {
this.floor = Float64Array.from(db)
return null
}
let peak = 0
let peakBin = -1
for (let i = 0; i < db.length; i++) {
const dev = db[i] - this.floor[i]
if (dev > peak) {
peak = dev
peakBin = i
}
this.floor[i] += this.alpha * (db[i] - this.floor[i])
}
if (peakBin >= 0 && peak >= this.thresholdDb && nowMs - this.lastEmit >= this.throttleMs) {
this.lastEmit = nowMs
const binHz = sampleRateHz / 2 / db.length
const offset = (peakBin - db.length / 2) * binHz
return { frequency: (centerHz + offset) / 1e6, magnitude: peak }
}
return null
}
reset(): void {
this.floor = null
this.lastEmit = -Infinity
}
}
export const SWEEP_START_MHZ = 88
export const SWEEP_END_MHZ = 108

102
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,102 @@
// Shared protocol + domain types for the Quantumancy frontend.
// Mirrors the backend contract exactly — do not invent changes.
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija'
export type Language = 'en' | 'es'
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
export type CodexVisual = {
hue: number
form: GhostForm
}
export type CodexEntity = {
id: string
name: string
epithet: string
rarity: Rarity
visual: CodexVisual
quotes: string[]
contact_count: number
discovered_at: string
discovered_by: string | null
}
export type CodexEntityDetail = CodexEntity & {
persona: string
voice: Record<string, unknown>
sightings: number
}
export type SpiritVoice = {
voice_id: string
pitch: number
rate: number
noise: number
echo: number
}
export type SpiritEntity = {
id: string
name: string
epithet: string
persona: string
rarity: Rarity
voice: SpiritVoice
visual: CodexVisual
quotes: string[]
contact_count: number
discovered_at: string
}
export type CodexListResponse = { entities: CodexEntity[] }
export type Stats = {
entities: number
sessions: number
utterances: number
anomalies: number
}
export type SessionStatus = 'attuning' | 'summoning' | 'gathering'
// ---- WebSocket frames: client -> server ----
export type ClientFrame =
| { type: 'ping' }
| { type: 'set_mode'; mode: Mode }
| { type: 'language'; language: Language }
| { type: 'summon' }
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
| { type: 'question'; text: string }
| { type: 'passive'; enabled: boolean }
// ---- WebSocket frames: server -> client ----
export type Telemetry = {
jitter_bytes_per_s: number
latency_variance_ms: number
latency_mean_ms: number
dns_ms: number
}
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
export type ServerFrame =
| { type: 'session'; id: string }
| { type: 'pong' }
| { type: 'mode'; mode: Mode }
| { type: 'status'; state: SessionStatus }
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
| { type: 'anomaly_ack'; count: number }
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
| { type: 'audio'; id: string; url: string }
| { type: 'reply_start' }
| { type: 'reply_token'; token: string }
| { type: 'reply_end'; id: string; text: string }
| { type: 'telemetry' } & Telemetry
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
// REST helpers
export type SortOrder = 'recent' | 'contacted'

295
frontend/src/lib/ws.test.ts Normal file
View File

@@ -0,0 +1,295 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { VeilSocket } from './ws'
import type { VeilConnectionState } from './ws'
import type { ClientFrame, ServerFrame } from './types'
const URL = 'ws://quantumancy.test/ws/session'
/**
* Minimal stand-in for the DOM WebSocket, driven manually by the tests.
* readyState uses the real constant values so VeilSocket's
* `readyState === WebSocket.OPEN` checks behave (WebSocket.OPEN === 1).
*/
class FakeWebSocket {
readyState = 0 // CONNECTING
onopen: (() => void) | null = null
onmessage: ((ev: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
readonly sent: string[] = []
constructor(readonly url: string) {}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = 3 // CLOSED
this.onclose?.()
}
// ---- test drives ----
serverOpen(): void {
this.readyState = 1 // WebSocket.OPEN
this.onopen?.()
}
serverSend(data: unknown): void {
this.onmessage?.({ data })
}
serverClose(): void {
this.readyState = 3
this.onclose?.()
}
}
type HarnessOptions = {
baseBackoffMs?: number
maxBackoffMs?: number
pingIntervalMs?: number
}
function makeHarness(opts: HarnessOptions = {}) {
const sockets: FakeWebSocket[] = []
const socket = new VeilSocket({
url: URL,
baseBackoffMs: opts.baseBackoffMs ?? 100,
maxBackoffMs: opts.maxBackoffMs ?? 1000,
pingIntervalMs: opts.pingIntervalMs ?? 1000,
socketFactory: (url: string): WebSocket => {
const fake = new FakeWebSocket(url)
sockets.push(fake)
return fake as unknown as WebSocket
},
})
return { socket, sockets }
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('VeilSocket', () => {
it('queues frames while connecting and flushes them on open', () => {
const { socket, sockets } = makeHarness()
const states: VeilConnectionState[] = []
socket.onState((s) => states.push(s))
socket.connect()
expect(socket.state).toBe('connecting')
expect(sockets).toHaveLength(1)
expect(sockets[0].url).toBe(URL)
const summon: ClientFrame = { type: 'summon' }
const question: ClientFrame = { type: 'question', text: 'is anyone there' }
socket.send(summon)
socket.send(question)
expect(sockets[0].sent).toEqual([]) // nothing on the wire yet
sockets[0].serverOpen()
expect(socket.state).toBe('open')
expect(sockets[0].sent).toEqual([JSON.stringify(summon), JSON.stringify(question)])
expect(states).toEqual(['connecting', 'open'])
})
it('sends immediately once open', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
socket.send({ type: 'ping' })
expect(sockets[0].sent).toEqual([JSON.stringify({ type: 'ping' })])
})
it('dispatches parsed JSON frames to onFrame handlers', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
const frames: ServerFrame[] = []
const off = socket.onFrame((f) => frames.push(f))
const session: ServerFrame = { type: 'session', id: 'abc' }
sockets[0].serverSend(JSON.stringify(session))
expect(frames).toEqual([session])
off()
sockets[0].serverSend(JSON.stringify({ type: 'pong' }))
expect(frames).toHaveLength(1) // unsubscribed handler no longer called
})
it('ignores malformed JSON and non-string payloads', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
const frames: ServerFrame[] = []
socket.onFrame((f) => frames.push(f))
expect(() => {
sockets[0].serverSend('}{ not json')
sockets[0].serverSend(new Uint8Array([1, 2, 3]).buffer)
sockets[0].serverSend(undefined)
}).not.toThrow()
expect(frames).toEqual([])
})
it('drops frames sent while unstable instead of queueing them', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
sockets[0].serverClose()
expect(socket.state).toBe('unstable')
socket.send({ type: 'summon' }) // dropped: not open, not connecting
vi.advanceTimersByTime(100) // reconnect fires
expect(sockets).toHaveLength(2)
sockets[1].serverOpen()
expect(sockets[1].sent).toEqual([]) // nothing stale flushed
})
it('drops frames sent while closed', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
socket.close()
socket.send({ type: 'summon' })
expect(sockets[0].sent).toEqual([])
})
it('goes unstable on unexpected close and reconnects with exponential backoff', () => {
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 1000 })
const states: VeilConnectionState[] = []
socket.onState((s) => states.push(s))
socket.connect()
sockets[0].serverOpen()
sockets[0].serverClose()
expect(socket.state).toBe('unstable')
expect(sockets).toHaveLength(1)
// First retry after 100 ms (base * 2^0).
vi.advanceTimersByTime(99)
expect(sockets).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(sockets).toHaveLength(2)
expect(socket.state).toBe('connecting')
// Second drop: backoff doubles to 200 ms.
sockets[1].serverClose()
vi.advanceTimersByTime(199)
expect(sockets).toHaveLength(2)
vi.advanceTimersByTime(1)
expect(sockets).toHaveLength(3)
// The reconnect succeeds and the veil lifts again.
sockets[2].serverOpen()
expect(socket.state).toBe('open')
expect(states).toEqual([
'connecting',
'open',
'unstable',
'connecting',
'unstable',
'connecting',
'open',
])
})
it('caps the backoff at maxBackoffMs', () => {
const { socket, sockets } = makeHarness({ baseBackoffMs: 100, maxBackoffMs: 250 })
socket.connect()
sockets[0].serverClose() // retry in 100 ms
vi.advanceTimersByTime(100)
sockets[1].serverClose() // retry in 200 ms
vi.advanceTimersByTime(200)
sockets[2].serverClose() // retry in min(250, 400) = 250 ms
vi.advanceTimersByTime(249)
expect(sockets).toHaveLength(3)
vi.advanceTimersByTime(1)
expect(sockets).toHaveLength(4)
})
it('does not reconnect after a deliberate close()', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverOpen()
socket.close()
expect(socket.state).toBe('closed')
vi.advanceTimersByTime(60_000)
expect(sockets).toHaveLength(1)
})
it('does not reconnect when close() races a pending reconnect timer', () => {
const { socket, sockets } = makeHarness()
socket.connect()
sockets[0].serverClose() // reconnect scheduled in 100 ms
socket.close() // deliberate: must cancel the pending timer
vi.advanceTimersByTime(60_000)
expect(sockets).toHaveLength(1)
expect(socket.state).toBe('closed')
})
it('sends ping frames on the configured interval while open', () => {
const { socket, sockets } = makeHarness({ pingIntervalMs: 1000 })
socket.connect()
sockets[0].serverOpen()
vi.advanceTimersByTime(3000)
expect(sockets[0].sent).toEqual([
JSON.stringify({ type: 'ping' }),
JSON.stringify({ type: 'ping' }),
JSON.stringify({ type: 'ping' }),
])
})
it('stops pinging after the connection drops', () => {
const { socket, sockets } = makeHarness({ pingIntervalMs: 1000 })
socket.connect()
sockets[0].serverOpen()
vi.advanceTimersByTime(2000)
expect(sockets[0].sent).toHaveLength(2)
sockets[0].serverClose()
vi.advanceTimersByTime(10_000)
expect(sockets[0].sent).toHaveLength(2) // frozen at the drop
})
it('does not ping when pingIntervalMs is 0', () => {
const { socket, sockets } = makeHarness({ pingIntervalMs: 0 })
socket.connect()
sockets[0].serverOpen()
vi.advanceTimersByTime(10_000)
expect(sockets[0].sent).toEqual([])
})
it('goes unstable and retries when the socket factory throws', () => {
const sockets: FakeWebSocket[] = []
let calls = 0
const socket = new VeilSocket({
url: URL,
baseBackoffMs: 100,
pingIntervalMs: 0,
socketFactory: (): WebSocket => {
calls++
if (calls === 1) throw new Error('the veil refused the connection')
const fake = new FakeWebSocket(URL)
sockets.push(fake)
return fake as unknown as WebSocket
},
})
socket.connect()
expect(socket.state).toBe('unstable')
vi.advanceTimersByTime(100)
expect(calls).toBe(2)
expect(sockets).toHaveLength(1)
sockets[0].serverOpen()
expect(socket.state).toBe('open')
})
})

171
frontend/src/lib/ws.ts Normal file
View File

@@ -0,0 +1,171 @@
// Reconnecting WebSocket client for /ws/session with a typed event emitter.
// Exponential backoff on unexpected close; surfaces connection status so the
// UI can show "the connection to the other side is unstable".
import type { ClientFrame, ServerFrame } from './types'
export type VeilConnectionState =
| 'connecting'
| 'open'
| 'unstable' // reconnecting after a drop
| 'closed'
type FrameHandler = (frame: ServerFrame) => void
type StateHandler = (state: VeilConnectionState) => void
export type VeilSocketOptions = {
url?: string
/** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */
baseBackoffMs?: number
maxBackoffMs?: number
/** Ping interval to keep the veil warm (ms). 0 disables. */
pingIntervalMs?: number
/** Injectable WebSocket constructor (tests). */
socketFactory?: (url: string) => WebSocket
}
export function defaultSessionUrl(): string {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
return `${proto}${location.host}/ws/session`
}
export class VeilSocket {
private ws: WebSocket | null = null
private frameHandlers = new Set<FrameHandler>()
private stateHandlers = new Set<StateHandler>()
private readonly url: string
private readonly baseBackoffMs: number
private readonly maxBackoffMs: number
private readonly pingIntervalMs: number
private readonly socketFactory: (url: string) => WebSocket
private attempts = 0
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private deliberatelyClosed = false
private outbox: string[] = []
private _state: VeilConnectionState = 'closed'
constructor(opts: VeilSocketOptions = {}) {
this.url = opts.url ?? defaultSessionUrl()
this.baseBackoffMs = opts.baseBackoffMs ?? 800
this.maxBackoffMs = opts.maxBackoffMs ?? 15000
this.pingIntervalMs = opts.pingIntervalMs ?? 25000
this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url))
}
get state(): VeilConnectionState {
return this._state
}
onFrame(handler: FrameHandler): () => void {
this.frameHandlers.add(handler)
return () => this.frameHandlers.delete(handler)
}
onState(handler: StateHandler): () => void {
this.stateHandlers.add(handler)
return () => this.stateHandlers.delete(handler)
}
connect(): void {
this.deliberatelyClosed = false
this.openSocket()
}
close(): void {
this.deliberatelyClosed = true
this.clearTimers()
this.ws?.close()
this.ws = null
this.setState('closed')
}
/** Send a frame; queued while connecting, dropped while closed/unstable. */
send(frame: ClientFrame): void {
const raw = JSON.stringify(frame)
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(raw)
} else if (this._state === 'connecting') {
if (this.outbox.length < 64) this.outbox.push(raw)
}
}
private setState(state: VeilConnectionState): void {
if (this._state === state) return
this._state = state
this.stateHandlers.forEach((h) => h(state))
}
private clearTimers(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.pingTimer !== null) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
private openSocket(): void {
this.clearTimers()
this.setState('connecting')
let ws: WebSocket
try {
ws = this.socketFactory(this.url)
} catch {
this.scheduleReconnect()
return
}
this.ws = ws
ws.onopen = () => {
this.attempts = 0
this.setState('open')
// Flush frames queued while connecting.
for (const raw of this.outbox) ws.send(raw)
this.outbox = []
if (this.pingIntervalMs > 0) {
this.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
}
}, this.pingIntervalMs)
}
}
ws.onmessage = (ev: MessageEvent) => {
if (typeof ev.data !== 'string') return
let frame: ServerFrame
try {
frame = JSON.parse(ev.data) as ServerFrame
} catch {
return // ignore malformed whispers
}
this.frameHandlers.forEach((h) => h(frame))
}
ws.onclose = () => {
this.clearTimers()
this.ws = null
if (!this.deliberatelyClosed) this.scheduleReconnect()
else this.setState('closed')
}
ws.onerror = () => {
// onclose follows onerror; nothing extra to do here.
}
}
private scheduleReconnect(): void {
this.setState('unstable')
const delay = Math.min(
this.maxBackoffMs,
this.baseBackoffMs * 2 ** this.attempts,
)
this.attempts++
this.reconnectTimer = setTimeout(() => {
if (!this.deliberatelyClosed) this.openSocket()
}, delay)
}
}

View File

@@ -1,5 +1,8 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@fontsource/cinzel'
import '@fontsource/ibm-plex-mono'
import './i18n'
import App from './App' import App from './App'
import './App.css' import './App.css'

View File

@@ -0,0 +1,482 @@
/* Codex dossier — single-spirit occult record. Same token palette as the registry:
bg #07070d, panel rgba(16,16,26,0.85), phosphor #7cffb2, violet #b26bff,
blood #ff3b5c (sparingly), borders rgba(124,255,178,0.18). */
.codex-detail-page {
min-height: 100vh;
width: 100%;
background:
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
box-sizing: border-box;
position: relative;
}
/* scanlines + vignette */
.codex-detail-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.022) 0px,
rgba(255, 255, 255, 0.022) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
}
/* ---- nav / states ---- */
.codex-detail-nav {
max-width: 780px;
margin: 0 auto 2.2rem;
}
.codex-detail-back {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
transition: color 160ms ease, text-shadow 160ms ease;
}
.codex-detail-back:hover,
.codex-detail-back:focus-visible {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
}
.codex-detail-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 5rem 0;
animation: codex-detail-breathe 2.4s ease-in-out infinite;
}
.codex-detail-state-error {
color: #ff3b5c;
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
animation: none;
}
/* ---- 404 / not reached ---- */
.codex-detail-missing {
max-width: 560px;
margin: 4rem auto 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6rem;
}
.codex-detail-missing-sigil {
width: 110px;
height: 110px;
border-radius: 50%;
border: 1px dashed rgba(124, 255, 178, 0.35);
box-shadow:
inset 0 0 30px rgba(124, 255, 178, 0.06),
0 0 24px rgba(124, 255, 178, 0.08);
animation: codex-detail-sigil-spin 26s linear infinite;
}
.codex-detail-missing-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.4rem, 4vw, 2rem);
font-weight: 700;
letter-spacing: 0.14em;
margin: 0;
color: rgba(238, 247, 240, 0.85);
text-shadow: 0 0 18px rgba(124, 255, 178, 0.25);
animation: codex-detail-flicker 9s linear infinite;
}
/* ---- dossier sheet ---- */
.codex-dossier {
max-width: 780px;
margin: 0 auto;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 12px;
padding: clamp(1.8rem, 5vw, 3.2rem);
position: relative;
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 40px rgba(124, 255, 178, 0.05);
}
.codex-dossier::before {
content: '';
position: absolute;
inset: 10px;
border: 1px solid rgba(124, 255, 178, 0.08);
border-radius: 8px;
pointer-events: none;
}
.codex-dossier[data-rarity='rare'] {
border-color: rgba(178, 107, 255, 0.35);
}
.codex-dossier[data-rarity='mythic'] {
border-color: rgba(255, 157, 74, 0.4);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 44px rgba(255, 157, 74, 0.1);
}
/* ---- dossier header ---- */
.codex-dossier-header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.55rem;
padding-bottom: 1.8rem;
border-bottom: 1px solid rgba(124, 255, 178, 0.14);
margin-bottom: 1.8rem;
}
.codex-dossier-glyph {
width: 150px;
height: 150px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 42%,
hsl(var(--glyph-hue), 82%, 74%) 0%,
hsl(var(--glyph-hue), 80%, 65%) 24%,
hsla(var(--glyph-hue), 80%, 55%, 0.3) 52%,
transparent 72%
);
filter: blur(2px);
opacity: 0.92;
animation: codex-detail-glyph-drift 8s ease-in-out infinite;
margin-bottom: 0.4rem;
}
.codex-dossier-form {
font-size: 0.64rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
.codex-dossier-name {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.9rem, 5vw, 2.8rem);
font-weight: 700;
letter-spacing: 0.16em;
margin: 0.2rem 0 0;
color: #eef7f0;
text-shadow:
0 0 18px rgba(124, 255, 178, 0.3),
0 0 46px rgba(178, 107, 255, 0.2);
animation: codex-detail-flicker 11s linear infinite;
}
.codex-dossier-epithet {
margin: 0;
font-size: 0.82rem;
letter-spacing: 0.1em;
font-style: italic;
color: rgba(178, 107, 255, 0.8);
}
.codex-dossier-rarity {
display: inline-block;
font-size: 0.62rem;
letter-spacing: 0.3em;
text-transform: uppercase;
padding: 0.26rem 0.85rem;
border: 1px solid;
border-radius: 999px;
margin-top: 0.5rem;
}
.codex-dossier-rarity[data-rarity='common'] {
color: #8a8f98;
border-color: rgba(138, 143, 152, 0.45);
}
.codex-dossier-rarity[data-rarity='uncommon'] {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-dossier-rarity[data-rarity='rare'] {
color: #b26bff;
border-color: rgba(178, 107, 255, 0.5);
}
.codex-dossier-rarity[data-rarity='mythic'] {
color: #ff9d4a;
border-color: rgba(255, 157, 74, 0.55);
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
box-shadow: 0 0 16px rgba(255, 157, 74, 0.32), inset 0 0 8px rgba(255, 157, 74, 0.12);
}
/* ---- sections ---- */
.codex-dossier-section {
margin-bottom: 2rem;
}
.codex-dossier-heading {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.6);
margin: 0 0 0.9rem;
}
.codex-dossier-heading::after {
content: '';
display: block;
width: 42px;
height: 1px;
margin-top: 0.45rem;
background: linear-gradient(90deg, rgba(124, 255, 178, 0.5), transparent);
}
.codex-dossier-persona {
margin: 0;
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 1.02rem;
line-height: 1.75;
color: rgba(238, 247, 240, 0.85);
}
.codex-dossier-quotes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.codex-dossier-quote {
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 0.94rem;
line-height: 1.6;
color: rgba(216, 230, 220, 0.8);
padding-left: 1rem;
border-left: 2px solid rgba(178, 107, 255, 0.35);
}
.codex-dossier-none {
margin: 0;
font-size: 0.75rem;
letter-spacing: 0.08em;
font-style: italic;
color: rgba(216, 230, 220, 0.4);
}
/* ---- stats row ---- */
.codex-dossier-stats {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1px;
background: rgba(124, 255, 178, 0.12);
border: 1px solid rgba(124, 255, 178, 0.12);
border-radius: 8px;
overflow: hidden;
}
.codex-dossier-stat {
background: rgba(10, 10, 18, 0.85);
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.codex-dossier-stat dt {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.42);
}
.codex-dossier-stat dd {
margin: 0;
font-size: 0.95rem;
color: #7cffb2;
text-shadow: 0 0 10px rgba(124, 255, 178, 0.3);
overflow-wrap: anywhere;
}
/* ---- voice signature table ---- */
.codex-dossier-voice {
width: 100%;
border-collapse: collapse;
border: 1px solid rgba(124, 255, 178, 0.14);
border-radius: 8px;
overflow: hidden;
font-size: 0.78rem;
}
.codex-dossier-voice th,
.codex-dossier-voice td {
padding: 0.6rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(124, 255, 178, 0.08);
}
.codex-dossier-voice tr:last-child th,
.codex-dossier-voice tr:last-child td {
border-bottom: none;
}
.codex-dossier-voice tr:nth-child(even) th,
.codex-dossier-voice tr:nth-child(even) td {
background: rgba(124, 255, 178, 0.03);
}
.codex-dossier-voice th {
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.64rem;
color: rgba(216, 230, 220, 0.5);
width: 40%;
}
.codex-dossier-voice td {
color: #b26bff;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.3);
overflow-wrap: anywhere;
}
/* ---- CTA ---- */
.codex-dossier-footer {
display: flex;
justify-content: center;
padding-top: 0.6rem;
}
.codex-detail-cta {
display: inline-block;
font-size: 0.78rem;
letter-spacing: 0.26em;
text-transform: uppercase;
text-decoration: none;
color: #07070d;
background: #7cffb2;
border: 1px solid #7cffb2;
border-radius: 6px;
padding: 0.85rem 2.2rem;
box-shadow: 0 0 22px rgba(124, 255, 178, 0.35);
transition: box-shadow 180ms ease, transform 180ms ease, background 180ms ease;
}
.codex-detail-cta:hover,
.codex-detail-cta:focus-visible {
background: #a5ffcb;
box-shadow: 0 0 34px rgba(124, 255, 178, 0.6);
transform: translateY(-2px);
outline: none;
}
.codex-detail-cta-ghost {
color: #7cffb2;
background: transparent;
}
.codex-detail-cta-ghost:hover,
.codex-detail-cta-ghost:focus-visible {
color: #07070d;
background: #7cffb2;
}
/* ---- motion ---- */
@keyframes codex-detail-flicker {
0%,
100% {
opacity: 1;
}
91% {
opacity: 1;
}
92% {
opacity: 0.55;
}
93% {
opacity: 1;
}
95% {
opacity: 0.72;
}
96% {
opacity: 1;
}
}
@keyframes codex-detail-glyph-drift {
0%,
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
50% {
transform: translateY(-8px) scale(1.05);
opacity: 1;
}
}
@keyframes codex-detail-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@keyframes codex-detail-sigil-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.codex-dossier-name,
.codex-dossier-glyph,
.codex-detail-state,
.codex-detail-missing-sigil,
.codex-detail-missing-title {
animation: none;
}
.codex-detail-cta,
.codex-detail-back {
transition: none;
}
}

View File

@@ -0,0 +1,209 @@
// Codex dossier — a single spirit's occult record at /codex/:id.
// Fetches GET /api/codex/:id; 404s render a themed "not reached" state.
import { useEffect, useState, type CSSProperties } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { CodexEntityDetail } from '../lib/types'
import './CodexEntityPage.css'
type DetailState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'notfound' }
| { status: 'ready'; entity: CodexEntityDetail }
function formatDate(iso: string, locale: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
/** Voice profiles are loose records — render whatever the veil preserved. */
function formatVoiceValue(value: unknown): string {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function CodexEntityPage() {
const { t, i18n } = useTranslation()
const { id } = useParams<{ id: string }>()
const [state, setState] = useState<DetailState>({ status: 'loading' })
useEffect(() => {
if (!id) {
setState({ status: 'notfound' })
return
}
const controller = new AbortController()
setState({ status: 'loading' })
fetch(`/api/codex/${encodeURIComponent(id)}`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 404) {
setState({ status: 'notfound' })
return
}
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
const entity = (await res.json()) as CodexEntityDetail
setState({ status: 'ready', entity })
})
.catch(() => {
if (!controller.signal.aborted) setState({ status: 'error' })
})
return () => controller.abort()
}, [id])
return (
<div className="codex-detail-page">
<div className="codex-detail-overlay" aria-hidden="true" />
<nav className="codex-detail-nav">
<Link to="/codex" className="codex-detail-back">
{t('codex.back')}
</Link>
</nav>
{state.status === 'loading' && (
<p className="codex-detail-state">
{t('codex.detail.loading', 'summoning the dossier…')}
</p>
)}
{state.status === 'error' && (
<p className="codex-detail-state codex-detail-state-error" role="alert">
{t('common.error')}
</p>
)}
{state.status === 'notfound' && (
<div className="codex-detail-missing">
<div className="codex-detail-missing-sigil" aria-hidden="true" />
<h1 className="codex-detail-missing-title">
{t('codex.detail.notReached', 'this spirit has not been reached')}
</h1>
<Link to="/codex" className="codex-detail-cta codex-detail-cta-ghost">
{t('codex.back')}
</Link>
</div>
)}
{state.status === 'ready' && (
<Dossier entity={state.entity} locale={i18n.language} />
)}
</div>
)
}
function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string }) {
const { t } = useTranslation()
const discoverer = entity.discovered_by ?? t('codex.detail.unknownMedium', 'unknown medium')
const stats: Array<{ label: string; value: string }> = [
{
label: t('codex.detail.stats.sightings', 'sightings'),
value: String(entity.sightings),
},
{
label: t('codex.detail.stats.contacts', 'contacts'),
value: String(entity.contact_count),
},
{
label: t('codex.detail.stats.discoveredBy', 'discovered by'),
value: discoverer,
},
{
label: t('codex.detail.stats.discoveredAt', 'entered the registry'),
value: formatDate(entity.discovered_at, locale),
},
]
return (
<article className="codex-dossier" data-rarity={entity.rarity}>
<header className="codex-dossier-header">
<div
className="codex-dossier-glyph"
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
aria-hidden="true"
/>
<span className="codex-dossier-form">
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
</span>
<h1 className="codex-dossier-name">{entity.name}</h1>
<p className="codex-dossier-epithet">{entity.epithet}</p>
<span className="codex-dossier-rarity" data-rarity={entity.rarity}>
{t(`codex.rarity.${entity.rarity}`)}
</span>
</header>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">{t('codex.persona')}</h2>
<p className="codex-dossier-persona">{entity.persona}</p>
</section>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.utterances', 'known utterances')}
</h2>
{entity.quotes.length > 0 ? (
<ul className="codex-dossier-quotes">
{entity.quotes.map((quote, i) => (
<li key={i} className="codex-dossier-quote">
“{quote}”
</li>
))}
</ul>
) : (
<p className="codex-dossier-none">
{t('codex.detail.noUtterances', 'no utterances recorded yet')}
</p>
)}
</section>
<section className="codex-dossier-section">
<dl className="codex-dossier-stats">
{stats.map((stat) => (
<div key={stat.label} className="codex-dossier-stat">
<dt>{stat.label}</dt>
<dd>{stat.value}</dd>
</div>
))}
</dl>
</section>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.voiceSignature', 'voice signature')}
</h2>
<table className="codex-dossier-voice">
<tbody>
{Object.entries(entity.voice).map(([key, value]) => (
<tr key={key}>
<th scope="row">{key.replace(/_/g, ' ')}</th>
<td>{formatVoiceValue(value)}</td>
</tr>
))}
</tbody>
</table>
</section>
<footer className="codex-dossier-footer">
<Link to="/seance" className="codex-detail-cta">
{t('codex.detail.attemptContact', 'attempt contact')}
</Link>
</footer>
</article>
)
}

View File

@@ -0,0 +1,412 @@
/* The Codex — gothic registry grid. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
.codex-page {
min-height: 100vh;
width: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
box-sizing: border-box;
position: relative;
}
/* scanlines + vignette */
.codex-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.022) 0px,
rgba(255, 255, 255, 0.022) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
}
/* ---- header ---- */
.codex-header {
text-align: center;
margin: 0 auto 2.5rem;
max-width: 760px;
position: relative;
}
.codex-back {
display: inline-block;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
margin-bottom: 1.6rem;
transition: color 160ms ease, text-shadow 160ms ease;
}
.codex-back:hover,
.codex-back:focus-visible {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
}
.codex-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(2.2rem, 6vw, 3.6rem);
font-weight: 700;
letter-spacing: 0.22em;
margin: 0 0 0.6rem;
color: #eef7f0;
text-shadow:
0 0 18px rgba(124, 255, 178, 0.35),
0 0 46px rgba(178, 107, 255, 0.25);
animation: codex-flicker 9s linear infinite;
}
.codex-subtitle {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.08em;
color: rgba(216, 230, 220, 0.55);
font-style: italic;
}
/* ---- control pills ---- */
.codex-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.8rem 2rem;
margin-bottom: 2.6rem;
}
.codex-pill-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.45rem;
}
.codex-pill {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.42rem 0.95rem;
color: rgba(216, 230, 220, 0.6);
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 999px;
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease, box-shadow 160ms ease,
background 160ms ease;
}
.codex-pill:hover {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-pill:focus-visible {
outline: none;
border-color: #7cffb2;
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.25), 0 0 14px rgba(124, 255, 178, 0.35);
}
.codex-pill-active,
.codex-pill-active:hover {
color: #07070d;
background: #7cffb2;
border-color: #7cffb2;
box-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
}
.codex-pill-active[data-rarity='rare'] {
background: #b26bff;
border-color: #b26bff;
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
}
.codex-pill-active[data-rarity='mythic'] {
background: #ff9d4a;
border-color: #ff9d4a;
box-shadow: 0 0 18px rgba(255, 157, 74, 0.55);
}
.codex-pill-active[data-rarity='common'] {
background: #8a8f98;
border-color: #8a8f98;
box-shadow: 0 0 14px rgba(138, 143, 152, 0.4);
}
/* ---- states ---- */
.codex-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 4rem 0;
animation: codex-breathe 2.4s ease-in-out infinite;
}
.codex-state-error {
color: #ff3b5c;
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
animation: none;
}
/* ---- grid + cards ---- */
.codex-grid {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 1280px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1.4rem;
}
.codex-card-item {
display: flex;
}
.codex-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 10px;
padding: 1.8rem 1.4rem 1.5rem;
position: relative;
overflow: hidden;
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.codex-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(124, 255, 178, 0.05), transparent 35%);
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.codex-card:hover,
.codex-card:focus-visible {
transform: translateY(-3px);
border-color: rgba(124, 255, 178, 0.5);
box-shadow:
0 0 0 1px rgba(124, 255, 178, 0.12),
0 10px 34px rgba(0, 0, 0, 0.55),
0 0 26px rgba(124, 255, 178, 0.14);
}
.codex-card:hover::before,
.codex-card:focus-visible::before {
opacity: 1;
}
.codex-card:focus-visible {
outline: none;
}
.codex-card[data-rarity='rare']:hover,
.codex-card[data-rarity='rare']:focus-visible {
border-color: rgba(178, 107, 255, 0.55);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 26px rgba(178, 107, 255, 0.2);
}
.codex-card[data-rarity='mythic']:hover,
.codex-card[data-rarity='mythic']:focus-visible {
border-color: rgba(255, 157, 74, 0.55);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 30px rgba(255, 157, 74, 0.22);
}
/* ---- ghost glyph ---- */
.codex-glyph-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
margin-bottom: 1rem;
}
.codex-glyph {
width: 88px;
height: 88px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 42%,
hsl(var(--glyph-hue), 82%, 74%) 0%,
hsl(var(--glyph-hue), 80%, 65%) 24%,
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
transparent 72%
);
filter: blur(1.5px);
opacity: 0.9;
animation: codex-glyph-drift 7s ease-in-out infinite;
}
.codex-glyph-form {
font-size: 0.62rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
/* ---- card body ---- */
.codex-card-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.codex-card-name {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.28rem;
font-weight: 700;
letter-spacing: 0.1em;
margin: 0;
color: #eef7f0;
}
.codex-card-epithet {
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.08em;
color: rgba(178, 107, 255, 0.75);
font-style: italic;
}
.codex-rarity {
display: inline-block;
font-size: 0.6rem;
letter-spacing: 0.28em;
text-transform: uppercase;
padding: 0.22rem 0.7rem;
border: 1px solid;
border-radius: 999px;
margin-top: 0.2rem;
}
.codex-rarity[data-rarity='common'] {
color: #8a8f98;
border-color: rgba(138, 143, 152, 0.45);
}
.codex-rarity[data-rarity='uncommon'] {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-rarity[data-rarity='rare'] {
color: #b26bff;
border-color: rgba(178, 107, 255, 0.5);
}
.codex-rarity[data-rarity='mythic'] {
color: #ff9d4a;
border-color: rgba(255, 157, 74, 0.55);
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
box-shadow: 0 0 14px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
}
.codex-card-quote {
margin: 0.5rem 0 0;
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 0.86rem;
line-height: 1.55;
color: rgba(216, 230, 220, 0.78);
}
.codex-card-meta {
margin: 0.35rem 0 0;
font-size: 0.66rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.55);
}
.codex-card-discoverer {
color: rgba(216, 230, 220, 0.38);
margin-top: 0;
}
/* ---- motion ---- */
@keyframes codex-flicker {
0%,
100% {
opacity: 1;
}
91% {
opacity: 1;
}
92% {
opacity: 0.55;
}
93% {
opacity: 1;
}
95% {
opacity: 0.72;
}
96% {
opacity: 1;
}
}
@keyframes codex-glyph-drift {
0%,
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
50% {
transform: translateY(-6px) scale(1.06);
opacity: 1;
}
}
@keyframes codex-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.codex-title,
.codex-glyph,
.codex-state {
animation: none;
}
.codex-card,
.codex-pill,
.codex-back {
transition: none;
}
}

View File

@@ -0,0 +1,190 @@
// The Codex — public registry of every spirit ever contacted.
// Filterable / sortable grid of entity dossiers, fetched from GET /api/codex.
import { useEffect, useState, type CSSProperties } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { CodexEntity, CodexListResponse, Rarity, SortOrder } from '../lib/types'
import './CodexPage.css'
type RarityFilter = Rarity | 'all'
type LoadState = 'loading' | 'error' | 'ready'
const RARITIES: readonly Rarity[] = ['common', 'uncommon', 'rare', 'mythic']
const SORTS: readonly SortOrder[] = ['recent', 'contacted']
function formatDate(iso: string, locale: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
export function CodexPage() {
const { t, i18n } = useTranslation()
const [rarity, setRarity] = useState<RarityFilter>('all')
const [sort, setSort] = useState<SortOrder>('recent')
const [entities, setEntities] = useState<CodexEntity[]>([])
const [state, setState] = useState<LoadState>('loading')
useEffect(() => {
const controller = new AbortController()
setState('loading')
const params = new URLSearchParams({ sort, limit: '120' })
if (rarity !== 'all') params.set('rarity', rarity)
fetch(`/api/codex?${params.toString()}`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
return (await res.json()) as CodexListResponse
})
.then((data) => {
setEntities(data.entities)
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [rarity, sort])
return (
<div className="codex-page">
<div className="codex-overlay" aria-hidden="true" />
<header className="codex-header">
<Link to="/" className="codex-back">
{t('codex.header.back', '← the threshold')}
</Link>
<h1 className="codex-title">{t('codex.header.title', 'THE CODEX')}</h1>
<p className="codex-subtitle">
{t(
'codex.header.subtitle',
'every spirit ever contacted, catalogued by the seekers who reached them',
)}
</p>
</header>
<div className="codex-controls">
<div
className="codex-pill-group"
role="group"
aria-label={t('codex.controls.rarity', 'filter by rarity')}
>
<button
type="button"
className={`codex-pill${rarity === 'all' ? ' codex-pill-active' : ''}`}
aria-pressed={rarity === 'all'}
onClick={() => setRarity('all')}
>
{t('codex.filterAll')}
</button>
{RARITIES.map((r) => (
<button
key={r}
type="button"
className={`codex-pill${rarity === r ? ' codex-pill-active' : ''}`}
data-rarity={r}
aria-pressed={rarity === r}
onClick={() => setRarity(r)}
>
{t(`codex.rarity.${r}`)}
</button>
))}
</div>
<div
className="codex-pill-group"
role="group"
aria-label={t('codex.controls.sort', 'sort the registry')}
>
{SORTS.map((s) => (
<button
key={s}
type="button"
className={`codex-pill${sort === s ? ' codex-pill-active' : ''}`}
aria-pressed={sort === s}
onClick={() => setSort(s)}
>
{t(`codex.sort.${s}`)}
</button>
))}
</div>
</div>
{state === 'loading' && (
<p className="codex-state">{t('codex.state.loading', 'consulting the registry…')}</p>
)}
{state === 'error' && (
<p className="codex-state codex-state-error" role="alert">
{t('common.error')}
</p>
)}
{state === 'ready' && entities.length === 0 && (
<p className="codex-state">
{t('codex.state.empty', 'no spirits of this tier have been reached yet')}
</p>
)}
{state === 'ready' && entities.length > 0 && (
<ul className="codex-grid">
{entities.map((entity) => (
<CodexCard key={entity.id} entity={entity} locale={i18n.language} />
))}
</ul>
)}
</div>
)
}
function CodexCard({ entity, locale }: { entity: CodexEntity; locale: string }) {
const { t } = useTranslation()
const discoverer = entity.discovered_by ?? t('codex.card.unknownMedium', 'unknown medium')
return (
<li className="codex-card-item">
<Link to={`/codex/${entity.id}`} className="codex-card" data-rarity={entity.rarity}>
<div className="codex-glyph-wrap">
<div
className="codex-glyph"
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
aria-hidden="true"
/>
<span className="codex-glyph-form">
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
</span>
</div>
<div className="codex-card-body">
<h2 className="codex-card-name">{entity.name}</h2>
<p className="codex-card-epithet">{entity.epithet}</p>
<span className="codex-rarity" data-rarity={entity.rarity}>
{t(`codex.rarity.${entity.rarity}`)}
</span>
{entity.quotes.length > 0 && (
<blockquote className="codex-card-quote">“{entity.quotes[0]}”</blockquote>
)}
<p className="codex-card-meta">
{t('codex.card.contacted', {
count: entity.contact_count,
defaultValue: 'contacted {{count}} times',
defaultValue_one: 'contacted {{count}} time',
defaultValue_other: 'contacted {{count}} times',
})}
</p>
<p className="codex-card-meta codex-card-discoverer">
{t('codex.card.discoveredBy', {
name: discoverer,
date: formatDate(entity.discovered_at, locale),
defaultValue: 'discovered by {{name}} · {{date}}',
})}
</p>
</div>
</Link>
</li>
)
}

View File

@@ -0,0 +1,275 @@
/* EnterPage: the threshold. Centered gothic auth panel over drifting CSS
fog. Scoped under .enter-page. */
.enter-page {
position: relative;
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #07070d;
color: #d8e6dd;
font-family: 'IBM Plex Mono', monospace;
overflow: hidden;
padding: 2rem 1rem;
box-sizing: border-box;
}
/* ---------- smoky backdrop (pure CSS fog) ---------- */
.enter-fog {
position: absolute;
inset: 0;
pointer-events: none;
}
.enter-fog::before,
.enter-fog::after {
content: '';
position: absolute;
width: 65vmax;
height: 65vmax;
border-radius: 50%;
filter: blur(70px);
opacity: 0.14;
}
.enter-fog::before {
background: radial-gradient(circle, #7cffb2 0%, transparent 62%);
top: -18%;
left: -12%;
animation: fog-drift-a 27s ease-in-out infinite alternate;
}
.enter-fog::after {
background: radial-gradient(circle, #b26bff 0%, transparent 62%);
bottom: -22%;
right: -14%;
animation: fog-drift-b 34s ease-in-out infinite alternate;
}
@keyframes fog-drift-a {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(9vw, 6vh) scale(1.15); }
}
@keyframes fog-drift-b {
from { transform: translate(0, 0) scale(1.1); }
to { transform: translate(-8vw, -7vh) scale(0.95); }
}
/* dimmed vignette + scanlines */
.enter-scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(ellipse at center, transparent 40%, rgba(3, 3, 7, 0.75) 100%),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.022) 0,
rgba(255, 255, 255, 0.022) 1px,
transparent 1px,
transparent 3px
);
}
/* ---------- the panel ---------- */
.enter-panel {
position: relative;
z-index: 2;
width: min(92vw, 420px);
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 2.6rem 2.2rem 2rem;
backdrop-filter: blur(4px);
box-shadow:
0 0 70px rgba(0, 0, 0, 0.65),
0 0 32px rgba(124, 255, 178, 0.07);
}
.enter-title {
margin: 0 0 0.4rem;
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: 1.7rem;
letter-spacing: 0.3em;
text-align: center;
text-transform: uppercase;
color: #eafff3;
text-shadow:
0 0 16px rgba(124, 255, 178, 0.45),
0 0 50px rgba(124, 255, 178, 0.15);
animation: enter-flicker 6s infinite;
}
@keyframes enter-flicker {
0%, 100% { opacity: 1; }
93% { opacity: 1; }
94% { opacity: 0.7; }
95% { opacity: 1; }
97% { opacity: 0.85; }
98% { opacity: 1; }
}
.enter-sub {
margin: 0 0 1.9rem;
text-align: center;
font-size: 0.78rem;
font-style: italic;
color: rgba(216, 230, 221, 0.5);
min-height: 1.2em;
}
/* ---------- mode toggle ---------- */
.enter-tabs {
display: flex;
border: 1px solid rgba(124, 255, 178, 0.18);
margin-bottom: 1.6rem;
}
.enter-tab {
flex: 1;
background: transparent;
border: none;
border-radius: 0;
padding: 0.7rem 0;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(216, 230, 221, 0.55);
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.enter-tab:hover {
color: #7cffb2;
background: rgba(124, 255, 178, 0.06);
}
.enter-tab.active,
.enter-tab.active:hover {
color: #07120c;
background: #7cffb2;
box-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
}
.enter-tab:focus-visible {
outline: 2px solid #b26bff;
outline-offset: -2px;
}
/* ---------- form ---------- */
.enter-form {
display: block;
}
.enter-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1.15rem;
}
.enter-field span {
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.65);
}
.enter-field input {
background: rgba(7, 7, 13, 0.8);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 0;
color: #eafff3;
padding: 0.72rem 0.8rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.enter-field input:focus {
border-color: #7cffb2;
box-shadow:
0 0 0 1px rgba(124, 255, 178, 0.35),
0 0 18px rgba(124, 255, 178, 0.18);
}
.enter-error {
margin: 0 0 1.15rem;
padding: 0.6rem 0.75rem;
font-size: 0.78rem;
line-height: 1.5;
color: #ff3b5c;
border: 1px solid rgba(255, 59, 92, 0.35);
background: rgba(255, 59, 92, 0.07);
text-shadow: 0 0 8px rgba(255, 59, 92, 0.35);
animation: error-flicker 2.6s infinite;
}
@keyframes error-flicker {
0%, 100% { opacity: 1; }
88% { opacity: 1; }
89% { opacity: 0.65; }
90% { opacity: 1; }
}
.enter-submit {
width: 100%;
font-family: 'Cinzel', serif;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #07120c;
background: linear-gradient(180deg, #8dffc0, #4fd392);
border: 1px solid #7cffb2;
border-radius: 0;
padding: 0.85rem 1rem;
cursor: pointer;
box-shadow:
0 0 22px rgba(124, 255, 178, 0.3),
inset 0 0 12px rgba(7, 7, 13, 0.35);
transition: box-shadow 0.25s ease, transform 0.25s ease, opacity 0.2s ease;
}
.enter-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow:
0 0 40px rgba(124, 255, 178, 0.55),
inset 0 0 12px rgba(7, 7, 13, 0.35);
}
.enter-submit:focus-visible {
outline: 2px solid #b26bff;
outline-offset: 3px;
}
.enter-submit:disabled {
opacity: 0.55;
cursor: wait;
}
.enter-smallprint {
margin: 1.6rem 0 0;
text-align: center;
font-size: 0.66rem;
letter-spacing: 0.06em;
color: rgba(216, 230, 221, 0.38);
}
@media (prefers-reduced-motion: reduce) {
.enter-fog::before,
.enter-fog::after,
.enter-title,
.enter-error {
animation: none;
}
}

View File

@@ -0,0 +1,129 @@
// Enter page: the threshold. Gothic auth panel floating in CSS fog —
// toggles between Reconnect (login) and First contact (register), and
// steps the medium through to the séance on success.
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import './EnterPage.css'
type EnterMode = 'login' | 'register'
export function EnterPage() {
const { t } = useTranslation()
const { user, login, register } = useAuth()
const navigate = useNavigate()
const [mode, setMode] = useState<EnterMode>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// Already seated at the table — no need to knock again.
if (user) {
return <Navigate to="/seance" replace />
}
const switchMode = (next: EnterMode) => {
setMode(next)
setError(null)
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (busy) return
setError(null)
setBusy(true)
try {
if (mode === 'register') {
await register(username, password)
} else {
await login(username, password)
}
navigate('/seance')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setBusy(false)
}
}
return (
<div className="enter-page">
<div className="enter-fog" aria-hidden="true" />
<div className="enter-scanlines" aria-hidden="true" />
<main className="enter-panel">
<h1 className="enter-title">{t('nav.home')}</h1>
<p className="enter-sub">
{mode === 'login' ? t('enter.reconnectSub') : t('enter.firstContactSub')}
</p>
<div className="enter-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={mode === 'login'}
className={`enter-tab${mode === 'login' ? ' active' : ''}`}
onClick={() => switchMode('login')}
>
{t('enter.reconnect')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'register'}
className={`enter-tab${mode === 'register' ? ' active' : ''}`}
onClick={() => switchMode('register')}
>
{t('enter.firstContact')}
</button>
</div>
<form className="enter-form" onSubmit={handleSubmit}>
<label className="enter-field">
<span>{t('enter.username')}</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
autoComplete="username"
autoFocus
/>
</label>
<label className="enter-field">
<span>{t('enter.password')}</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
</label>
{error && (
<p className="enter-error" role="alert">
{t('enter.failed', { message: error })}
</p>
)}
<button type="submit" className="enter-submit" disabled={busy}>
{mode === 'login' ? t('enter.submitLogin') : t('enter.submitRegister')}
</button>
</form>
<p className="enter-smallprint">
{t('enter.smallPrint', {
defaultValue: 'your medium handle is only ever stored on this server',
})}
</p>
</main>
</div>
)
}

View File

@@ -0,0 +1,466 @@
/* LandingPage: gothic x hacker front door. Scoped under .landing to avoid
colliding with other pages' styles. */
.landing {
position: relative;
width: 100%;
min-height: 100vh;
background: #07070d;
color: #d8e6dd;
font-family: 'IBM Plex Mono', monospace;
overflow-x: hidden;
text-align: left;
}
/* scanline + vignette film over everything */
.landing-scanlines {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.55) 100%),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 3px
);
}
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.smoke-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero-content {
position: relative;
z-index: 2;
text-align: center;
padding: 2rem 1.5rem;
max-width: 60rem;
}
.hero-kicker {
font-size: 0.78rem;
letter-spacing: 0.34em;
text-transform: lowercase;
color: rgba(216, 230, 221, 0.55);
margin: 0 0 1.25rem;
}
.hero-kicker::before,
.hero-kicker::after {
content: '//';
color: rgba(124, 255, 178, 0.4);
margin: 0 0.75rem;
}
.glitch-title {
position: relative;
margin: 0;
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: clamp(2.6rem, 11vw, 8.5rem);
letter-spacing: 0.08em;
line-height: 1.05;
color: #eafff3;
text-shadow:
0 0 18px rgba(124, 255, 178, 0.45),
0 0 60px rgba(124, 255, 178, 0.18),
0 0 120px rgba(178, 107, 255, 0.12);
animation: title-flicker 5.2s infinite;
}
.glitch-title::before,
.glitch-title::after {
content: attr(data-text);
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.glitch-title::before {
color: #7cffb2;
animation: glitch-slice-a 2.9s infinite steps(1, end);
}
.glitch-title::after {
color: #b26bff;
animation: glitch-slice-b 3.4s infinite steps(1, end);
}
/* short jump-cut bursts, long eerie calm in between */
@keyframes glitch-slice-a {
0%, 84%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
85% { opacity: 0.85; transform: translate(-7px, -3px); clip-path: inset(8% 0 64% 0); }
88% { opacity: 0.85; transform: translate(5px, 2px); clip-path: inset(56% 0 20% 0); }
91% { opacity: 0.85; transform: translate(-4px, 3px); clip-path: inset(32% 0 44% 0); }
93% { opacity: 0; }
}
@keyframes glitch-slice-b {
0%, 78%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
79% { opacity: 0.8; transform: translate(6px, 2px); clip-path: inset(62% 0 12% 0); }
82% { opacity: 0.8; transform: translate(-5px, -2px); clip-path: inset(14% 0 70% 0); }
85% { opacity: 0.8; transform: translate(3px, -3px); clip-path: inset(40% 0 36% 0); }
87% { opacity: 0; }
}
@keyframes title-flicker {
0%, 100% { opacity: 1; }
91% { opacity: 1; }
92% { opacity: 0.72; }
93% { opacity: 1; }
96% { opacity: 0.86; }
97% { opacity: 1; }
}
.hero-tagline-slot {
min-height: 1.6em;
margin: 1.5rem 0 2.5rem;
}
.hero-tagline {
display: inline-block;
font-size: clamp(0.9rem, 2vw, 1.15rem);
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
animation: tagline-in 1.1s ease both;
}
@keyframes tagline-in {
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.hero-ctas {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.1rem;
}
.cta-primary {
font-family: 'Cinzel', serif;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.22em;
color: #07120c;
background: linear-gradient(180deg, #8dffc0, #4fd392);
border: 1px solid #7cffb2;
padding: 0.95rem 2.4rem;
cursor: pointer;
box-shadow:
0 0 24px rgba(124, 255, 178, 0.35),
inset 0 0 12px rgba(7, 7, 13, 0.35);
transition: box-shadow 0.25s ease, transform 0.25s ease;
}
.cta-primary:hover {
transform: translateY(-2px);
box-shadow:
0 0 44px rgba(124, 255, 178, 0.6),
inset 0 0 12px rgba(7, 7, 13, 0.35);
}
.cta-primary:focus-visible {
outline: 2px solid #b26bff;
outline-offset: 3px;
}
.cta-secondary {
color: #b26bff;
font-size: 0.85rem;
letter-spacing: 0.12em;
text-decoration: none;
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
padding-bottom: 2px;
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
}
.cta-secondary:hover {
color: #d3a8ff;
border-color: #d3a8ff;
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
}
/* ---------- shared section chrome ---------- */
.landing-section {
position: relative;
z-index: 2;
max-width: 1100px;
margin: 0 auto;
padding: 5rem 1.5rem;
}
.section-title {
display: flex;
align-items: center;
justify-content: center;
gap: 0.7rem;
font-family: 'Cinzel', serif;
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 700;
letter-spacing: 0.28em;
color: #eafff3;
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
margin: 0 0 2.5rem;
text-align: center;
}
.section-title::before,
.section-title::after {
content: '';
flex: 0 0 3rem;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
}
.section-title::after {
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
}
/* ---------- live veil activity ---------- */
.live-dot {
flex: none;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #7cffb2;
box-shadow: 0 0 10px rgba(124, 255, 178, 0.9);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.8); }
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: rgba(124, 255, 178, 0.18);
border: 1px solid rgba(124, 255, 178, 0.18);
box-shadow: 0 0 40px rgba(124, 255, 178, 0.06);
}
.stat-cell {
background: rgba(16, 16, 26, 0.85);
padding: 1.9rem 1rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-value {
font-size: clamp(1.6rem, 4vw, 2.6rem);
color: #7cffb2;
text-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(216, 230, 221, 0.5);
}
/* ---------- how the veil answers ---------- */
.modes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.mode-card {
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 1.75rem 1.35rem;
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
}
.mode-card:hover {
border-color: rgba(124, 255, 178, 0.55);
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
transform: translateY(-4px);
}
.mode-glyph {
color: #7cffb2;
margin-bottom: 1rem;
transition: color 0.25s ease, filter 0.25s ease;
}
.mode-card:hover .mode-glyph {
color: #b26bff;
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
}
.mode-glyph-svg {
width: 44px;
height: 44px;
display: block;
}
.mode-name {
font-family: 'Cinzel', serif;
font-size: 1.05rem;
letter-spacing: 0.14em;
color: #eafff3;
margin: 0 0 0.7rem;
}
.mode-desc {
font-size: 0.82rem;
line-height: 1.65;
color: rgba(216, 230, 221, 0.62);
margin: 0;
}
/* ---------- featured spirits ---------- */
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.spirit-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 1.9rem 1.4rem;
text-align: center;
text-decoration: none;
color: inherit;
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
}
.spirit-card:hover {
border-color: rgba(178, 107, 255, 0.55);
box-shadow: 0 0 32px rgba(178, 107, 255, 0.14);
transform: translateY(-4px);
}
.spirit-name {
font-family: 'Cinzel', serif;
font-size: 1.15rem;
letter-spacing: 0.1em;
color: #eafff3;
margin-top: 0.6rem;
}
.spirit-epithet {
font-size: 0.8rem;
font-style: italic;
color: rgba(216, 230, 221, 0.55);
}
.rarity-badge {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border: 1px solid currentColor;
margin-top: 0.25rem;
}
.spirit-card .rarity-common { color: rgba(124, 255, 178, 0.7); }
.spirit-card .rarity-uncommon { color: #b26bff; }
.spirit-card .rarity-rare { color: #b26bff; text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); }
.spirit-card .rarity-mythic { color: #ff3b5c; text-shadow: 0 0 10px rgba(255, 59, 92, 0.7); }
.spirit-quote {
margin-top: 0.8rem;
font-family: Georgia, serif;
font-style: italic;
font-size: 0.82rem;
line-height: 1.6;
color: rgba(216, 230, 221, 0.6);
quotes: '\201C' '\201D';
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.featured-empty {
text-align: center;
color: rgba(216, 230, 221, 0.5);
font-style: italic;
}
/* ---------- footer ---------- */
.landing-footer {
position: relative;
z-index: 2;
border-top: 1px solid rgba(124, 255, 178, 0.18);
padding: 2.5rem 1.5rem 3rem;
text-align: center;
font-size: 0.78rem;
color: rgba(216, 230, 221, 0.5);
}
.landing-footer p {
margin: 0.25rem 0;
}
.footer-colophon {
color: rgba(124, 255, 178, 0.45);
letter-spacing: 0.14em;
font-size: 0.7rem;
}
/* ---------- responsive + reduced motion ---------- */
@media (max-width: 720px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.section-title::before,
.section-title::after {
flex-basis: 1.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.glitch-title,
.glitch-title::before,
.glitch-title::after,
.hero-tagline,
.live-dot {
animation: none;
}
.glitch-title::before,
.glitch-title::after {
opacity: 0;
}
}

View File

@@ -0,0 +1,347 @@
// Landing page: the hype front door. Full-viewport glitch hero over a
// self-contained drifting-smoke canvas, live veil-activity counters,
// the four contact modes, featured spirits from the codex, and the CTA.
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { GhostGlyph } from '../components/GhostGlyph'
import type { CodexEntity, CodexListResponse, Mode, Stats } from '../lib/types'
import './LandingPage.css'
const STATS_REFRESH_MS = 15_000
const TAGLINE_MS = 4_000
const MODE_ORDER: Mode[] = ['radio', 'evp', 'wire', 'ouija']
const STAT_KEYS = ['entities', 'sessions', 'utterances', 'anomalies'] as const
/** Hacker-occult stroke glyphs for the four contact modes (no emoji, no fonts). */
function ModeGlyph({ mode }: { mode: Mode }) {
const common = {
viewBox: '0 0 48 48',
className: 'mode-glyph-svg',
'aria-hidden': true as const,
}
switch (mode) {
case 'radio':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="24" cy="18" r="3" />
<path d="M24 21 L15 42 M24 21 L33 42 M18.6 33 h10.8" />
<path d="M16.5 12.5 a10.5 10.5 0 0 1 15 0" />
<path d="M11.5 7.5 a18 18 0 0 1 25 0" />
</g>
</svg>
)
case 'evp':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M7 24 h4 M37 24 h4" />
<path d="M14 20 v8 M19 14 v20 M24 18 v12 M29 10 v28 M34 17 v14" />
</g>
</svg>
)
case 'wire':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M14.6 31.6 L21.7 16.6 M26.3 16.6 L33.4 29.6 M15.2 34 h17.6" />
<circle cx="12" cy="34" r="3.2" />
<circle cx="24" cy="13" r="3.2" />
<circle cx="36" cy="32" r="3.2" />
</g>
</svg>
)
case 'ouija':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M24 6 C29 14 39 20 39 30 A15 15 0 1 1 9 30 C9 20 19 14 24 6 Z" />
<circle cx="24" cy="29" r="5.5" />
</g>
</svg>
)
}
}
type Wisp = {
x: number
y: number
r: number
hue: number
alpha: number
vy: number
swayAmp: number
swaySpeed: number
phase: number
}
/**
* Lightweight 2D-canvas fog: slow phosphor-green/violet wisps drifting
* upward on an additive blend. Self-contained; tears itself down on unmount.
*/
function SmokeCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let raf = 0
let wisps: Wisp[] = []
const HUES = [150, 275] // phosphor green / violet
const spawn = (anywhere: boolean): Wisp => {
const r = 50 + Math.random() * 120
return {
x: Math.random() * canvas.width,
y: anywhere ? Math.random() * canvas.height : canvas.height + r,
r,
hue: HUES[Math.floor(Math.random() * HUES.length)],
alpha: 0.025 + Math.random() * 0.05,
vy: 0.06 + Math.random() * 0.22,
swayAmp: 0.1 + Math.random() * 0.35,
swaySpeed: 0.0004 + Math.random() * 0.0009,
phase: Math.random() * Math.PI * 2,
}
}
const seed = () => {
const count = Math.max(14, Math.min(34, Math.floor((canvas.width * canvas.height) / 42000)))
wisps = Array.from({ length: count }, () => spawn(true))
}
const resize = () => {
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
seed()
}
const frame = (now: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < wisps.length; i++) {
const w = wisps[i]
w.y -= w.vy
const x = w.x + Math.sin(now * w.swaySpeed + w.phase) * w.swayAmp * 40
if (w.y + w.r < 0) {
wisps[i] = spawn(false)
continue
}
const g = ctx.createRadialGradient(x, w.y, 0, x, w.y, w.r)
g.addColorStop(0, `hsla(${w.hue}, 90%, 65%, ${w.alpha})`)
g.addColorStop(1, `hsla(${w.hue}, 90%, 65%, 0)`)
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(x, w.y, w.r, 0, Math.PI * 2)
ctx.fill()
}
raf = requestAnimationFrame(frame)
}
resize()
window.addEventListener('resize', resize)
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
}
}, [])
return <canvas ref={canvasRef} className="smoke-canvas" aria-hidden="true" />
}
/** Count-up number that eases toward `value`; renders '—' when unknown. */
function AnimatedNumber({ value }: { value: number | null }) {
const [display, setDisplay] = useState<number | null>(value)
const shownRef = useRef<number | null>(value)
useEffect(() => {
if (value === null) {
shownRef.current = null
setDisplay(null)
return
}
const from = shownRef.current ?? 0
if (from === value) {
setDisplay(value)
return
}
const start = performance.now()
const duration = 900
let raf = requestAnimationFrame(function tick(now: number) {
const p = Math.min(1, (now - start) / duration)
const eased = 1 - Math.pow(1 - p, 3)
const v = Math.round(from + (value - from) * eased)
shownRef.current = v
setDisplay(v)
if (p < 1) raf = requestAnimationFrame(tick)
})
return () => cancelAnimationFrame(raf)
}, [value])
return <span className="stat-value">{display === null ? '—' : display.toLocaleString()}</span>
}
export function LandingPage() {
const { t } = useTranslation()
const { user } = useAuth()
const navigate = useNavigate()
const [stats, setStats] = useState<Stats | null>(null)
const [featured, setFeatured] = useState<CodexEntity[] | null>(null)
const [taglineIdx, setTaglineIdx] = useState(0)
const taglineObjects: unknown = t('landing.taglines', { returnObjects: true })
const taglines = Array.isArray(taglineObjects)
? taglineObjects.filter((s): s is string => typeof s === 'string')
: []
const title = t('common.appName')
// Rotate the hero tagline; the key remount re-triggers the CSS fade.
useEffect(() => {
if (taglines.length < 2) return
const id = window.setInterval(() => setTaglineIdx((i) => (i + 1) % taglines.length), TAGLINE_MS)
return () => window.clearInterval(id)
}, [taglines.length])
// Live veil activity: poll the public stats endpoint, fail silently.
useEffect(() => {
let cancelled = false
const load = () => {
fetch('/api/stats', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`stats ${r.status}`)
return r.json() as Promise<Stats>
})
.then((s) => {
if (!cancelled) setStats(s)
})
.catch(() => {
/* the veil keeps its secrets — keep old numbers or '—' */
})
}
load()
const id = window.setInterval(load, STATS_REFRESH_MS)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
// Featured spirits: most-contacted three from the codex. Hidden on failure.
useEffect(() => {
let cancelled = false
fetch('/api/codex?sort=contacted&limit=3', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`codex ${r.status}`)
return r.json() as Promise<CodexListResponse>
})
.then((d) => {
if (!cancelled) setFeatured(d.entities.slice(0, 3))
})
.catch(() => {
/* registry unreachable — section simply stays shut */
})
return () => {
cancelled = true
}
}, [])
return (
<div className="landing">
<div className="landing-scanlines" aria-hidden="true" />
<section className="hero">
<SmokeCanvas />
<div className="hero-content">
<p className="hero-kicker">{t('landing.heroKicker')}</p>
<h1 className="glitch-title" data-text={title}>
{title}
</h1>
<p className="hero-tagline-slot">
{taglines.length > 0 && (
<span key={taglineIdx} className="hero-tagline">
{taglines[taglineIdx % taglines.length]}
</span>
)}
</p>
<div className="hero-ctas">
<button
type="button"
className="cta-primary"
onClick={() => navigate(user ? '/seance' : '/enter')}
>
{t('landing.cta')}
</button>
<Link className="cta-secondary" to="/codex">
{t('landing.ctaCodex')}
</Link>
</div>
</div>
</section>
<section className="landing-section veil-stats" aria-label={t('landing.tickerPrefix')}>
<h2 className="section-title">
<span className="live-dot" aria-hidden="true" />
{t('landing.tickerPrefix')}
</h2>
<div className="stats-grid">
{STAT_KEYS.map((key) => (
<div className="stat-cell" key={key}>
<AnimatedNumber value={stats ? stats[key] : null} />
<span className="stat-label">{t(`landing.stats.${key}`)}</span>
</div>
))}
</div>
</section>
<section className="landing-section modes">
<h2 className="section-title">{t('landing.modesTitle')}</h2>
<div className="modes-grid">
{MODE_ORDER.map((mode) => (
<article className="mode-card" key={mode}>
<div className="mode-glyph">
<ModeGlyph mode={mode} />
</div>
<h3 className="mode-name">{t(`landing.modes.${mode}.name`)}</h3>
<p className="mode-desc">{t(`landing.modes.${mode}.desc`)}</p>
</article>
))}
</div>
</section>
{featured !== null && (
<section className="landing-section featured">
<h2 className="section-title">{t('landing.featured')}</h2>
{featured.length === 0 ? (
<p className="featured-empty">{t('landing.featuredEmpty')}</p>
) : (
<div className="featured-grid">
{featured.map((e) => (
<Link to={`/codex/${e.id}`} className="spirit-card" key={e.id}>
<GhostGlyph hue={e.visual.hue} form={e.visual.form} size={56} />
<span className="spirit-name">{e.name}</span>
<span className="spirit-epithet">{e.epithet}</span>
<span className={`rarity-badge rarity-${e.rarity}`}>
{t(`codex.rarity.${e.rarity}`)}
</span>
{e.quotes.length > 0 && <q className="spirit-quote">{e.quotes[0]}</q>}
</Link>
))}
</div>
)}
</section>
)}
<footer className="landing-footer">
<p>{t('landing.footer')}</p>
<p className="footer-colophon">{t('landing.footerColophon')}</p>
</footer>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,819 @@
// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija), transmission log and direct-contact row.
//
// Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
// itself; this page only enqueues utterance text and polls snapshot()
// (non-mutating) for the ouija panel readout.
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
// in the panel's unmount cleanup — switching modes or leaving the page
// always releases the hardware.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { SeanceProvider, useSeance } from '../state/seance'
import type { Toast } from '../state/seance'
import { GhostCanvas } from '../three/GhostCanvas'
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
import { PlanchetteBoard } from '../components/PlanchetteBoard'
import { Transcript } from '../components/Transcript'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import {
isSupported,
RtlSdr,
SpectrumAnomalyDetector,
SWEEP_END_MHZ,
SWEEP_START_MHZ,
} from '../lib/sdr'
import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types'
import './SeancePage.css'
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
const TOAST_TTL_MS = 6000
/** SDR sample rate must match RtlSdr.open()'s default. */
const SDR_SAMPLE_RATE_HZ = 2_048_000
export function SeancePage() {
const { user, checking } = useAuth()
const { t } = useTranslation()
if (checking) {
return (
<div className="seance-loading" role="status">
<div className="loading-sigil" aria-hidden>
⟁
</div>
<p className="loading-text">{t('common.loading')}</p>
</div>
)
}
if (!user) return <Navigate to="/enter" replace />
return (
<SeanceProvider>
<SeanceSession username={user.username} />
</SeanceProvider>
)
}
export default SeancePage
// ---- the séance itself (rendered inside SeanceProvider) ----
function SeanceSession({ username }: { username: string }) {
const {
state,
audioPlayer,
setMode,
setPassive,
setLanguage,
summon,
ask,
playUtterance,
dismissToast,
} = useSeance()
const { logout } = useAuth()
const { t, i18n } = useTranslation()
const [departed, setDeparted] = useState(false)
const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true)
// The planchette machine lives for the whole session; the board ticks it.
const machineRef = useRef(new PlanchetteMachine())
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
// Tell the backend which tongue we whisper in (queued while connecting).
useEffect(() => {
setLanguage(storedLanguage())
}, [setLanguage])
// Feed every fresh utterance (greetings, fragments, replies) to the board.
const lastEnqueuedRef = useRef<string | null>(null)
useEffect(() => {
const uts = state.utterances
if (uts.length === 0) return
const lastSeen = lastEnqueuedRef.current
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
const fresh = uts.slice(idx + 1)
for (const u of fresh) machineRef.current.enqueue(u.text)
lastEnqueuedRef.current = uts[uts.length - 1].id
}, [state.utterances])
// Poll the machine's snapshot for the ouija readout (board owns ticking).
useEffect(() => {
let lastSig = ''
const id = window.setInterval(() => {
const s = machineRef.current.snapshot()
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
if (sig !== lastSig) {
lastSig = sig
setBoardSnap(s)
}
}, 150)
return () => window.clearInterval(id)
}, [])
// Open the dossier whenever a new presence steps through.
const entityId = state.entity?.id ?? null
useEffect(() => {
if (entityId) setDossierOpen(true)
}, [entityId])
const ghostVisual = useMemo<GhostVisualState | null>(
() =>
state.entity
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
: null,
[state.entity],
)
const ghostMood: GhostMood = state.speakingId
? 'speaking'
: state.status === 'gathering' || state.status === 'summoning'
? 'gathering'
: state.status === 'attuning'
? 'attuning'
: 'idle'
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
const switchLanguage = (lang: Language) => {
setLanguage(lang)
void i18n.changeLanguage(lang)
persistLanguage(lang)
}
const handleLogout = () => {
void logout()
.catch(() => undefined)
.finally(() => setDeparted(true))
}
const streaming = state.replyStreaming.active
const submitQuestion = (e: FormEvent) => {
e.preventDefault()
if (streaming) return
ask(question)
setQuestion('')
}
if (departed) return <Navigate to="/" replace />
const entityHue = state.entity?.visual.hue ?? 150
const entity = state.entity
return (
<div className="seance-page">
<GhostCanvas
className="seance-ghost"
visual={ghostVisual}
mood={ghostMood}
getAmplitude={getAmplitude}
/>
{/* ---- top bar ---- */}
<header className="seance-topbar">
<div className="seance-brand">
<span className="brand-sigil" aria-hidden>
⟁
</span>
{t('common.appName')}
</div>
<nav className="seance-modes" aria-label="modes">
{MODES.map((m) => (
<button
key={m}
type="button"
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`)}
</button>
))}
</nav>
<div className="seance-top-right">
<label className="passive-toggle" title={t('seance.passive')}>
<input
type="checkbox"
checked={state.passive}
onChange={(e) => setPassive(e.target.checked)}
/>
<span className="passive-track" aria-hidden />
<span className="passive-label">{t('seance.passive')}</span>
</label>
<div className="lang-toggle" role="group" aria-label="language">
<button
type="button"
className={state.language === 'en' ? 'active' : ''}
onClick={() => switchLanguage('en')}
>
EN
</button>
<button
type="button"
className={state.language === 'es' ? 'active' : ''}
onClick={() => switchLanguage('es')}
>
ES
</button>
</div>
<Link to="/codex" className="seance-codex-link">
{t('nav.codex')}
</Link>
<span className="seance-user" title={username}>
{username}
</span>
<button type="button" className="seance-logout" onClick={handleLogout}>
{t('nav.logout')}
</button>
<span
className={`conn-dot conn-${state.connection}`}
title={t(`seance.connection.${state.connection}`)}
aria-label={t(`seance.connection.${state.connection}`)}
/>
</div>
</header>
{/* ---- unstable-veil banner ---- */}
{state.connection !== 'open' && (
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
<span className="conn-glyph" aria-hidden>
⟁
</span>
{t(`seance.connection.${state.connection}`)}
</div>
)}
{/* ---- main: board + side column ---- */}
<main className="seance-main">
<section className="seance-board-wrap">
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
{state.status && (
<div className="seance-status-overlay" aria-live="polite">
<span className="status-sigil" aria-hidden />
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
</div>
)}
</section>
<aside className="seance-side">
{entity ? (
<section className={`spirit-dossier ${dossierOpen ? 'open' : 'closed'}`}>
<button
type="button"
className="dossier-header"
onClick={() => setDossierOpen((o) => !o)}
aria-expanded={dossierOpen}
>
<span className="dossier-title">{t('seance.entity.title')}</span>
<span className="dossier-chevron" aria-hidden>
{dossierOpen ? '▾' : '◂'}
</span>
</button>
{dossierOpen && (
<div className="dossier-body">
<h3 className="dossier-name" style={{ color: `hsl(${entity.visual.hue} 85% 78%)` }}>
{entity.name}
</h3>
<p className="dossier-epithet">{entity.epithet}</p>
<div className="dossier-badges">
<span className={`rarity-badge rarity-${entity.rarity}`}>
{t(`seance.entity.rarity.${entity.rarity}`)}
</span>
{state.entityIsNew ? (
<span className="dossier-badge badge-new">{t('seance.entity.isNew')}</span>
) : (
<span className="dossier-badge badge-known">
{t('seance.entity.known', { count: entity.contact_count })}
</span>
)}
</div>
<div className="dossier-section">
<h4>{t('seance.entity.persona')}</h4>
<p className="dossier-persona">{entity.persona}</p>
</div>
<div className="dossier-section">
<h4>{t('seance.entity.voice')}</h4>
<dl className="voice-params">
<div>
<dt>id</dt>
<dd>{entity.voice.voice_id}</dd>
</div>
<div>
<dt>pitch</dt>
<dd>{entity.voice.pitch.toFixed(2)}</dd>
</div>
<div>
<dt>rate</dt>
<dd>{entity.voice.rate.toFixed(2)}</dd>
</div>
<div>
<dt>noise</dt>
<dd>{entity.voice.noise.toFixed(2)}</dd>
</div>
<div>
<dt>echo</dt>
<dd>{entity.voice.echo.toFixed(2)}</dd>
</div>
</dl>
</div>
{entity.quotes.length > 0 && (
<div className="dossier-section">
<h4>{t('seance.entity.quotes')}</h4>
<ul className="dossier-quotes">
{entity.quotes.slice(0, 4).map((q, i) => (
<li key={i}>“{q}”</li>
))}
</ul>
</div>
)}
</div>
)}
</section>
) : (
<section className="spirit-dossier empty">
<div className="dossier-body">
<p className="dossier-none">{t('seance.entity.none')}</p>
<p className="dossier-nonehint dim">{t('seance.entity.noneHint')}</p>
</div>
</section>
)}
<section className="mode-panel">
{state.mode === 'wire' && <WirePanel />}
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
</section>
</aside>
</main>
{/* ---- bottom: transmission log + direct contact ---- */}
<footer className="seance-bottom">
<div className="seance-transcript-wrap">
<div className="seance-transcript-title">
<span>{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
{t('seance.anomaly.count', { count: state.anomalyCount })}
</span>
</div>
<Transcript
entries={state.transcript}
streaming={state.replyStreaming}
speakingId={state.speakingId}
onReplay={playUtterance}
/>
</div>
<form className="seance-contact" onSubmit={submitQuestion}>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={t('seance.askPlaceholder')}
disabled={streaming}
maxLength={280}
aria-label={t('seance.ask')}
/>
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
{t('seance.ask')}
</button>
<button
type="button"
className="summon-btn"
onClick={summon}
disabled={state.status === 'summoning'}
>
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
</button>
</form>
</footer>
{/* ---- toasts ---- */}
<div className="seance-toasts" aria-live="polite">
{state.toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</div>
{/* ---- CRT overlays (above everything, never interactive) ---- */}
<div className="seance-scanlines" aria-hidden />
<div className="seance-vignette" aria-hidden />
</div>
)
}
// ---- toasts ----
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
const { t } = useTranslation()
useEffect(() => {
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
return () => window.clearTimeout(id)
}, [toast.id, onDismiss])
return (
<div className="seance-toast" role="alert">
<span className="toast-glyph" aria-hidden>
⚠
</span>
<span className="toast-msg">
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
</span>
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label={t('common.dismiss')}
>
×
</button>
</div>
)
}
// ---- wire panel: raw line telemetry ----
function WirePanel() {
const { state } = useSeance()
const { t } = useTranslation()
const tel = state.telemetry
return (
<div className="wire-panel">
<h4 className="panel-title">{t('seance.telemetry.title')}</h4>
{tel ? (
<div className="telemetry-grid">
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.jitter')}</span>
<span className="tel-val flicker-soft">
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.latencyVar')}</span>
<span className="tel-val flicker-soft">
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.latencyMean')}</span>
<span className="tel-val flicker-soft">
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.dns')}</span>
<span className="tel-val flicker-soft">
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
</span>
</div>
</div>
) : (
<p className="dim flicker">{t('common.loading')}</p>
)}
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
{!state.passive && (
<p className="wire-hint dim">
{t('seance.wire.hint', {
defaultValue:
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
})}
</p>
)}
</div>
)
}
// ---- EVP panel: microphone voice-band listening ----
function EvpPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [bandDb, setBandDb] = useState<number | null>(null)
const listenerRef = useRef<EvpListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastMeterRef = useRef(0)
// Release the microphone when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) void l.stop()
},
[],
)
const drawScope = (db: Float32Array) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const n = db.length
const step = Math.max(1, Math.floor(n / 160))
const bw = w / (n / step)
for (let i = 0, x = 0; i < n; i += step, x++) {
const v = db[i] // dB, roughly -160..0
const norm = Math.max(0, Math.min(1, (v + 110) / 80))
const bh = Math.max(1, norm * h)
ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})`
ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh)
}
}
const start = async () => {
if (pending || listening) return
setDenied(false)
setPending(true)
const listener = listenerRef.current ?? new EvpListener()
listenerRef.current = listener
try {
await listener.start({
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
onFrame: (db) => {
drawScope(db)
const now = performance.now()
if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
const n = db.length
const lo = Math.floor((300 / 24000) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
let peak = -Infinity
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
setBandDb(Number.isFinite(peak) ? peak : null)
}
},
})
if (listenerRef.current === listener) setListening(true)
} catch {
if (listenerRef.current === listener) listenerRef.current = null
setDenied(true)
setBandDb(null)
} finally {
setPending(false)
}
}
const stop = async () => {
const l = listenerRef.current
listenerRef.current = null
if (l) await l.stop()
setListening(false)
setBandDb(null)
}
return (
<div className="evp-panel">
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
<div className="evp-readout">
<span className="dim">{t('seance.evp.band')}</span>
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
</div>
{listening && (
<p className="evp-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.evp.listening')}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.evp.deniedTitle')}</h4>
<p>{t('seance.evp.denied')}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (listening ? stop() : start())}
disabled={pending}
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
</div>
)
}
// ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState<string | null>(null)
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTuneUiRef = useRef(0)
// Power down the dongle on unmount (mode switch / leaving the page).
useEffect(
() => () => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
},
[],
)
const drawWaterfallRow = (db: Float64Array) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Scroll the existing waterfall down one row, paint the new one on top.
ctx.drawImage(canvas, 0, 1)
const n = db.length
const bw = w / n
for (let i = 0; i < n; i++) {
const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70))
ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)`
ctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
}
}
const stop = useCallback(() => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
setPhase('idle')
setTunedMhz(null)
}, [])
const start = async () => {
if (!supported || sdrRef.current || phase === 'busy') return
setError(null)
setPhase('busy')
const sdr = new RtlSdr()
try {
await sdr.requestDevice()
await sdr.open()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const name = err instanceof DOMException ? err.name : ''
setError(
name === 'NotFoundError' || /no device selected/i.test(msg)
? t('seance.radio.noDevice')
: /claim/i.test(msg)
? t('seance.radio.claimFailed')
: msg,
)
setPhase('idle')
void sdr.close()
return
}
sdrRef.current = sdr
detectorRef.current = new SpectrumAnomalyDetector()
setPhase('sweeping')
void sdr
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
onSpectrum: (centerHz, db) => {
if (sdrRef.current !== sdr) return
drawWaterfallRow(db)
const now = performance.now()
if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now
setTunedMhz(centerHz / 1e6)
}
const det = detectorRef.current
if (det) {
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
if (a) sendAnomaly('radio', a.frequency, a.magnitude)
}
},
onError: (err) => {
if (sdrRef.current === sdr) setError(err.message)
},
})
.then(() => {
if (sdrRef.current === sdr) setPhase('idle')
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError(err instanceof Error ? err.message : String(err))
setPhase('idle')
}
})
}
if (!supported) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>{t('seance.radio.unsupported')}</p>
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
return (
<div className="radio-panel">
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
<div className="radio-readout">
{tunedMhz !== null ? (
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
) : (
<span className="dim">—</span>
)}
{phase === 'sweeping' && (
<span className="dim flicker">
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
</span>
)}
</div>
{error && (
<div className="panel-notice error">
<p>{error}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (phase === 'sweeping' ? stop() : start())}
disabled={phase === 'busy'}
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
</div>
)
}
// ---- ouija panel: what the planchette is doing ----
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
const { t } = useTranslation()
const word = snapshot?.word ?? null
return (
<div className="ouija-panel">
{word ? (
<div className="ouija-spelling">
<span className="dim ouija-spelling-label">
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
</span>
<div className="ouija-word" aria-live="polite">
{word.split('').map((ch, i) => (
<span
key={i}
className={
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
}
>
{ch}
</span>
))}
</div>
</div>
) : (
<p className="dim ouija-drift">
{t('seance.ouija.drift', {
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
})}
</p>
)}
{snapshot && snapshot.spelled.length > 0 && (
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
)}
</div>
)
}

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
}

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react'
import { GhostScene } from './GhostScene'
import type { GhostMood, GhostVisualState } from './GhostScene'
export type GhostCanvasProps = {
visual: GhostVisualState | null
mood: GhostMood
/** Called each frame to get the speaking amplitude (0..1). */
getAmplitude?: () => number
className?: string
}
/** React wrapper: owns the canvas element and the GhostScene lifecycle. */
export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sceneRef = useRef<GhostScene | null>(null)
const ampRef = useRef(getAmplitude)
ampRef.current = getAmplitude
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const scene = new GhostScene(canvas)
sceneRef.current = scene
scene.start()
const ampTimer = window.setInterval(() => {
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
}, 50)
return () => {
window.clearInterval(ampTimer)
scene.dispose()
sceneRef.current = null
}
}, [])
useEffect(() => {
sceneRef.current?.setVisual(visual)
}, [visual])
useEffect(() => {
sceneRef.current?.setMood(mood)
}, [mood])
return (
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
)
}

View File

@@ -0,0 +1,477 @@
// The 3D ghost scene: a smoky, wavy, translucent spirit rendered with
// custom shaders — fbm value-noise vertex displacement on a lathed figure,
// additive particle wisps, ground mist, fog, slow camera drift.
// Reacts to seance state via uniforms (energy / speaking amplitude / hue / form).
import * as THREE from 'three'
import type { GhostForm } from '../lib/types'
export type GhostMood = 'idle' | 'attuning' | 'gathering' | 'speaking'
export type GhostVisualState = {
hue: number // 0..360
form: GhostForm
}
const NOISE_GLSL = /* glsl */ `
float hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float vnoise(vec3 x) {
vec3 i = floor(x);
vec3 f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
f.z);
}
float fbm(vec3 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p *= 2.03;
a *= 0.5;
}
return v;
}
`
const GHOST_VERT = /* glsl */ `
uniform float uTime;
uniform float uEnergy; // 0..1 swirl intensity
uniform float uSpeak; // 0..1 voice amplitude pulse
uniform float uFormStretch; // form-driven vertical stretch
varying vec2 vUv;
varying float vNoise;
varying vec3 vPos;
${NOISE_GLSL}
void main() {
vUv = uv;
vec3 p = position;
p.y *= uFormStretch;
float swirl = uTime * (0.35 + uEnergy * 1.6);
float n = fbm(vec3(p.x * 1.4 + swirl, p.y * 0.9 - uTime * (0.5 + uEnergy), p.z * 1.4));
float n2 = fbm(vec3(p.z * 2.2 - swirl * 0.7, p.y * 1.3 + uTime * 0.4, p.x * 2.2));
// Radial displacement, stronger toward the base (smoke trail) and with energy.
float base = smoothstep(1.0, -1.0, p.y);
float amp = (0.12 + uEnergy * 0.35 + uSpeak * 0.25) * (0.4 + base);
p.x += (n - 0.5) * 2.0 * amp;
p.z += (n2 - 0.5) * 2.0 * amp;
// Breathing pulse while speaking.
float pulse = 1.0 + uSpeak * 0.18 * sin(uTime * 9.0 + p.y * 3.0);
p.xz *= pulse;
vNoise = n;
vPos = p;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`
const GHOST_FRAG = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uEnergy;
uniform float uSpeak;
uniform float uOpacity;
uniform float uDark; // shade form absorbs light
varying vec2 vUv;
varying float vNoise;
varying vec3 vPos;
void main() {
// Fade top and bottom, fade with noise, brighter core.
float vert = smoothstep(0.0, 0.25, vUv.y) * smoothstep(1.0, 0.55, vUv.y);
float body = smoothstep(0.25, 0.75, vNoise + uSpeak * 0.2);
float a = vert * body * (0.35 + uEnergy * 0.5 + uSpeak * 0.35) * uOpacity;
vec3 col = uColor * (0.55 + uEnergy * 0.8 + uSpeak * 0.9);
col = mix(col, col * 0.12, uDark);
// Flicker.
col *= 0.92 + 0.08 * sin(uTime * 13.7 + vPos.y * 5.0);
gl_FragColor = vec4(col, a);
}
`
const WISP_VERT = /* glsl */ `
uniform float uTime;
uniform float uEnergy;
uniform float uSpeak;
uniform float uMoteSpeed;
attribute float aSeed;
varying float vSeed;
void main() {
vSeed = aSeed;
vec3 p = position;
float t = uTime * (0.12 + 0.5 * uEnergy) * uMoteSpeed + aSeed * 17.0;
float r = length(p.xz) * (1.0 + 0.2 * sin(t * 0.9 + aSeed * 6.28));
float ang = atan(p.z, p.x) + t * (0.3 + aSeed * 0.2);
p.x = cos(ang) * r;
p.z = sin(ang) * r;
p.y += sin(t + aSeed * 12.0) * (0.35 + uSpeak * 0.4);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = (2.0 + aSeed * 5.0 + uSpeak * 3.0) * (18.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`
const WISP_FRAG = /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vSeed;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
float a = smoothstep(0.5, 0.0, d);
gl_FragColor = vec4(uColor * (0.7 + vSeed * 0.6), a * uOpacity);
}
`
const MIST_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const MIST_FRAG = /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
${NOISE_GLSL}
void main() {
vec2 c = vUv - 0.5;
float r = length(c) * 2.0;
float n = fbm(vec3(vUv * 4.0, uTime * 0.08));
float a = smoothstep(1.0, 0.1, r) * n * uOpacity;
gl_FragColor = vec4(uColor * 0.6, a);
}
`
function hsl(hue: number, s: number, l: number): THREE.Color {
return new THREE.Color().setHSL(((hue % 360) + 360) % 360 / 360, s, l)
}
/** Build the lathed "hooded wisp" figure profile. */
function buildFigureGeometry(): THREE.LatheGeometry {
const pts: THREE.Vector2[] = []
// Profile: wide smoky base → waist → shoulders → head → dissolving tip.
const profile: Array<[number, number]> = [
[0.0, -1.6],
[0.9, -1.55],
[0.75, -1.1],
[0.5, -0.55],
[0.42, -0.1],
[0.5, 0.25],
[0.46, 0.55],
[0.3, 0.8],
[0.34, 0.95], // hood
[0.22, 1.15],
[0.08, 1.35],
[0.0, 1.55],
]
for (const [x, y] of profile) pts.push(new THREE.Vector2(x, y))
const geo = new THREE.LatheGeometry(pts, 48)
geo.computeVertexNormals()
return geo
}
type FormParams = {
scale: number
stretch: number
moteCount: number
moteSpeed: number
dark: number
opacity: number
}
function formParams(form: GhostForm): FormParams {
switch (form) {
case 'fairy':
return { scale: 0.55, stretch: 0.9, moteCount: 260, moteSpeed: 2.2, dark: 0, opacity: 1 }
case 'banshee':
return { scale: 1.05, stretch: 1.55, moteCount: 140, moteSpeed: 0.8, dark: 0.15, opacity: 0.95 }
case 'shade':
return { scale: 1.1, stretch: 1.15, moteCount: 90, moteSpeed: 0.5, dark: 0.85, opacity: 0.9 }
case 'wisp':
default:
return { scale: 0.85, stretch: 1.0, moteCount: 160, moteSpeed: 1.2, dark: 0, opacity: 1 }
}
}
export class GhostScene {
private renderer: THREE.WebGLRenderer
private scene = new THREE.Scene()
private camera: THREE.PerspectiveCamera
private canvas: HTMLCanvasElement
private ghostMat: THREE.ShaderMaterial
private ghost: THREE.Mesh
private veil: THREE.Mesh // banshee trailing veil
private wispMat: THREE.ShaderMaterial
private wisps: THREE.Points
private mistMat: THREE.ShaderMaterial
private mists: THREE.Mesh[] = []
private farWisps: THREE.Points
private farWispMat: THREE.PointsMaterial
private raf = 0
private running = false
private clock = new THREE.Clock()
private resizeObserver: ResizeObserver | null = null
private mood: GhostMood = 'idle'
private visual: GhostVisualState | null = null
private speakAmp = 0
private smoothedAmp = 0
private energy = 0.15
private disposed = false
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: 'high-performance',
})
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 60)
this.camera.position.set(0, 0.6, 6.2)
this.scene.fog = new THREE.FogExp2(0x07070d, 0.09)
// --- Ghost figure ---
this.ghostMat = new THREE.ShaderMaterial({
vertexShader: GHOST_VERT,
fragmentShader: GHOST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
uEnergy: { value: 0.15 },
uSpeak: { value: 0 },
uFormStretch: { value: 1 },
uColor: { value: hsl(150, 0.9, 0.65) },
uOpacity: { value: 1 },
uDark: { value: 0 },
},
})
this.ghost = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
this.ghost.position.set(0, 0.4, 0)
this.ghost.visible = false
this.scene.add(this.ghost)
// Trailing veil (banshee): a second, elongated, fainter copy behind.
this.veil = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
this.veil.position.set(0.35, 0.1, -0.6)
this.veil.scale.set(0.8, 1.3, 0.8)
this.veil.visible = false
this.scene.add(this.veil)
// --- Orbiting wisps / motes ---
this.wispMat = new THREE.ShaderMaterial({
vertexShader: WISP_VERT,
fragmentShader: WISP_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uEnergy: { value: 0.15 },
uSpeak: { value: 0 },
uMoteSpeed: { value: 1.2 },
uColor: { value: hsl(150, 0.9, 0.7) },
uOpacity: { value: 0.55 },
},
})
this.wisps = new THREE.Points(this.buildWispGeometry(260), this.wispMat)
this.wisps.position.set(0, 0.4, 0)
this.wisps.visible = false
this.scene.add(this.wisps)
// --- Ground mist planes ---
this.mistMat = new THREE.ShaderMaterial({
vertexShader: MIST_VERT,
fragmentShader: MIST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uColor: { value: hsl(160, 0.5, 0.4) },
uOpacity: { value: 0.16 },
},
})
for (let i = 0; i < 3; i++) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), this.mistMat)
m.rotation.x = -Math.PI / 2
m.position.set(0, -1.5 + i * 0.22, 0)
m.rotation.z = (i * Math.PI) / 3
this.scene.add(m)
this.mists.push(m)
}
// --- Far-off ambient wisps (no entity summoned) ---
const farGeo = new THREE.BufferGeometry()
const farCount = 60
const farPos = new Float32Array(farCount * 3)
for (let i = 0; i < farCount; i++) {
farPos[i * 3] = (Math.random() - 0.5) * 30
farPos[i * 3 + 1] = Math.random() * 6 - 1
farPos[i * 3 + 2] = -6 - Math.random() * 18
}
farGeo.setAttribute('position', new THREE.BufferAttribute(farPos, 3))
this.farWispMat = new THREE.PointsMaterial({
color: 0x7cffb2,
size: 0.08,
transparent: true,
opacity: 0.35,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
this.farWisps = new THREE.Points(farGeo, this.farWispMat)
this.scene.add(this.farWisps)
this.handleResize()
this.resizeObserver = new ResizeObserver(() => this.handleResize())
if (canvas.parentElement) this.resizeObserver.observe(canvas.parentElement)
}
private buildWispGeometry(count: number): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
const pos = new Float32Array(count * 3)
const seed = new Float32Array(count)
for (let i = 0; i < count; i++) {
const r = 0.5 + Math.random() * 1.6
const a = Math.random() * Math.PI * 2
pos[i * 3] = Math.cos(a) * r
pos[i * 3 + 1] = Math.random() * 3 - 1.4
pos[i * 3 + 2] = Math.sin(a) * r
seed[i] = Math.random()
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
return geo
}
setMood(mood: GhostMood): void {
this.mood = mood
}
/** Call every frame with the audio player's instantaneous amplitude. */
setSpeakAmplitude(amp: number): void {
this.speakAmp = Math.max(0, Math.min(1, amp))
}
setVisual(visual: GhostVisualState | null): void {
this.visual = visual
if (!visual) {
this.ghost.visible = false
this.veil.visible = false
this.wisps.visible = false
return
}
const params = formParams(visual.form)
const color = hsl(visual.hue, 0.85, 0.62)
const ghostU = this.ghostMat.uniforms
ghostU.uColor.value = color
ghostU.uFormStretch.value = params.stretch
ghostU.uDark.value = params.dark
ghostU.uOpacity.value = params.opacity
this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7)
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4)
this.ghost.scale.setScalar(params.scale)
this.ghost.visible = true
this.wisps.visible = true
this.veil.visible = visual.form === 'banshee'
// Rebuild motes if the form wants a different count.
const current = this.wisps.geometry.getAttribute('position')?.count ?? 0
if (current !== params.moteCount) {
this.wisps.geometry.dispose()
this.wisps.geometry = this.buildWispGeometry(params.moteCount)
}
}
start(): void {
if (this.running || this.disposed) return
this.running = true
this.clock.start()
const loop = () => {
if (!this.running) return
this.raf = requestAnimationFrame(loop)
this.renderFrame()
}
this.raf = requestAnimationFrame(loop)
}
private renderFrame(): void {
const t = this.clock.getElapsedTime()
// Energy target from mood; smooth toward it.
const target =
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15
this.energy += (target - this.energy) * 0.04
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
const ghostU = this.ghostMat.uniforms
ghostU.uTime.value = t
ghostU.uEnergy.value = this.energy
ghostU.uSpeak.value = this.smoothedAmp
this.wispMat.uniforms.uTime.value = t
this.wispMat.uniforms.uEnergy.value = this.energy
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
this.mistMat.uniforms.uTime.value = t
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
// Idle drift / slow camera wander.
this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
this.veil.position.y = this.ghost.position.y - 0.3
this.camera.position.x = Math.sin(t * 0.11) * 0.55
this.camera.position.y = 0.6 + Math.sin(t * 0.07) * 0.25
this.camera.lookAt(0, 0.4, 0)
// Far wisps drift slowly.
this.farWisps.rotation.y = t * 0.01
this.farWispMat.opacity = this.visual ? 0.12 : 0.3 + 0.1 * Math.sin(t * 0.5)
this.renderer.render(this.scene, this.camera)
}
private handleResize(): void {
const parent = this.canvas.parentElement
const w = parent?.clientWidth || window.innerWidth
const h = parent?.clientHeight || window.innerHeight
if (w === 0 || h === 0) return
this.renderer.setSize(w, h, false)
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
}
dispose(): void {
this.disposed = true
this.running = false
cancelAnimationFrame(this.raf)
this.resizeObserver?.disconnect()
this.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points) {
obj.geometry.dispose()
const mat = obj.material
if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
else mat.dispose()
}
})
this.renderer.dispose()
}
}

77
frontend/src/types/webusb.d.ts vendored Normal file
View File

@@ -0,0 +1,77 @@
// Minimal WebUSB ambient declarations — just the surface src/lib/sdr.ts uses.
// WebUSB is not part of TS's dom lib and @types/web doesn't carry it.
interface USBEndpoint {
endpointNumber: number
direction: 'in' | 'out'
type: 'bulk' | 'interrupt' | 'isochronous'
packetSize: number
}
interface USBAlternateInterface {
alternateSetting: number
interfaceClass: number
interfaceSubclass: number
interfaceProtocol: number
interfaceName?: string
endpoints: USBEndpoint[]
}
interface USBInterface {
interfaceNumber: number
alternates: USBAlternateInterface[]
claimed: boolean
}
interface USBConfiguration {
configurationValue: number
configurationName?: string
interfaces: USBInterface[]
}
interface USBDevice {
readonly opened: boolean
readonly configuration: USBConfiguration | null
open(): Promise<void>
close(): Promise<void>
claimInterface(interfaceNumber: number): Promise<void>
releaseInterface(interfaceNumber: number): Promise<void>
selectAlternateInterface(
interfaceNumber: number,
alternateSetting: number,
): Promise<void>
controlTransferOut(
setup: {
requestType: 'standard' | 'class' | 'vendor'
recipient: 'device' | 'interface' | 'endpoint' | 'other'
request: number
value: number
index: number
},
data?: BufferSource,
): Promise<USBOutTransferResult>
transferIn(
endpointNumber: number,
length: number,
): Promise<USBInTransferResult>
}
interface USBInTransferResult {
data?: DataView
status: 'ok' | 'stall' | 'babble'
}
interface USBOutTransferResult {
bytesWritten: number
status: 'ok' | 'stall'
}
interface USB {
requestDevice(options: {
filters: Array<{ vendorId?: number; productId?: number }>
}): Promise<USBDevice>
}
interface Navigator {
readonly usb: USB
}

View File

@@ -11,6 +11,12 @@ export default defineConfig({
proxy: { proxy: {
'/auth': 'http://localhost:7777', '/auth': 'http://localhost:7777',
'/healthz': 'http://localhost:7777', '/healthz': 'http://localhost:7777',
'/api': 'http://localhost:7777',
'/audio': 'http://localhost:7777',
'/ws': {
target: 'http://localhost:7777',
ws: true,
},
}, },
}, },
test: { test: {