Files
qtalker---/frontend/src/pages/SeancePage.tsx
Indiana f0ee7dbdab refactor: use TelemetryReadout and EntityCard components
Both were built and tested but never wired in — SeancePage.tsx was
reimplementing the same markup inline. Removes the duplication.
2026-07-21 03:45:11 +00:00

1027 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija / emf), transmission log and direct-contact row.
//
// Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
// itself; this page only enqueues utterance text and polls snapshot()
// (non-mutating) for the ouija panel readout.
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
// in the panel's unmount cleanup — switching modes or leaving the page
// always releases the hardware.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { SeanceProvider, useSeance } from '../state/seance'
import type { Toast } from '../state/seance'
import { GhostCanvas } from '../three/GhostCanvas'
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
import { PlanchetteBoard } from '../components/PlanchetteBoard'
import { MatrixView } from '../components/MatrixView'
import { GraphView } from '../components/GraphView'
import { Transcript } from '../components/Transcript'
import { EntityCard } from '../components/EntityCard'
import { TelemetryReadout } from '../components/TelemetryReadout'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import { EmfSensorListener } from '../lib/emf'
import {
isSecureContext,
isSupported,
RtlSdr,
SpectrumAnomalyDetector,
SWEEP_END_MHZ,
SWEEP_START_MHZ,
} from '../lib/sdr'
import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types'
import './SeancePage.css'
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf']
// Fallback tab labels for modes the i18n catalogs don't carry yet.
const MODE_LABEL_DEFAULTS: Partial<Record<Mode, string>> = { emf: 'FIELD' }
// 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
export function SeancePage() {
const { user, checking } = useAuth()
const { t } = useTranslation()
if (checking) {
return (
<div className="seance-loading" role="status">
<div className="loading-sigil" aria-hidden>
⟁
</div>
<p className="loading-text">{t('common.loading')}</p>
</div>
)
}
if (!user) return <Navigate to="/enter" replace />
return (
<SeanceProvider>
<SeanceSession username={user.username} />
</SeanceProvider>
)
}
export default SeancePage
// ---- the séance itself (rendered inside SeanceProvider) ----
function SeanceSession({ username }: { username: string }) {
const {
state,
audioPlayer,
setMode,
setPassive,
setLanguage,
summon,
ask,
playUtterance,
dismissToast,
} = useSeance()
const { logout } = useAuth()
const { t, i18n } = useTranslation()
const [departed, setDeparted] = useState(false)
const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('board')
// The planchette machine lives for the whole session; the board ticks it.
const machineRef = useRef(new PlanchetteMachine())
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
// Tell the backend which tongue we whisper in (queued while connecting).
useEffect(() => {
setLanguage(storedLanguage())
}, [setLanguage])
// Feed every fresh utterance (greetings, fragments, replies) to the board.
const lastEnqueuedRef = useRef<string | null>(null)
useEffect(() => {
const uts = state.utterances
if (uts.length === 0) return
const lastSeen = lastEnqueuedRef.current
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
const fresh = uts.slice(idx + 1)
for (const u of fresh) machineRef.current.enqueue(u.text)
lastEnqueuedRef.current = uts[uts.length - 1].id
}, [state.utterances])
// Poll the machine's snapshot for the ouija readout (board owns ticking).
useEffect(() => {
let lastSig = ''
const id = window.setInterval(() => {
const s = machineRef.current.snapshot()
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
if (sig !== lastSig) {
lastSig = sig
setBoardSnap(s)
}
}, 150)
return () => window.clearInterval(id)
}, [])
// Open the dossier whenever a new presence steps through.
const entityId = state.entity?.id ?? null
useEffect(() => {
if (entityId) setDossierOpen(true)
}, [entityId])
const ghostVisual = useMemo<GhostVisualState | null>(
() =>
state.entity
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
: null,
[state.entity],
)
const ghostMood: GhostMood = state.speakingId
? 'speaking'
: state.status === 'gathering' || state.status === 'summoning'
? 'gathering'
: state.status === 'attuning'
? 'attuning'
: 'idle'
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
const switchLanguage = (lang: Language) => {
setLanguage(lang)
void i18n.changeLanguage(lang)
persistLanguage(lang)
}
const handleLogout = () => {
void logout()
.catch(() => undefined)
.finally(() => setDeparted(true))
}
const streaming = state.replyStreaming.active
const submitQuestion = (e: FormEvent) => {
e.preventDefault()
if (streaming) return
ask(question)
setQuestion('')
}
if (departed) return <Navigate to="/" replace />
const entityHue = state.entity?.visual.hue ?? 150
const entity = state.entity
return (
<div className="seance-page">
<GhostCanvas
className="seance-ghost"
visual={ghostVisual}
mood={ghostMood}
getAmplitude={getAmplitude}
/>
{/* ---- top bar ---- */}
<header className="seance-topbar">
<div className="seance-brand">
<span className="brand-sigil" aria-hidden>
⟁
</span>
{t('common.appName')}
</div>
<nav className="seance-modes" aria-label="modes">
{MODES.map((m) => (
<button
key={m}
type="button"
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
</button>
))}
</nav>
<div className="seance-top-right">
<label className="passive-toggle" title={t('seance.passive')}>
<input
type="checkbox"
checked={state.passive}
onChange={(e) => setPassive(e.target.checked)}
/>
<span className="passive-track" aria-hidden />
<span className="passive-label">{t('seance.passive')}</span>
</label>
<div className="lang-toggle" role="group" aria-label="language">
<button
type="button"
className={state.language === 'en' ? 'active' : ''}
onClick={() => switchLanguage('en')}
>
EN
</button>
<button
type="button"
className={state.language === 'es' ? 'active' : ''}
onClick={() => switchLanguage('es')}
>
ES
</button>
</div>
<Link to="/shop" className="seance-codex-link">
{t('nav.shop', { defaultValue: 'shop' })}
</Link>
<Link to="/codex" className="seance-codex-link">
{t('nav.codex')}
</Link>
<span className="seance-user" title={username}>
{username}
</span>
<button type="button" className="seance-logout" onClick={handleLogout}>
{t('nav.logout')}
</button>
<span
className={`conn-dot conn-${state.connection}`}
title={t(`seance.connection.${state.connection}`)}
aria-label={t(`seance.connection.${state.connection}`)}
/>
</div>
</header>
{/* ---- 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>
⟁
</span>
{t(`seance.connection.${state.connection}`)}
</div>
)}
{/* ---- main: board + side column ---- */}
<main className="seance-main">
<section className="seance-board-wrap">
{/* 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">
<span className="status-sigil" aria-hidden />
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
</div>
)}
</section>
<aside className="seance-side">
<EntityCard
entity={entity}
isNew={state.entityIsNew}
open={dossierOpen}
onToggleOpen={() => setDossierOpen((o) => !o)}
/>
<section className="mode-panel">
{state.mode === 'wire' && <WirePanel />}
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
{state.mode === 'emf' && <EmfPanel />}
</section>
</aside>
</main>
{/* ---- bottom: transmission log + direct contact ---- */}
<footer className="seance-bottom">
<div className="seance-transcript-wrap">
<div className="seance-transcript-title">
<span>{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
{t('seance.anomaly.count', { count: state.anomalyCount })}
</span>
</div>
<Transcript
entries={state.transcript}
streaming={state.replyStreaming}
speakingId={state.speakingId}
onReplay={playUtterance}
/>
</div>
<form className="seance-contact" onSubmit={submitQuestion}>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={t('seance.askPlaceholder')}
disabled={streaming}
maxLength={280}
aria-label={t('seance.ask')}
/>
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
{t('seance.ask')}
</button>
<button
type="button"
className="summon-btn"
onClick={summon}
disabled={state.status === 'summoning'}
>
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
</button>
</form>
</footer>
{/* ---- toasts ---- */}
<div className="seance-toasts" aria-live="polite">
{state.toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</div>
{/* ---- CRT overlays (above everything, never interactive) ---- */}
<div className="seance-scanlines" aria-hidden />
<div className="seance-vignette" aria-hidden />
</div>
)
}
// ---- toasts ----
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
const { t } = useTranslation()
useEffect(() => {
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
return () => window.clearTimeout(id)
}, [toast.id, onDismiss])
return (
<div className="seance-toast" role="alert">
<span className="toast-glyph" aria-hidden>
⚠
</span>
<span className="toast-msg">
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
</span>
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label={t('common.dismiss')}
>
×
</button>
</div>
)
}
// ---- wire panel: raw line telemetry ----
function WirePanel() {
const { state } = useSeance()
const { t } = useTranslation()
return (
<div className="wire-panel">
<TelemetryReadout telemetry={state.telemetry} />
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
{!state.passive && (
<p className="wire-hint dim">
{t('seance.wire.hint', {
defaultValue:
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
})}
</p>
)}
</div>
)
}
// ---- EVP panel: microphone voice-band listening ----
function EvpPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [bandDb, setBandDb] = useState<number | null>(null)
const listenerRef = useRef<EvpListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastMeterRef = useRef(0)
// Release the microphone when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) void l.stop()
},
[],
)
const drawScope = (db: Float32Array) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const n = db.length
const step = Math.max(1, Math.floor(n / 160))
const bw = w / (n / step)
for (let i = 0, x = 0; i < n; i += step, x++) {
const v = db[i] // dB, roughly -160..0
const norm = Math.max(0, Math.min(1, (v + 110) / 80))
const bh = Math.max(1, norm * h)
ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})`
ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh)
}
}
const start = async () => {
if (pending || listening) return
setDenied(false)
setPending(true)
const listener = listenerRef.current ?? new EvpListener()
listenerRef.current = listener
try {
await listener.start({
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
onFrame: (db) => {
drawScope(db)
const now = performance.now()
if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
const n = db.length
const lo = Math.floor((300 / 24000) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
let peak = -Infinity
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
setBandDb(Number.isFinite(peak) ? peak : null)
}
},
})
if (listenerRef.current === listener) setListening(true)
} catch {
if (listenerRef.current === listener) listenerRef.current = null
setDenied(true)
setBandDb(null)
} finally {
setPending(false)
}
}
const stop = async () => {
const l = listenerRef.current
listenerRef.current = null
if (l) await l.stop()
setListening(false)
setBandDb(null)
}
return (
<div className="evp-panel">
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
<div className="evp-readout">
<span className="dim">{t('seance.evp.band')}</span>
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
</div>
{listening && (
<p className="evp-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.evp.listening')}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.evp.deniedTitle')}</h4>
<p>{t('seance.evp.denied')}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (listening ? stop() : start())}
disabled={pending}
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
</div>
)
}
// ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
// WebUSB hides itself entirely outside secure ground (https / localhost).
const secure = useMemo(() => isSecureContext(), [])
const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null)
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTuneUiRef = useRef(0)
// Power down the dongle on unmount (mode switch / leaving the page).
useEffect(
() => () => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
},
[],
)
const drawWaterfallRow = (db: Float64Array) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Scroll the existing waterfall down one row, paint the new one on top.
ctx.drawImage(canvas, 0, 1)
const n = db.length
const bw = w / n
for (let i = 0; i < n; i++) {
const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70))
ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)`
ctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
}
}
const stop = useCallback(() => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
setPhase('idle')
setTunedMhz(null)
}, [])
const start = async () => {
if (!supported || sdrRef.current || phase === 'busy') return
setError(null)
setPhase('busy')
const sdr = new RtlSdr()
try {
await sdr.requestDevice()
await sdr.open()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const name = err instanceof DOMException ? err.name : ''
// NotFoundError: the seeker dismissed the picker — a gentle nudge only.
// Security/Network/claim failures: something else is holding the
// dongle (kernel DVB driver, missing WinUSB driver) — troubleshoot.
setError(
name === 'NotFoundError' || /no device selected/i.test(msg)
? { kind: 'cancelled' }
: name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
? { kind: 'claim' }
: { kind: 'other', message: msg },
)
setPhase('idle')
void sdr.close()
return
}
sdrRef.current = sdr
detectorRef.current = new SpectrumAnomalyDetector()
setPhase('sweeping')
void sdr
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
onSpectrum: (centerHz, db) => {
if (sdrRef.current !== sdr) return
drawWaterfallRow(db)
const now = performance.now()
if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now
setTunedMhz(centerHz / 1e6)
}
const det = detectorRef.current
if (det) {
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
if (a) sendAnomaly('radio', a.frequency, a.magnitude)
}
},
onError: (err) => {
if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
},
})
.then(() => {
if (sdrRef.current === sdr) setPhase('idle')
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError({ kind: 'other', message: err instanceof Error ? err.message : String(err) })
setPhase('idle')
}
})
}
if (!secure) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>
{t('seance.radio.insecure', {
defaultValue:
'the radio dead only speak through consecrated ground — open this site via its https address.',
})}
</p>
<p className="dim troubleshoot">
{t('seance.radio.insecureHint', {
defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
if (!supported) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>{t('seance.radio.unsupported')}</p>
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
return (
<div className="radio-panel">
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
<div className="radio-readout">
{tunedMhz !== null ? (
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
) : (
<span className="dim">—</span>
)}
{phase === 'sweeping' && (
<span className="dim flicker">
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
</span>
)}
</div>
{error?.kind === 'cancelled' && (
<div className="panel-notice">
<p>{t('seance.radio.noDevice')}</p>
</div>
)}
{error?.kind === 'claim' && (
<div className="panel-notice error">
<h4>{t('seance.radio.claimFailed')}</h4>
<p>
{t('seance.radio.claimFix', {
defaultValue:
'linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.',
})}
</p>
<button type="button" className="panel-subaction" onClick={() => void start()}>
{t('seance.radio.tryAgain', { defaultValue: 'try again' })}
</button>
</div>
)}
{error?.kind === 'other' && (
<div className="panel-notice error">
<h4>{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}</h4>
<p>{error.message}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (phase === 'sweeping' ? stop() : start())}
disabled={phase === 'busy'}
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
</div>
)
}
// ---- ouija panel: what the planchette is doing ----
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
const { t } = useTranslation()
const word = snapshot?.word ?? null
return (
<div className="ouija-panel">
{word ? (
<div className="ouija-spelling">
<span className="dim ouija-spelling-label">
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
</span>
<div className="ouija-word" aria-live="polite">
{word.split('').map((ch, i) => (
<span
key={i}
className={
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
}
>
{ch}
</span>
))}
</div>
</div>
) : (
<p className="dim ouija-drift">
{t('seance.ouija.drift', {
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
})}
</p>
)}
{snapshot && snapshot.spelled.length > 0 && (
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
)}
</div>
)
}
// ---- EMF panel: the phone becomes a field meter ----
/** Highest field strength the gauge renders before pinning the needle. */
const EMF_GAUGE_MAX = 6
function EmfPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [field, setField] = useState<number | null>(null)
const [baseline, setBaseline] = useState<number | null>(null)
const listenerRef = useRef<EmfSensorListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
// Drop the sensor listeners when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) l.stop()
},
[],
)
const drawGauge = (strength: number) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const cx = w / 2
const cy = h - 14
const r = Math.min(w / 2 - 14, h - 30)
const norm = Math.max(0, Math.min(1, strength / EMF_GAUGE_MAX))
const hue = 150 + norm * 122 // phosphor at rest → violet when it surges
// Track arc.
ctx.lineWidth = 10
ctx.lineCap = 'round'
ctx.strokeStyle = 'rgba(124, 255, 178, 0.12)'
ctx.beginPath()
ctx.arc(cx, cy, r, Math.PI, 2 * Math.PI)
ctx.stroke()
// Lit arc.
if (norm > 0.004) {
ctx.strokeStyle = `hsla(${hue} 90% 68% / 0.92)`
ctx.shadowColor = `hsla(${hue} 90% 68% / 0.8)`
ctx.shadowBlur = 12
ctx.beginPath()
ctx.arc(cx, cy, r, Math.PI, Math.PI + norm * Math.PI)
ctx.stroke()
ctx.shadowBlur = 0
}
// Tick marks.
ctx.lineWidth = 1
ctx.strokeStyle = 'rgba(211, 233, 219, 0.25)'
for (let i = 0; i <= 8; i++) {
const a = Math.PI + (i / 8) * Math.PI
ctx.beginPath()
ctx.moveTo(cx + Math.cos(a) * (r - 10), cy + Math.sin(a) * (r - 10))
ctx.lineTo(cx + Math.cos(a) * (r - 15), cy + Math.sin(a) * (r - 15))
ctx.stroke()
}
// Needle.
const ang = Math.PI + norm * Math.PI
ctx.lineWidth = 2
ctx.strokeStyle = `hsla(${hue} 85% 78% / 0.95)`
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(ang) * (r - 20), cy + Math.sin(ang) * (r - 20))
ctx.stroke()
ctx.fillStyle = '#7cffb2'
ctx.beginPath()
ctx.arc(cx, cy, 3, 0, 2 * Math.PI)
ctx.fill()
}
// Paint the resting needle once (and whenever support is re-checked).
useEffect(() => {
if (supported) drawGauge(0)
}, [supported])
const start = async () => {
if (pending || listening || !supported) return
setDenied(false)
setPending(true)
const listener = listenerRef.current ?? new EmfSensorListener()
listenerRef.current = listener
try {
// iOS gates the senses behind an explicit, gesture-triggered grant.
if (EmfSensorListener.needsPermission()) {
const granted = await EmfSensorListener.requestPermission()
if (!granted) {
if (listenerRef.current === listener) setDenied(true)
return
}
}
if (listenerRef.current !== listener) return // unmounted mid-permission
listener.start({
onAnomaly: (a) => sendAnomaly('emf', a.frequency, a.magnitude),
onReading: (strength, base) => {
drawGauge(strength)
setField(strength)
setBaseline(base)
},
})
setListening(true)
} finally {
setPending(false)
}
}
const stop = () => {
const l = listenerRef.current
if (l) l.stop()
setListening(false)
setField(null)
setBaseline(null)
drawGauge(0)
}
const calibrate = () => {
listenerRef.current?.reset()
}
if (!supported) {
return (
<div className="panel-notice">
<h4>
{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}
</h4>
<p>
{t('seance.emf.unsupported', {
defaultValue:
'a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
</div>
)
}
return (
<div className="emf-panel">
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
<div className="emf-readout">
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
<span className="emf-baseline dim">
{t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '}
{baseline !== null ? baseline.toFixed(2) : '—'}
</span>
</div>
{listening && (
<p className="emf-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}</h4>
<p>
{t('seance.emf.denied', {
defaultValue:
'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.',
})}
</p>
</div>
)}
<div className="emf-actions">
<button
type="button"
className="panel-action"
onClick={() => (listening ? stop() : void start())}
disabled={pending}
>
{listening
? t('seance.emf.stop', { defaultValue: 'ground the meter' })
: t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
<button
type="button"
className="panel-subaction"
onClick={calibrate}
disabled={!listening}
>
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
</button>
</div>
</div>
)
}