diff --git a/README.md b/README.md new file mode 100644 index 0000000..c17bce2 --- /dev/null +++ b/README.md @@ -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://: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://: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/.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/` diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-03-wire-ghost-ouija.md b/docs/superpowers/plans/2026-07-20-quantumancy-03-wire-ghost-ouija.md new file mode 100644 index 0000000..6090473 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-03-wire-ghost-ouija.md @@ -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 (`.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):///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. diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-04-evp-listening.md b/docs/superpowers/plans/2026-07-20-quantumancy-04-evp-listening.md new file mode 100644 index 0000000..2ad3f87 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-04-evp-listening.md @@ -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, 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`, `stop() -> Promise`, `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. diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md b/docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md new file mode 100644 index 0000000..268c609 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-06-codex-entities.md b/docs/superpowers/plans/2026-07-20-quantumancy-06-codex-entities.md new file mode 100644 index 0000000..931bf23 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-06-codex-entities.md @@ -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:"` 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:")` 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:"`. + +- [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). diff --git a/docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md b/docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md new file mode 100644 index 0000000..3c03673 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md @@ -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('.
.')` — 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 `.
.` 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b01cbe5..3f3287e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,15 @@ "name": "quantumancy-frontend", "version": "0.1.0", "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-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": { "@testing-library/jest-dom": "^6.6.3", @@ -290,7 +297,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -459,6 +465,12 @@ "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -850,6 +862,24 @@ "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": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1386,6 +1416,12 @@ "@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": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1474,6 +1510,32 @@ "@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": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1809,6 +1871,19 @@ "dev": true, "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": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2081,6 +2156,12 @@ "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": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -2240,6 +2321,15 @@ "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": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2268,6 +2358,34 @@ "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": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2431,6 +2549,12 @@ "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": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2624,6 +2748,33 @@ "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": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -2642,6 +2793,44 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -2747,6 +2936,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2798,6 +2993,12 @@ "dev": true, "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2892,7 +3093,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2933,6 +3134,15 @@ "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": { "version": "5.4.21", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 948ec7c..8f6e535 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,15 @@ "test": "vitest run" }, "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-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": { "@testing-library/jest-dom": "^6.6.3", diff --git a/frontend/src/App.css b/frontend/src/App.css index d3270c4..a553b06 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { 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 { - margin: 0; - min-height: 100vh; - background: #0a0a0f; - color: #e6e6f0; - font-family: Georgia, serif; - display: flex; - align-items: center; - justify-content: center; + color: var(--qm-text); + font-family: var(--qm-font-ui); + font-size: 15px; + line-height: 1.55; + overflow-x: hidden; } -.app { - width: min(90vw, 420px); - padding: 2rem; - text-align: center; +h1, +h2, +h3, +.display { + font-family: var(--qm-font-display); + letter-spacing: 0.08em; } -h1 { - font-size: 2.5rem; - letter-spacing: 0.1em; - margin-bottom: 0.25rem; +a { + color: var(--qm-green); + text-decoration: none; } -.tagline { - opacity: 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; +a:hover { + text-shadow: 0 0 8px rgba(124, 255, 178, 0.6); } button { - background: #2a1f3d; - border: 1px solid #5a3f8f; - color: inherit; - padding: 0.6rem 1rem; - border-radius: 4px; + font-family: var(--qm-font-ui); cursor: pointer; - font-size: 1rem; } -button:hover { - background: #3a2a55; +input, +textarea { + font-family: var(--qm-font-ui); } -.mode-toggle { - display: flex; - gap: 0.5rem; +::selection { + background: rgba(178, 107, 255, 0.35); + color: #fff; } -.mode-toggle button.active { - background: #5a3f8f; +/* Thin phosphor scrollbar */ +::-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 { - color: #ff6b6b; - font-size: 0.9rem; +/* Global CRT glass: scanlines + vignette above every page, never clickable. */ +.crt-overlay { + 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; + } } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fb5c884..1e3fea7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,31 +1,64 @@ import { render, screen, waitFor } from '@testing-library/react' 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 * as api from './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', () => { - it('renders the Quantumancy title', async () => { + beforeEach(() => { + mockFetch() vi.mocked(api.me).mockRejectedValue(new Error('not authenticated')) - render() - await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument()) }) - it('logs in and shows the contact-established state', async () => { - vi.mocked(api.me).mockRejectedValue(new Error('not authenticated')) - vi.mocked(api.login).mockResolvedValue({ id: '1', username: 'medium1' }) + it('renders the landing page with the Quantumancy title', async () => { render() - - 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(() => - 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() + // 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() + 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')) + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ee185d..2336cee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,95 +1,32 @@ -import { useEffect, useState } from 'react' -import { login, logout, me, register, type User } from './api' +// App shell: router + providers + the global CRT overlay (scanlines/vignette) +// 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() { - const [user, setUser] = useState(null) - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [mode, setMode] = useState<'login' | 'register'>('login') - const [error, setError] = useState(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 ( -
-

Listening for a signal...

-
- ) - } - return ( -
-

Quantumancy

-

Something is listening on the other side.

- - {user ? ( -
-

Contact established as {user.username}.

- -
- ) : ( -
-
- - -
- - - {error &&

{error}

} - -
- )} -
+ + +