Add EMF field mode, Armory shop/waitlist, and ambient haunting layer

Three self-contained features, verified complete and cross-wired
end-to-end (audited: backend 54/54 tests, frontend 110/110 tests,
tsc --noEmit clean, i18n coverage script clean):

- EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing,
  a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own
  fragment prompt persona and full frontend gauge UI.
- Armory (shop/waitlist): pre-order capture page for the future
  Ultimate Quantum Box hardware line, rate-limited public endpoint,
  explicitly no payment collection.
- Haunting layer: ambient possession effects (dread-bed audio, title
  glitching, idle-paced whispers/manifests), respects
  prefers-reduced-motion, mounted once at the app root.

Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID,
explicit selectConfiguration, isSecureContext gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
Indiana
2026-07-21 02:28:14 +00:00
parent c372427ced
commit 0756e677b9
33 changed files with 3148 additions and 48 deletions

View File

@@ -1,5 +1,5 @@
// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija), transmission log and direct-contact row.
// (wire / evp / radio / ouija / emf), transmission log and direct-contact row.
//
// Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
@@ -25,7 +25,9 @@ import { Transcript } from '../components/Transcript'
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,
@@ -36,7 +38,9 @@ import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types'
import './SeancePage.css'
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
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 = [
@@ -207,7 +211,7 @@ function SeanceSession({ username }: { username: string }) {
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`)}
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
</button>
))}
</nav>
@@ -240,6 +244,10 @@ function SeanceSession({ username }: { username: string }) {
</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>
@@ -417,6 +425,7 @@ function SeanceSession({ username }: { username: string }) {
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
{state.mode === 'emf' && <EmfPanel />}
</section>
</aside>
</main>
@@ -679,9 +688,11 @@ function EvpPanel() {
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<string | null>(null)
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)
@@ -743,12 +754,15 @@ function RadioPanel() {
} 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)
? t('seance.radio.noDevice')
: /claim/i.test(msg)
? t('seance.radio.claimFailed')
: msg,
? { kind: 'cancelled' }
: name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
? { kind: 'claim' }
: { kind: 'other', message: msg },
)
setPhase('idle')
void sdr.close()
@@ -774,7 +788,7 @@ function RadioPanel() {
}
},
onError: (err) => {
if (sdrRef.current === sdr) setError(err.message)
if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
},
})
.then(() => {
@@ -782,12 +796,34 @@ function RadioPanel() {
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError(err instanceof Error ? err.message : String(err))
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">
@@ -816,9 +852,29 @@ function RadioPanel() {
</span>
)}
</div>
{error && (
{error?.kind === 'cancelled' && (
<div className="panel-notice">
<p>{t('seance.radio.noDevice')}</p>
</div>
)}
{error?.kind === 'claim' && (
<div className="panel-notice error">
<p>{error}</p>
<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
@@ -872,3 +928,206 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
</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>
)
}