diff --git a/README.md b/README.md index c17bce2..4949a13 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ Ollama box on the LAN with the two model tiers pulled. ```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;" +sudo -u postgres psql -c "CREATE DATABASE quantumancy OWNER quantumancy ENCODING 'UTF8' TEMPLATE template0;" +sudo -u postgres psql -c "CREATE DATABASE quantumancy_test OWNER quantumancy ENCODING 'UTF8' TEMPLATE template0;" ``` > `quantumancy_test` is dropped and recreated by every test run. Never point diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 6808ad3..2a678ff 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -34,7 +34,12 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)) @router.post("/login", response_model=UserOut) -async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): +async def login( + payload: LoginRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +): user = await db.scalar(select(User).where(User.username == payload.username)) if user is None: verify_password(payload.password, _DUMMY_PASSWORD_HASH) @@ -51,12 +56,15 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De db.add(session) await db.commit() + # The app is reached two ways: https via the Cloudflare Tunnel (Secure + # required) and plain http on the LAN (a Secure cookie would be dropped + # by the browser entirely, silently breaking the séance socket). response.set_cookie( SESSION_COOKIE_NAME, raw_token, httponly=True, samesite="lax", - secure=True, + secure=request.url.scheme == "https", max_age=int(SESSION_TTL.total_seconds()), ) return user @@ -64,6 +72,7 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( + request: Request, response: Response, qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), db: AsyncSession = Depends(get_db), @@ -74,7 +83,12 @@ async def logout( if session is not None: await db.delete(session) await db.commit() - response.delete_cookie(SESSION_COOKIE_NAME, httponly=True, samesite="lax", secure=True) + response.delete_cookie( + SESSION_COOKIE_NAME, + httponly=True, + samesite="lax", + secure=request.url.scheme == "https", + ) @router.get("/me", response_model=UserOut) diff --git a/backend/app/telemetry.py b/backend/app/telemetry.py index 82e0b5a..9e7d0c6 100644 --- a/backend/app/telemetry.py +++ b/backend/app/telemetry.py @@ -111,3 +111,24 @@ async def sample_network(period_s: float = 1.0) -> TelemetrySample: if dns_times: sample.dns_ms = sum(dns_times) / len(dns_times) return sample + + +def detect_wire_spike( + history: list[float], current: float, *, min_samples: int = 6 +) -> float | None: + """Return the spike ratio when `current` is anomalous vs the rolling + baseline of jitter samples — the Wire Ghost noticing something move. + + Three guards so idle-hour noise doesn't cry ghost: enough history to + have a baseline, an absolute floor (20 KB/s) so near-silent links stay + silent, and a statistical (3σ) + relative (2.5× mean) threshold. + """ + if len(history) < min_samples: + return None + mean = sum(history) / len(history) + if mean <= 0 or current <= 20_000: + return None + std = (sum((x - mean) ** 2 for x in history) / len(history)) ** 0.5 + if current > mean + 3 * std and current > mean * 2.5: + return current / mean + return None diff --git a/backend/app/ws.py b/backend/app/ws.py index 86e7c10..5935c4f 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -17,6 +17,7 @@ the wire. import asyncio import contextlib import random +import time import uuid from dataclasses import dataclass, field from datetime import datetime, timezone @@ -36,7 +37,7 @@ from app.models.entity import Entity from app.models.entity_sighting import EntitySighting from app.models.event import Event from app.rate_limit import RateLimiter -from app.telemetry import sample_network +from app.telemetry import detect_wire_spike, sample_network from app.tts.piper import synthesize_spirit_voice from app.tts.voices import pick_voice @@ -71,6 +72,8 @@ class SeanceState: anomalies: list[dict] = field(default_factory=list) history: list[dict] = field(default_factory=list) ambient_task: asyncio.Task | None = None + wire_jitter_history: list[float] = field(default_factory=list) + last_wire_anomaly_at: float = 0.0 def serialize_entity(entity: Entity) -> dict: @@ -324,16 +327,36 @@ async def _handle_question(state: SeanceState, text: str) -> None: async def _ambient_loop(state: SeanceState) -> None: - """The Wire Ghost's pulse: telemetry every few seconds, a whisper only - when the LLM box has been quiet long enough.""" + """The Wire Ghost's pulse: telemetry every few seconds. Spikes in the + jitter baseline become first-class anomaly events (driving fragments and + the client's data views); quieter ticks may earn an ambient whisper.""" try: while True: - await asyncio.sleep(random.uniform(6, 10)) + await asyncio.sleep(random.uniform(3, 5)) try: sample = await sample_network(period_s=1.0) except Exception: continue await state.send_queue.put({"type": "telemetry", **sample.as_dict()}) + + jitter = sample.jitter_bytes_per_s + ratio = detect_wire_spike(state.wire_jitter_history, jitter) + state.wire_jitter_history = (state.wire_jitter_history + [jitter])[-60:] + + now = time.monotonic() + if ratio is not None and now - state.last_wire_anomaly_at > 20: + state.last_wire_anomaly_at = now + anomaly = { + "source": "wire", + "frequency": round(jitter, 1), + "magnitude": round(ratio * 10, 1), + } + # Tell the client first so its matrix/graph views light up, + # then run the anomaly through the usual séance pipeline. + await state.send_queue.put({"type": "anomaly", **anomaly}) + await _handle_anomaly(state, anomaly) + continue + if not spirit_service.ambient_ready(): continue whisper = await spirit_service.wire_whisper(sample.as_dict(), state.language) diff --git a/backend/tests/test_cookie_scheme.py b/backend/tests/test_cookie_scheme.py new file mode 100644 index 0000000..bca3e71 --- /dev/null +++ b/backend/tests/test_cookie_scheme.py @@ -0,0 +1,24 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.mark.asyncio +async def test_login_cookie_secure_only_over_https(client): + await client.post("/auth/register", json={"username": "schemer", "password": "spookyspooky"}) + + https_login = await client.post( + "/auth/login", json={"username": "schemer", "password": "spookyspooky"} + ) + assert "secure" in https_login.headers["set-cookie"].lower() + + # Plain-http (LAN) access: a Secure cookie would be dropped by the + # browser and silently break the séance socket. + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client: + http_login = await http_client.post( + "/auth/login", json={"username": "schemer", "password": "spookyspooky"} + ) + cookie = http_login.headers["set-cookie"].lower() + assert "qm_session=" in cookie + assert "secure" not in cookie diff --git a/backend/tests/test_telemetry.py b/backend/tests/test_telemetry.py index 41f9095..de531cc 100644 --- a/backend/tests/test_telemetry.py +++ b/backend/tests/test_telemetry.py @@ -1,4 +1,4 @@ -from app.telemetry import parse_proc_net_dev +from app.telemetry import detect_wire_spike, parse_proc_net_dev PROC_NET_DEV = """Inter-| Receive | Transmit face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed @@ -17,3 +17,32 @@ def test_parse_proc_net_dev_extracts_counters(): def test_parse_proc_net_dev_ignores_malformed_lines(): assert parse_proc_net_dev("garbage\nno colon here\n") == {} + + +def test_spike_needs_history(): + assert detect_wire_spike([], 1_000_000) is None + assert detect_wire_spike([10_000.0] * 3, 1_000_000) is None + + +def test_spike_fires_on_real_surge(): + history = [20_000.0, 25_000.0, 22_000.0, 21_000.0, 23_000.0, 24_000.0, 22_500.0] + ratio = detect_wire_spike(history, 400_000.0) + assert ratio is not None + assert ratio > 10 + + +def test_spike_ignores_normal_fluctuation(): + history = [20_000.0, 25_000.0, 22_000.0, 21_000.0, 23_000.0, 24_000.0, 22_500.0] + assert detect_wire_spike(history, 30_000.0) is None + + +def test_spike_has_absolute_floor_for_silent_links(): + # A nearly idle link jittering by a few KB/s must never cry ghost. + history = [100.0, 150.0, 120.0, 90.0, 110.0, 130.0, 140.0] + assert detect_wire_spike(history, 5_000.0) is None + + +def test_spike_adapts_to_loud_baseline(): + # Once the line is genuinely busy, the same surge is no longer anomalous. + history = [350_000.0, 380_000.0, 360_000.0, 370_000.0, 355_000.0, 375_000.0, 365_000.0] + assert detect_wire_spike(history, 400_000.0) is None diff --git a/frontend/src/components/GraphView.css b/frontend/src/components/GraphView.css new file mode 100644 index 0000000..7ead39b --- /dev/null +++ b/frontend/src/components/GraphView.css @@ -0,0 +1,78 @@ +/* GraphView — scrolling wire-flood time-series for the séance board area. + Tokens: bg #07070d · panel rgba(16,16,26,0.85) · phosphor #7cffb2 · + violet #b26bff · radio orange #ff9d4a · mono 'IBM Plex Mono'. */ + +.graph-view { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background: rgba(16, 16, 26, 0.85); + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 6px; + overflow: hidden; + filter: drop-shadow(0 0 18px rgba(7, 7, 13, 0.9)); +} + +.graph-counters { + display: flex; + gap: 1.4rem; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid rgba(124, 255, 178, 0.12); + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + flex-shrink: 0; +} + +.graph-counter { + display: flex; + align-items: baseline; + gap: 0.5rem; + white-space: nowrap; +} + +.counter-key { + font-size: 0.62rem; + letter-spacing: 0.18em; + color: rgba(211, 233, 219, 0.5); +} + +.counter-val { + font-size: 0.85rem; + color: #7cffb2; + text-shadow: 0 0 10px rgba(124, 255, 178, 0.55); +} + +.counter-entity { + color: #b26bff; + text-shadow: 0 0 10px rgba(178, 107, 255, 0.55); +} + +.graph-canvas-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.graph-canvas-wrap canvas { + position: absolute; + inset: 0; + cursor: crosshair; +} + +.graph-listening { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + margin: 0; + color: #b26bff; + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 0.78rem; + letter-spacing: 0.22em; + text-transform: uppercase; + text-shadow: 0 0 12px rgba(178, 107, 255, 0.7); + pointer-events: none; +} diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx new file mode 100644 index 0000000..8551043 --- /dev/null +++ b/frontend/src/components/GraphView.tsx @@ -0,0 +1,324 @@ +// GraphView — real-time scrolling time-series of the wire flood on canvas 2D. +// Three polylines (jitter green, latency-variance violet, dns dim gray) scroll +// right-to-left over a rolling window of the last ~180 telemetry frames, each +// with a glowing last-point dot and a mono current-value label. Anomalies from +// the session fire as vertical ticks on the shared time axis, height ∝ +// magnitude, colored by source; hovering a marker draws its frequency and +// magnitude as a tooltip. A top row of flickering mono counters tracks the +// session's anomaly total, the contacted entity and the current jitter. + +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import type { AnomalyEntry } from '../state/seance' +import type { Telemetry } from '../lib/types' +import './GraphView.css' + +/** Rolling history length (telemetry frames). */ +export const HISTORY_CAP = 180 + +export type TelemetrySample = { + at: number + jitter: number + variance: number + dns: number +} + +// ---- pure helpers (unit-tested without canvas) ---- + +/** Append a sample to the rolling history, dropping the oldest when full. */ +export function pushHistory( + history: TelemetrySample[], + sample: TelemetrySample, + cap = HISTORY_CAP, +): TelemetrySample[] { + const out = history.length >= cap ? history.slice(history.length - cap + 1) : [...history] + out.push(sample) + return out +} + +/** Adaptive y-scale: the series max with 15% headroom, never below `floor`. */ +export function adaptiveMax(values: number[], floor: number): number { + let max = floor + for (const v of values) if (v > max) max = v + return max * 1.15 +} + +/** Marker color per anomaly source: wire green, evp violet, radio orange. */ +export function anomalyColor(source: AnomalyEntry['source']): string { + switch (source) { + case 'wire': + return '#7cffb2' + case 'evp': + return '#b26bff' + case 'radio': + return '#ff9d4a' + } +} + +// ---- component ---- + +export type GraphViewProps = { + telemetry: Telemetry | null + anomalies: AnomalyEntry[] + anomalyCount: number + entityName: string | null + /** When false, the rAF loop is paused (view hidden). */ + active: boolean +} + +const MARGIN = { top: 14, right: 96, bottom: 22, left: 10 } + +type Series = { + key: 'jitter' | 'variance' | 'dns' + color: string + floor: number + label: (s: TelemetrySample) => string +} + +const SERIES: Series[] = [ + { key: 'jitter', color: '#7cffb2', floor: 10, label: (s) => `JIT ${s.jitter.toFixed(1)}` }, + { key: 'variance', color: '#b26bff', floor: 2, label: (s) => `VAR ${s.variance.toFixed(2)}` }, + { key: 'dns', color: 'rgba(211, 233, 219, 0.4)', floor: 5, label: (s) => `DNS ${s.dns.toFixed(1)}` }, +] + +export function GraphView({ telemetry, anomalies, anomalyCount, entityName, active }: GraphViewProps) { + const { t } = useTranslation() + const canvasRef = useRef(null) + const historyRef = useRef([]) + const anomaliesRef = useRef(anomalies) + anomaliesRef.current = anomalies + const hoverRef = useRef<{ x: number; y: number; anomaly: AnomalyEntry } | null>(null) + + // Rolling history: one timestamped sample per telemetry frame. + useEffect(() => { + if (!telemetry) return + historyRef.current = pushHistory(historyRef.current, { + at: Date.now(), + jitter: telemetry.jitter_bytes_per_s, + variance: telemetry.latency_variance_ms, + dns: telemetry.dns_ms, + }) + }, [telemetry]) + + // Marker hit-testing: remember the anomaly nearest the cursor. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const pick = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect() + const mx = e.clientX - rect.left + const my = e.clientY - rect.top + const history = historyRef.current + hoverRef.current = null + if (history.length < 2) return + const w = rect.width + const h = rect.height + const plotW = w - MARGIN.left - MARGIN.right + const plotH = h - MARGIN.top - MARGIN.bottom + const t0 = history[0].at + const t1 = history[history.length - 1].at + const span = Math.max(1, t1 - t0) + let best: { x: number; y: number; anomaly: AnomalyEntry } | null = null + for (const a of anomaliesRef.current) { + if (a.at < t0 || a.at > t1) continue + const x = MARGIN.left + ((a.at - t0) / span) * plotW + if (Math.abs(x - mx) > 6) continue + const tickH = Math.min(plotH, 8 + Math.abs(a.magnitude) * 3) + const y = MARGIN.top + plotH - tickH + if (my < y - 6) continue + if (!best || Math.abs(x - mx) < Math.abs(best.x - mx)) best = { x, y, anomaly: a } + } + hoverRef.current = best + } + const clear = () => { + hoverRef.current = null + } + + canvas.addEventListener('mousemove', pick) + canvas.addEventListener('mouseleave', clear) + return () => { + canvas.removeEventListener('mousemove', pick) + canvas.removeEventListener('mouseleave', clear) + } + }, []) + + useEffect(() => { + if (!active) return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let raf = 0 + let w = 0 + let h = 0 + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const dpr = Math.min(window.devicePixelRatio, 2) + w = parent.clientWidth + h = parent.clientHeight + if (w === 0 || h === 0) return + canvas.width = w * dpr + canvas.height = h * dpr + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + resize() + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const draw = () => { + raf = requestAnimationFrame(draw) + if (w === 0 || h === 0) return + const history = historyRef.current + const plotW = w - MARGIN.left - MARGIN.right + const plotH = h - MARGIN.top - MARGIN.bottom + + ctx.fillStyle = '#07070d' + ctx.fillRect(0, 0, w, h) + + // faint horizontal grid + ctx.strokeStyle = 'rgba(124, 255, 178, 0.07)' + ctx.lineWidth = 1 + for (let i = 1; i <= 3; i++) { + const y = MARGIN.top + (plotH * i) / 4 + ctx.beginPath() + ctx.moveTo(MARGIN.left, y) + ctx.lineTo(MARGIN.left + plotW, y) + ctx.stroke() + } + + ctx.font = '10px "IBM Plex Mono", monospace' + ctx.textBaseline = 'middle' + + if (history.length >= 2) { + const t0 = history[0].at + const t1 = history[history.length - 1].at + const span = Math.max(1, t1 - t0) + const xAt = (at: number) => MARGIN.left + ((at - t0) / span) * plotW + + // --- anomaly markers: vertical ticks, height ∝ magnitude --- + for (const a of anomaliesRef.current) { + if (a.at < t0 || a.at > t1) continue + const x = xAt(a.at) + const tickH = Math.min(plotH, 8 + Math.abs(a.magnitude) * 3) + const color = anomalyColor(a.source) + ctx.strokeStyle = color + ctx.globalAlpha = 0.85 + ctx.beginPath() + ctx.moveTo(x, MARGIN.top + plotH) + ctx.lineTo(x, MARGIN.top + plotH - tickH) + ctx.stroke() + ctx.globalAlpha = 0.25 + ctx.beginPath() + ctx.moveTo(x, MARGIN.top + plotH - tickH) + ctx.lineTo(x, MARGIN.top) + ctx.stroke() + ctx.globalAlpha = 1 + } + + // --- series polylines + glowing last point + value label --- + ctx.textAlign = 'left' + SERIES.forEach((s, si) => { + const max = adaptiveMax( + history.map((p) => p[s.key]), + s.floor, + ) + const yFor = (v: number) => MARGIN.top + plotH - (Math.min(v, max) / max) * plotH + ctx.strokeStyle = s.color + ctx.lineWidth = 1.4 + ctx.beginPath() + history.forEach((p, i) => { + const x = xAt(p.at) + const y = yFor(p[s.key]) + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + }) + ctx.stroke() + + const lastP = history[history.length - 1] + const lx = xAt(lastP.at) + const ly = yFor(lastP[s.key]) + ctx.fillStyle = s.color + ctx.shadowColor = s.color + ctx.shadowBlur = 10 + ctx.beginPath() + ctx.arc(lx, ly, 3, 0, Math.PI * 2) + ctx.fill() + ctx.shadowBlur = 0 + ctx.fillText(s.label(lastP), MARGIN.left + plotW + 10, MARGIN.top + 12 + si * 16) + }) + + // --- hover tooltip for the marker under the cursor --- + const hover = hoverRef.current + if (hover) { + const lines = [ + `${hover.anomaly.frequency.toFixed(1)} MHZ`, + `${hover.anomaly.magnitude >= 0 ? '+' : '-'}${Math.abs(hover.anomaly.magnitude).toFixed(1)} DB`, + ] + ctx.font = '11px "IBM Plex Mono", monospace' + const tw = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 16 + const th = 34 + const tx = Math.min(Math.max(hover.x - tw / 2, MARGIN.left), MARGIN.left + plotW - tw) + const ty = Math.max(MARGIN.top + 4, hover.y - th - 10) + ctx.fillStyle = 'rgba(16, 16, 26, 0.94)' + ctx.strokeStyle = anomalyColor(hover.anomaly.source) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.rect(tx, ty, tw, th) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#d3e9db' + ctx.textAlign = 'center' + ctx.fillText(lines[0], tx + tw / 2, ty + 11) + ctx.fillText(lines[1], tx + tw / 2, ty + 24) + } + } else { + // Empty state: the grid alone would be a blank box — say we're listening. + ctx.textAlign = 'center' + ctx.fillStyle = 'rgba(178, 107, 255, 0.75)' + ctx.font = '12px "IBM Plex Mono", monospace' + ctx.fillText('· · ·', MARGIN.left + plotW / 2, MARGIN.top + plotH / 2) + } + } + + raf = requestAnimationFrame(draw) + return () => { + cancelAnimationFrame(raf) + ro.disconnect() + } + }, [active]) + + return ( +
+
+ + {t('seance.views.graph.anomalies', { defaultValue: 'ANOMALIES' })} + {anomalyCount} + + + {t('seance.views.graph.entity', { defaultValue: 'ENTITY' })} + {entityName ?? '—'} + + + {t('seance.views.graph.jitter', { defaultValue: 'JITTER' })} + + {telemetry ? `${telemetry.jitter_bytes_per_s.toFixed(1)} KB/S` : '—'} + + +
+
+ + {!telemetry && ( +

+ {t('seance.views.listening', { defaultValue: 'listening…' })} +

+ )} +
+
+ ) +} diff --git a/frontend/src/components/MatrixView.css b/frontend/src/components/MatrixView.css new file mode 100644 index 0000000..762a1d5 --- /dev/null +++ b/frontend/src/components/MatrixView.css @@ -0,0 +1,57 @@ +/* MatrixView — "matrix rain" live-data view for the séance board area. + Tokens: bg #07070d · phosphor #7cffb2 · violet #b26bff · blood #ff3b5c. */ + +.matrix-view { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + background: #07070d; + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 6px; + overflow: hidden; + filter: drop-shadow(0 0 18px rgba(7, 7, 13, 0.9)); +} + +.matrix-view canvas { + position: absolute; + inset: 0; +} + +.matrix-listening { + position: absolute; + left: 14px; + bottom: 12px; + margin: 0; + color: #b26bff; + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 0.78rem; + letter-spacing: 0.22em; + text-transform: uppercase; + text-shadow: 0 0 12px rgba(178, 107, 255, 0.7); + pointer-events: none; +} + +/* ---------- view switcher (BOARD / MATRIX / GRAPHS) ---------- + Styles live here because SeancePage.css is outside this change's scope; + the tab buttons themselves reuse the existing .mode-tab look. */ + +.seance-view-tabs { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 6; + display: flex; + gap: 4px; + padding: 2px; + background: rgba(16, 16, 26, 0.85); + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 5px; + backdrop-filter: blur(3px); +} + +.seance-view-tabs .mode-tab { + font-size: 0.7rem; + padding: 0.3rem 0.6rem; +} diff --git a/frontend/src/components/MatrixView.tsx b/frontend/src/components/MatrixView.tsx new file mode 100644 index 0000000..612fb4c --- /dev/null +++ b/frontend/src/components/MatrixView.tsx @@ -0,0 +1,245 @@ +// MatrixView — a "matrix rain" of spirit data on canvas 2D. +// Classic falling-glyph columns (katakana / latin / digits / runes, phosphor +// green heads with fading trails) interleaved with slower violet 'data drops' +// that carry REAL live strings: utterance words, anomaly readings, telemetry +// values and the summoned entity's name. Roughly every 8s one column flashes +// blood-red for a beat — the veil twitching. Rain density scales gently with +// the recent anomaly rate. + +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import type { AnomalyEntry, Utterance } from '../state/seance' +import type { Telemetry } from '../lib/types' +import './MatrixView.css' + +const GLYPHS = + 'アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛞᛟ' +const CELL = 16 +/** How many recent data strings the rain remembers. */ +export const DATA_CAP = 24 +const FLASH_EVERY_MS = 8000 +const FLASH_LEN_MS = 550 +const ANOMALY_WINDOW_MS = 10_000 +const MAX_DATA_DROPS = 4 + +// ---- pure helpers (unit-tested without canvas) ---- + +/** Append to a bounded ring buffer, dropping the oldest entry when full. */ +export function pushRing(ring: T[], item: T, cap: number): T[] { + const out = ring.length >= cap ? ring.slice(ring.length - cap + 1) : [...ring] + out.push(item) + return out +} + +/** `101.4MHZ +8.2DB` — the string a data drop carries for one anomaly. */ +export function anomalyString(a: Pick): string { + const sign = a.magnitude >= 0 ? '+' : '-' + return `${a.frequency.toFixed(1)}MHZ ${sign}${Math.abs(a.magnitude).toFixed(1)}DB` +} + +/** Extract an uppercase single word suitable for a data drop, else null. */ +export function utteranceWord(text: string): string | null { + const words = text.toUpperCase().split(/[^A-Z0-9ᚠ-ᛟ]+/).filter((w) => w.length >= 3 && w.length <= 12) + if (words.length === 0) return null + return words[Math.floor(words.length / 2)] // middle word reads best +} + +/** + * Build the ring buffer of recent live-data strings the rain draws from: + * recent utterance words, anomaly readings, telemetry values, entity name. + */ +export function collectDataStrings(input: { + anomalies: AnomalyEntry[] + utterances: Utterance[] + telemetry: Telemetry | null + entityName: string | null +}): string[] { + let ring: string[] = [] + const push = (s: string | null) => { + if (s) ring = pushRing(ring, s, DATA_CAP) + } + for (const u of input.utterances.slice(-6)) push(utteranceWord(u.text)) + for (const a of input.anomalies.slice(-6)) push(anomalyString(a)) + if (input.telemetry) { + push(`JIT ${input.telemetry.jitter_bytes_per_s.toFixed(1)}KB/S`) + push(`RTT ${input.telemetry.latency_mean_ms.toFixed(1)}MS`) + } + if (input.entityName) push(input.entityName.toUpperCase()) + return ring +} + +/** + * Rain density 0..1 from the recent anomaly rate — more anomalies, more rain. + * Saturates at ~1 anomaly per second over the window. + */ +export function anomalyDensity( + anomalies: Pick[], + now: number, + windowMs = ANOMALY_WINDOW_MS, +): number { + let recent = 0 + for (const a of anomalies) if (now - a.at <= windowMs) recent++ + return Math.min(1, recent / (windowMs / 1000)) +} + +// ---- component ---- + +export type MatrixViewProps = { + anomalies: AnomalyEntry[] + utterances: Utterance[] + telemetry: Telemetry | null + entityName: string | null + /** When false, the rAF loop is paused (view hidden). */ + active: boolean +} + +type Drop = { col: number; y: number; speed: number; text: string } + +export function MatrixView({ anomalies, utterances, telemetry, entityName, active }: MatrixViewProps) { + const { t } = useTranslation() + const canvasRef = useRef(null) + + // Live data flows into the rAF loop through refs (no re-render per frame). + const ringRef = useRef([]) + const anomaliesRef = useRef(anomalies) + anomaliesRef.current = anomalies + useEffect(() => { + ringRef.current = collectDataStrings({ anomalies, utterances, telemetry, entityName }) + }, [anomalies, utterances, telemetry, entityName]) + + // Reactive empty-state: nothing has whispered through the wire yet. + const listening = + anomalies.length === 0 && utterances.length === 0 && telemetry === null && entityName === null + + useEffect(() => { + if (!active) return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let raf = 0 + let w = 0 + let h = 0 + let cols = 0 + let heads: number[] = [] + let speeds: number[] = [] + let drops: Drop[] = [] + let flashCol = -1 + let flashAt = 0 + let nextFlash = performance.now() + FLASH_EVERY_MS + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const dpr = Math.min(window.devicePixelRatio, 2) + w = parent.clientWidth + h = parent.clientHeight + if (w === 0 || h === 0) return + canvas.width = w * dpr + canvas.height = h * dpr + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = '#07070d' + ctx.fillRect(0, 0, w, h) + cols = Math.max(1, Math.floor(w / CELL)) + heads = Array.from({ length: cols }, () => Math.random() * (h / CELL)) + speeds = Array.from({ length: cols }, () => 0.4 + Math.random() * 0.8) + drops = [] + } + resize() + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + if (w === 0 || h === 0) return + const density = 0.3 + 0.7 * anomalyDensity(anomaliesRef.current, now) + + // Fading trail: translucent veil over the previous frame. + ctx.fillStyle = 'rgba(7, 7, 13, 0.09)' + ctx.fillRect(0, 0, w, h) + ctx.font = `${CELL - 2}px "IBM Plex Mono", monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // The veil twitch: pick a column to burn red for a beat. + if (now >= nextFlash) { + flashCol = Math.floor(Math.random() * cols) + flashAt = now + nextFlash = now + FLASH_EVERY_MS * (0.75 + Math.random() * 0.5) + } + if (flashCol >= 0 && now - flashAt >= FLASH_LEN_MS) flashCol = -1 + + // --- glyph rain --- + for (let i = 0; i < cols; i++) { + if (Math.random() > density) continue // density gates column activity + const x = i * CELL + CELL / 2 + const y = heads[i] * CELL + const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] + if (i === flashCol) { + ctx.fillStyle = 'rgba(255, 59, 92, 0.07)' + ctx.fillRect(i * CELL, 0, CELL, h) + ctx.shadowColor = '#ff3b5c' + ctx.shadowBlur = 14 + ctx.fillStyle = '#ff3b5c' + ctx.fillText(ch, x, y) + ctx.shadowBlur = 0 + } else { + ctx.fillStyle = '#7cffb2' + ctx.fillText(ch, x, y) + } + heads[i] += speeds[i] + if (y > h && Math.random() < 0.02 + density * 0.04) heads[i] = 0 + } + + // --- violet data drops: live strings riding the rain --- + const ring = ringRef.current + if (drops.length < MAX_DATA_DROPS && ring.length > 0 && Math.random() < 0.025) { + drops.push({ + col: Math.floor(Math.random() * cols), + y: -CELL, + speed: 1 + Math.random() * 1.2, // slower than the rain + text: ring[Math.floor(Math.random() * ring.length)], + }) + } + ctx.shadowColor = '#b26bff' + ctx.shadowBlur = 9 + for (let i = drops.length - 1; i >= 0; i--) { + const d = drops[i] + d.y += d.speed + const chars = d.text.slice(0, 16) + const x = d.col * CELL + CELL / 2 + // Reads top → bottom; the leading (bottom) glyph burns brightest. + for (let j = 0; j < chars.length; j++) { + const cy = d.y - (chars.length - 1 - j) * CELL + if (cy < -CELL || cy > h + CELL) continue + ctx.fillStyle = '#b26bff' + ctx.globalAlpha = j === chars.length - 1 ? 1 : 0.2 + (j / chars.length) * 0.6 + ctx.fillText(chars[j], x, cy) + } + ctx.globalAlpha = 1 + if (d.y - chars.length * CELL > h) drops.splice(i, 1) + } + ctx.shadowBlur = 0 + } + + raf = requestAnimationFrame(draw) + return () => { + cancelAnimationFrame(raf) + ro.disconnect() + } + }, [active]) + + return ( +
+ + {listening && ( +

+ {t('seance.views.listening', { defaultValue: 'listening…' })} +

+ )} +
+ ) +} diff --git a/frontend/src/components/views.test.ts b/frontend/src/components/views.test.ts new file mode 100644 index 0000000..c1f73ae --- /dev/null +++ b/frontend/src/components/views.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { initialSeanceState, seanceReducer } from '../state/seance' +import type { AnomalyEntry, SeanceState, Utterance } from '../state/seance' +import type { ServerFrame } from '../lib/types' +import { anomalyDensity, anomalyString, collectDataStrings, pushRing, utteranceWord, DATA_CAP } from './MatrixView' +import { adaptiveMax, anomalyColor, pushHistory, HISTORY_CAP } from './GraphView' + +const AT = 1_700_000_000_000 + +function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState { + return seanceReducer(state, { type: 'frame', frame: f, at }) +} + +function lastRow(state: SeanceState) { + return state.transcript[state.transcript.length - 1] +} + +describe('seanceReducer — server anomaly frames', () => { + it('appends a server anomaly to both the anomalies list and the transcript', () => { + const state = frame(initialSeanceState, { + type: 'anomaly', + source: 'wire', + frequency: 432.8, + magnitude: -3.1, + }) + expect(state.anomalies).toHaveLength(1) + expect(state.anomalies[0]).toMatchObject({ + source: 'wire', + frequency: 432.8, + magnitude: -3.1, + at: AT, + }) + expect(lastRow(state)).toMatchObject({ + kind: 'anomaly', + source: 'wire', + frequency: 432.8, + magnitude: -3.1, + at: AT, + }) + // Same entry id shared between the list and the transcript row. + const row = lastRow(state) + if (row.kind === 'anomaly') expect(row.id).toBe(state.anomalies[0].id) + // The server-side count only moves when anomaly_ack arrives. + expect(state.anomalyCount).toBe(0) + expect(initialSeanceState.anomalies).toEqual([]) // reducer is pure + }) + + it('caps server anomalies at 200, dropping the oldest', () => { + let state = initialSeanceState + for (let i = 1; i <= 205; i++) { + state = frame( + state, + { type: 'anomaly', source: 'radio', frequency: 100 + i, magnitude: 5 }, + AT + i, + ) + } + expect(state.anomalies).toHaveLength(200) + expect(state.anomalies[0].frequency).toBe(106) + expect(state.anomalies[199].frequency).toBe(305) + }) +}) + +describe('MatrixView helpers', () => { + it('pushRing appends and caps, dropping the oldest', () => { + let ring: number[] = [] + for (let i = 1; i <= 5; i++) ring = pushRing(ring, i, 3) + expect(ring).toEqual([3, 4, 5]) + }) + + it('anomalyString formats frequency and signed magnitude', () => { + expect(anomalyString({ frequency: 101.44, magnitude: 8.23 })).toBe('101.4MHZ +8.2DB') + expect(anomalyString({ frequency: 88, magnitude: -3.06 })).toBe('88.0MHZ -3.1DB') + }) + + it('utteranceWord picks a single uppercase word and rejects noise', () => { + expect(utteranceWord('the veil is thin')).toBe('VEIL') // 'IS' filtered (<3), middle of the rest + expect(utteranceWord('mirela')).toBe('MIRELA') + expect(utteranceWord('... --- ...')).toBeNull() + }) + + it('collectDataStrings gathers words, anomalies, telemetry and the entity name', () => { + const anomalies: AnomalyEntry[] = [ + { id: 1, source: 'wire', frequency: 55.2, magnitude: 1.2, at: AT }, + ] + const utterances: Utterance[] = [ + { id: 'u1', kind: 'fragment', text: 'we are below', entity: null, audioUrl: null, at: AT }, + ] + const ring = collectDataStrings({ + anomalies, + utterances, + telemetry: { jitter_bytes_per_s: 23.68, latency_variance_ms: 1, latency_mean_ms: 5.94, dns_ms: 7 }, + entityName: 'Mirela', + }) + expect(ring).toContain('BELOW') + expect(ring).toContain('55.2MHZ +1.2DB') + expect(ring).toContain('JIT 23.7KB/S') + expect(ring).toContain('RTT 5.9MS') + expect(ring).toContain('MIRELA') + expect(ring.length).toBeLessThanOrEqual(DATA_CAP) + }) + + it('collectDataStrings degrades to an empty ring with no live data', () => { + expect( + collectDataStrings({ anomalies: [], utterances: [], telemetry: null, entityName: null }), + ).toEqual([]) + }) + + it('anomalyDensity scales 0→1 with the recent anomaly rate', () => { + const now = AT + expect(anomalyDensity([], now)).toBe(0) + const recent = Array.from({ length: 10 }, (_, i) => ({ at: now - i * 500 })) + expect(anomalyDensity(recent, now, 10_000)).toBe(1) + const stale = [{ at: now - 60_000 }] + expect(anomalyDensity(stale, now, 10_000)).toBe(0) + }) +}) + +describe('GraphView helpers', () => { + it('pushHistory appends and caps at HISTORY_CAP, dropping the oldest', () => { + let history = pushHistory([], { at: AT, jitter: 1, variance: 1, dns: 1 }) + expect(history).toHaveLength(1) + for (let i = 1; i <= HISTORY_CAP + 5; i++) { + history = pushHistory(history, { at: AT + i, jitter: i, variance: i, dns: i }) + } + expect(history).toHaveLength(HISTORY_CAP) + expect(history[history.length - 1].jitter).toBe(HISTORY_CAP + 5) + expect(history[0].jitter).toBe(6) + }) + + it('adaptiveMax respects the floor and adds headroom', () => { + expect(adaptiveMax([], 10)).toBeCloseTo(11.5) + expect(adaptiveMax([2, 4, 100, 7], 10)).toBeCloseTo(115) + expect(adaptiveMax([1, 2], 50)).toBeCloseTo(57.5) + }) + + it('anomalyColor maps sources to their token colors', () => { + expect(anomalyColor('wire')).toBe('#7cffb2') + expect(anomalyColor('evp')).toBe('#b26bff') + expect(anomalyColor('radio')).toBe('#ff9d4a') + }) +}) diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 98c0119..5d6c726 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -105,7 +105,10 @@ "latencyVar": "latency σ²", "latencyMean": "latency μ", "dns": "dns", - "units": {"bytes": "B/s", "ms": "ms"} + "units": { + "bytes": "B/s", + "ms": "ms" + } }, "wire": { "hint": "the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own." @@ -156,6 +159,18 @@ "toast": { "rate_limited": "the veil is thin — slow your knocking", "veil_crowded": "too many spirits crowd the line" + }, + "views": { + "board": "BOARD", + "matrix": "MATRIX", + "graphs": "GRAPHS", + "tuning": "tuning the veil…", + "listening": "listening…", + "graph": { + "anomalies": "anomalies", + "entity": "entity", + "jitter": "jitter" + } } }, "codex": { diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 5349995..27a290e 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -105,7 +105,10 @@ "latencyVar": "latencia σ²", "latencyMean": "latencia μ", "dns": "dns", - "units": {"bytes": "B/s", "ms": "ms"} + "units": { + "bytes": "B/s", + "ms": "ms" + } }, "wire": { "hint": "el cable golpea más fuerte cuando nadie lo toca — activa la escucha pasiva y deja que la línea murmure sola." @@ -156,6 +159,18 @@ "toast": { "rate_limited": "el velo es delgado — golpea más despacio", "veil_crowded": "demasiados espíritus se apiñan en la línea" + }, + "views": { + "board": "TABLERO", + "matrix": "MATRIZ", + "graphs": "GRÁFICAS", + "tuning": "afinando el velo…", + "listening": "escuchando…", + "graph": { + "anomalies": "anomalías", + "entity": "entidad", + "jitter": "fluctuación" + } } }, "codex": { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bbd2577..2875b63 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -90,6 +90,7 @@ export type ServerFrame = | { type: 'status'; state: SessionStatus } | { type: 'entity'; is_new: boolean; entity: SpiritEntity } | { type: 'anomaly_ack'; count: number } + | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number } | { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null } | { type: 'audio'; id: string; url: string } | { type: 'reply_start' } diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index f6f9f2b..e600f6a 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -19,6 +19,8 @@ import type { Toast } from '../state/seance' import { GhostCanvas } from '../three/GhostCanvas' import type { GhostMood, GhostVisualState } from '../three/GhostScene' import { PlanchetteBoard } from '../components/PlanchetteBoard' +import { MatrixView } from '../components/MatrixView' +import { GraphView } from '../components/GraphView' import { Transcript } from '../components/Transcript' import { PlanchetteMachine } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette' @@ -35,6 +37,14 @@ import type { Language, Mode } from '../lib/types' import './SeancePage.css' const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija'] + +// Board-area views; labels fall back to these until the i18n JSONs land. +const VIEWS = [ + { id: 'board', fallback: 'BOARD' }, + { id: 'matrix', fallback: 'MATRIX' }, + { id: 'graphs', fallback: 'GRAPHS' }, +] as const +type BoardView = (typeof VIEWS)[number]['id'] const TOAST_TTL_MS = 6000 /** SDR sample rate must match RtlSdr.open()'s default. */ const SDR_SAMPLE_RATE_HZ = 2_048_000 @@ -84,6 +94,7 @@ function SeanceSession({ username }: { username: string }) { const [departed, setDeparted] = useState(false) const [question, setQuestion] = useState('') const [dossierOpen, setDossierOpen] = useState(true) + const [view, setView] = useState('board') // The planchette machine lives for the whole session; the board ticks it. const machineRef = useRef(new PlanchetteMachine()) @@ -248,8 +259,17 @@ function SeanceSession({ username }: { username: string }) { - {/* ---- unstable-veil banner ---- */} - {state.connection !== 'open' && ( + {/* ---- veil banner: 'connecting' is a neutral tuning state; + only an unstable or dead line raises the warning ---- */} + {state.connection === 'connecting' && ( +
+ + ⟁ + + {t('seance.views.tuning', { defaultValue: 'tuning the veil…' })} +
+ )} + {(state.connection === 'unstable' || state.connection === 'closed') && (
⟁ @@ -261,7 +281,42 @@ function SeanceSession({ username }: { username: string }) { {/* ---- main: board + side column ---- */}
- + {/* view switcher: BOARD / MATRIX / GRAPHS (styles in MatrixView.css, + buttons reuse the .mode-tab look) */} + + + {/* The PlanchetteMachine lives in this page, so unmounting the board + component for another view loses nothing. */} + {view === 'board' && } + {view === 'matrix' && ( + + )} + {view === 'graphs' && ( + + )} {state.status && (
diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index f94edc3..c2885b0 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -220,6 +220,34 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS case 'anomaly_ack': return { ...state, anomalyCount: frame.count } + case 'anomaly': { + // Server-pushed anomaly (the wire-telemetry detector) — same shape + // and handling as a locally-detected one. + const entry: AnomalyEntry = { + id: nextId(), + source: frame.source, + frequency: frame.frequency, + magnitude: frame.magnitude, + at, + } + return { + ...state, + anomalies: pushCapped(state.anomalies, entry, ANOMALY_CAP), + transcript: pushCapped( + state.transcript, + { + id: entry.id, + kind: 'anomaly', + source: frame.source, + frequency: frame.frequency, + magnitude: frame.magnitude, + at, + }, + TRANSCRIPT_CAP, + ), + } + } + case 'utterance': { const ut: Utterance = { id: frame.id,