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:
312
README.md
Normal file
312
README.md
Normal 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/`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
131
docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md
Normal file
131
docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md
Normal 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.
|
||||||
@@ -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).
|
||||||
122
docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md
Normal file
122
docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md
Normal 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.
|
||||||
225
frontend/package-lock.json
generated
225
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
frontend/src/components/EntityCard.tsx
Normal file
79
frontend/src/components/EntityCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
frontend/src/components/GhostGlyph.tsx
Normal file
63
frontend/src/components/GhostGlyph.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
315
frontend/src/components/PlanchetteBoard.tsx
Normal file
315
frontend/src/components/PlanchetteBoard.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/components/TelemetryReadout.tsx
Normal file
28
frontend/src/components/TelemetryReadout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/components/Transcript.tsx
Normal file
87
frontend/src/components/Transcript.tsx
Normal 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
229
frontend/src/i18n/en.json
Normal 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
229
frontend/src/i18n/es.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
frontend/src/i18n/index.ts
Normal file
38
frontend/src/i18n/index.ts
Normal 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
131
frontend/src/lib/audio.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
frontend/src/lib/evp.test.ts
Normal file
158
frontend/src/lib/evp.test.ts
Normal 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
202
frontend/src/lib/evp.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
136
frontend/src/lib/fft.test.ts
Normal file
136
frontend/src/lib/fft.test.ts
Normal 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
95
frontend/src/lib/fft.ts
Normal 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
|
||||||
|
}
|
||||||
179
frontend/src/lib/planchette.test.ts
Normal file
179
frontend/src/lib/planchette.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
158
frontend/src/lib/planchette.ts
Normal file
158
frontend/src/lib/planchette.ts
Normal 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
339
frontend/src/lib/sdr.ts
Normal 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
102
frontend/src/lib/types.ts
Normal 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
295
frontend/src/lib/ws.test.ts
Normal 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
171
frontend/src/lib/ws.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
482
frontend/src/pages/CodexEntityPage.css
Normal file
482
frontend/src/pages/CodexEntityPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
209
frontend/src/pages/CodexEntityPage.tsx
Normal file
209
frontend/src/pages/CodexEntityPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
412
frontend/src/pages/CodexPage.css
Normal file
412
frontend/src/pages/CodexPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
frontend/src/pages/CodexPage.tsx
Normal file
190
frontend/src/pages/CodexPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
frontend/src/pages/EnterPage.css
Normal file
275
frontend/src/pages/EnterPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
frontend/src/pages/EnterPage.tsx
Normal file
129
frontend/src/pages/EnterPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
466
frontend/src/pages/LandingPage.css
Normal file
466
frontend/src/pages/LandingPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
347
frontend/src/pages/LandingPage.tsx
Normal file
347
frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1263
frontend/src/pages/SeancePage.css
Normal file
1263
frontend/src/pages/SeancePage.css
Normal file
File diff suppressed because it is too large
Load Diff
819
frontend/src/pages/SeancePage.tsx
Normal file
819
frontend/src/pages/SeancePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
frontend/src/state/auth.tsx
Normal file
61
frontend/src/state/auth.tsx
Normal 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
|
||||||
|
}
|
||||||
257
frontend/src/state/seance.test.ts
Normal file
257
frontend/src/state/seance.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
466
frontend/src/state/seance.tsx
Normal file
466
frontend/src/state/seance.tsx
Normal 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
|
||||||
|
}
|
||||||
49
frontend/src/three/GhostCanvas.tsx
Normal file
49
frontend/src/three/GhostCanvas.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
477
frontend/src/three/GhostScene.ts
Normal file
477
frontend/src/three/GhostScene.ts
Normal 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
77
frontend/src/types/webusb.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user