feat: matrix/graph data views, wire-generated anomalies, http cookie fix

- auth: session cookie Secure only over https — plain-http LAN access was
  silently dropping the cookie, killing WS auth ('connection unstable')
- wire ghost: server-side spike detection on jitter baseline (3σ + 2.5×mean,
  20KB/s floor, 20s throttle) — wire anomalies now flood every session with
  zero hardware, pushed to clients as {type:'anomaly'} frames
- telemetry cadence 3-5s for live graphs; ambient whispers unchanged
- frontend: MATRIX view (data-rain interleaved with live utterances/anomaly/
  telemetry strings), GRAPHS view (scrolling jitter/variance/dns lines +
  anomaly markers + counters), BOARD/MATRIX/GRAPHS switcher
- connection banner: 'connecting' is now neutral 'tuning the veil…', only
  unstable/closed warns
- db: recreated quantumancy(+_test) as UTF8 (was SQL_ASCII — crashed on
  non-ASCII spirit text); README quickstart updated
- i18n: seance.views.* EN/ES
This commit is contained in:
Indiana
2026-07-21 00:02:41 +00:00
parent 6edbbbbc2a
commit c372427ced
16 changed files with 1086 additions and 16 deletions

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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<HTMLCanvasElement | null>(null)
const historyRef = useRef<TelemetrySample[]>([])
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 (
<div className="graph-view">
<div className="graph-counters" aria-live="polite">
<span className="graph-counter flicker-soft">
<span className="counter-key">{t('seance.views.graph.anomalies', { defaultValue: 'ANOMALIES' })}</span>
<span className="counter-val">{anomalyCount}</span>
</span>
<span className="graph-counter flicker-soft">
<span className="counter-key">{t('seance.views.graph.entity', { defaultValue: 'ENTITY' })}</span>
<span className="counter-val counter-entity">{entityName ?? '—'}</span>
</span>
<span className="graph-counter flicker-soft">
<span className="counter-key">{t('seance.views.graph.jitter', { defaultValue: 'JITTER' })}</span>
<span className="counter-val">
{telemetry ? `${telemetry.jitter_bytes_per_s.toFixed(1)} KB/S` : '—'}
</span>
</span>
</div>
<div className="graph-canvas-wrap">
<canvas ref={canvasRef} />
{!telemetry && (
<p className="graph-listening flicker" role="status">
{t('seance.views.listening', { defaultValue: 'listening…' })}
</p>
)}
</div>
</div>
)
}

View File

@@ -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;
}

View File

@@ -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<T>(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<AnomalyEntry, 'frequency' | 'magnitude'>): 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<AnomalyEntry, 'at'>[],
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<HTMLCanvasElement | null>(null)
// Live data flows into the rAF loop through refs (no re-render per frame).
const ringRef = useRef<string[]>([])
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 (
<div className="matrix-view">
<canvas ref={canvasRef} aria-hidden />
{listening && (
<p className="matrix-listening flicker" role="status">
{t('seance.views.listening', { defaultValue: 'listening…' })}
</p>
)}
</div>
)
}

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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' }

View File

@@ -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<BoardView>('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 }) {
</div>
</header>
{/* ---- 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' && (
<div className="seance-conn-banner conn-connecting" role="status">
<span className="conn-glyph" aria-hidden>
⟁
</span>
{t('seance.views.tuning', { defaultValue: 'tuning the veil…' })}
</div>
)}
{(state.connection === 'unstable' || state.connection === 'closed') && (
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
<span className="conn-glyph" aria-hidden>
⟁
@@ -261,7 +281,42 @@ function SeanceSession({ username }: { username: string }) {
{/* ---- main: board + side column ---- */}
<main className="seance-main">
<section className="seance-board-wrap">
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
{/* view switcher: BOARD / MATRIX / GRAPHS (styles in MatrixView.css,
buttons reuse the .mode-tab look) */}
<nav className="seance-view-tabs" aria-label="views">
{VIEWS.map((v) => (
<button
key={v.id}
type="button"
className={`mode-tab ${view === v.id ? 'active' : ''}`}
onClick={() => setView(v.id)}
>
{t(`seance.views.${v.id}`, { defaultValue: v.fallback })}
</button>
))}
</nav>
{/* The PlanchetteMachine lives in this page, so unmounting the board
component for another view loses nothing. */}
{view === 'board' && <PlanchetteBoard machine={machineRef.current} hue={entityHue} />}
{view === 'matrix' && (
<MatrixView
anomalies={state.anomalies}
utterances={state.utterances}
telemetry={state.telemetry}
entityName={state.entity?.name ?? null}
active={view === 'matrix'}
/>
)}
{view === 'graphs' && (
<GraphView
telemetry={state.telemetry}
anomalies={state.anomalies}
anomalyCount={state.anomalyCount}
entityName={state.entity?.name ?? null}
active={view === 'graphs'}
/>
)}
{state.status && (
<div className="seance-status-overlay" aria-live="polite">

View File

@@ -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,