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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user