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

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