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

@@ -43,7 +43,7 @@ export function adaptiveMax(values: number[], floor: number): number {
return max * 1.15
}
/** Marker color per anomaly source: wire green, evp violet, radio orange. */
/** Marker color per anomaly source: wire green, evp violet, radio orange, emf amber. */
export function anomalyColor(source: AnomalyEntry['source']): string {
switch (source) {
case 'wire':
@@ -52,6 +52,8 @@ export function anomalyColor(source: AnomalyEntry['source']): string {
return '#b26bff'
case 'radio':
return '#ff9d4a'
case 'emf':
return '#ffd24a'
}
}

View File

@@ -0,0 +1,145 @@
/* HauntingLayer — the possession overlay. Sits just under the global CRT
glass (z-index 9999) so scanlines/vignette texture the apparitions.
Tokens: bg #07070d · phosphor #7cffb2 · violet #b26bff · mono 'IBM Plex Mono'. */
.haunting-layer {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 9998;
}
/* Breathing vignette — quickens as the idle escalator rises. */
.haunt-vignette {
position: absolute;
inset: -4%;
background: radial-gradient(
ellipse at center,
transparent 46%,
rgba(0, 0, 0, 0.62) 100%
);
opacity: 0;
transition: opacity 5s ease;
animation: haunt-breathe 9s ease-in-out infinite;
}
.haunting-layer--awake .haunt-vignette {
opacity: 0.55;
}
@keyframes haunt-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.045);
}
}
/* Manifest — the full-screen CRT tear. Duration is set inline per event. */
.haunt-manifest {
position: absolute;
inset: 0;
opacity: 0;
background:
repeating-linear-gradient(
to bottom,
rgba(124, 255, 178, 0.055) 0 1px,
transparent 1px 3px
),
radial-gradient(
ellipse at 50% 42%,
rgba(178, 107, 255, 0.12),
rgba(7, 7, 13, 0.6) 72%
);
mix-blend-mode: screen;
animation: haunt-manifest 1s steps(2, jump-none) both;
}
@keyframes haunt-manifest {
0% {
opacity: 0;
transform: translateY(0);
}
7% {
opacity: 0.85;
transform: translateY(-3px);
}
13% {
opacity: 0.12;
transform: translateY(1px);
}
21% {
opacity: 0.65;
transform: translateY(2px) skewX(0.4deg);
}
29% {
opacity: 0.05;
}
37% {
opacity: 0.5;
transform: none;
}
55% {
opacity: 0.1;
transform: translateX(-2px);
}
72% {
opacity: 0.35;
transform: translateX(1px);
}
100% {
opacity: 0;
transform: none;
}
}
/* Whispers — fleeting words that surface and dissolve in a blur. */
.haunt-whisper {
position: absolute;
transform: translate(-50%, -50%);
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 0.85rem;
letter-spacing: 0.4em;
white-space: nowrap;
color: rgba(214, 228, 220, 0.92);
text-shadow:
0 0 14px rgba(124, 255, 178, 0.75),
0 0 34px rgba(178, 107, 255, 0.5);
animation: haunt-whisper-fade 2.6s ease-out both;
}
@keyframes haunt-whisper-fade {
0% {
opacity: 0;
filter: blur(7px);
}
24% {
opacity: 0.9;
filter: blur(1.5px);
}
68% {
opacity: 0.55;
filter: blur(2.5px);
}
100% {
opacity: 0;
filter: blur(9px);
}
}
/* Reduced motion: no flicker at all — the layer stays a static vignette. */
@media (prefers-reduced-motion: reduce) {
.haunt-vignette,
.haunt-manifest,
.haunt-whisper {
animation: none !important;
}
.haunt-manifest,
.haunt-whisper {
display: none;
}
}

View File

@@ -0,0 +1,197 @@
// HauntingLayer — the possession overlay, mounted once at the app root.
// It makes the browser itself feel haunted: full-screen CRT manifest
// flashes, fleeting whisper-words drifting across the glass, a vignette
// that breathes faster the longer the seeker sits idle, an ambient audio
// dread-bed (gesture-armed, autoplay-safe) and occasional document.title
// glitches. Nothing stirs during the first ten seconds after mount — the
// haunting creeps in, it never jumpscares on arrival. Everything is
// jittered, everything unsubscribes/cancels on unmount, and under
// prefers-reduced-motion there is no flicker and no title meddling.
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
HauntBed,
IdleEscalator,
flickerTitle,
hauntBus,
pickWhisperWord,
} from '../lib/haunting'
import './HauntingLayer.css'
/** The grace hush: nothing visible or audible before this elapses. */
const GRACE_MS = 10_000
/** How long a whisper lingers before dissolving (matches the CSS fade). */
const WHISPER_LIFE_MS = 2_600
/** Cap on simultaneous whispers so the glass never gets crowded. */
const WHISPER_CAP = 6
const MANIFEST_RANGE: readonly [number, number] = [120_000, 240_000] // every 2–4 min
const WHISPER_RANGE: readonly [number, number] = [20_000, 50_000]
const FLARE_RANGE: readonly [number, number] = [45_000, 90_000]
type Whisper = { id: number; word: string; x: number; y: number }
type Manifest = { id: number; durationMs: number }
function jitter([min, max]: readonly [number, number]): number {
return min + Math.random() * (max - min)
}
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
}
/** Vignette breathing period per idle level — quicker as dread escalates. */
function breatheMs(level: number): number {
return level >= 2 ? 3_200 : level === 1 ? 5_500 : 9_000
}
export function HauntingLayer() {
const { t } = useTranslation()
const [awake, setAwake] = useState(false)
const [idleLevel, setIdleLevel] = useState(0)
const [whispers, setWhispers] = useState<Whisper[]>([])
const [manifest, setManifest] = useState<Manifest | null>(null)
const idRef = useRef(0)
// Whisper words + glitch titles, re-read each render so a language
// switch mid-session is honored without re-arming the haunting.
const words = [
t('haunting.whisper.behindYou', { defaultValue: 'behind you' }),
t('haunting.whisper.cold', { defaultValue: 'cold' }),
t('haunting.whisper.stay', { defaultValue: 'stay' }),
t('haunting.whisper.seesYou', { defaultValue: 'it sees you' }),
t('haunting.whisper.closer', { defaultValue: 'closer' }),
t('haunting.whisper.stillHere', { defaultValue: 'still here' }),
t('haunting.whisper.lineListening', { defaultValue: 'the line is listening' }),
t('haunting.whisper.dontLook', { defaultValue: "don't look" }),
t('haunting.whisper.beneathStatic', { defaultValue: 'beneath the static' }),
t('haunting.whisper.knockTwice', { defaultValue: 'knock twice' }),
]
const titles = [
t('haunting.title.seesYou', { defaultValue: 'QUANTUMANCY — it sees you' }),
t('haunting.title.behindYou', { defaultValue: 'QUANTUM▓NCY — behind you' }),
t('haunting.title.listening', { defaultValue: '…listening…' }),
]
const wordsRef = useRef(words)
wordsRef.current = words
const titlesRef = useRef(titles)
titlesRef.current = titles
useEffect(() => {
const reduced = prefersReducedMotion()
const escalator = new IdleEscalator()
const bed = new HauntBed()
const timers = new Set<ReturnType<typeof setTimeout>>()
let disposed = false
// Tracked timeout: auto-cleared on unmount, guarded against late fires.
const later = (fn: () => void, ms: number) => {
const id = setTimeout(() => {
timers.delete(id)
if (!disposed) fn()
}, ms)
timers.add(id)
}
// A self-rescheduling jittered loop: fires, then re-arms in [min, max].
const loop = (range: readonly [number, number], fn: () => void) => {
const tick = () => {
fn()
later(tick, jitter(range))
}
later(tick, jitter(range))
}
// The seeker's restlessness resets the idle clock.
const onActivity = () => escalator.notifyActivity()
const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const
for (const ev of ACTIVITY_EVENTS) window.addEventListener(ev, onActivity, { passive: true })
// Render reactions to the shared bus (our loops below, or anyone else).
const offManifest = hauntBus.on('manifest', ({ durationMs }) => {
idRef.current += 1
setManifest({ id: idRef.current, durationMs })
})
const offWhisper = hauntBus.on('whisper', ({ word, x, y }) => {
if (!word) return
idRef.current += 1
const id = idRef.current
setWhispers((ws) => [...ws.slice(-(WHISPER_CAP - 1)), { id, word, x, y }])
later(() => setWhispers((ws) => ws.filter((w) => w.id !== id)), WHISPER_LIFE_MS + 250)
})
// The dread-bed waits for a gesture; it starts only after the hush.
bed.armOnFirstGesture()
let stopTitle: (() => void) | null = null
const awaken = () => {
setAwake(true)
bed.start()
if (reduced) return // no flicker, no title changes under reduced motion
stopTitle = flickerTitle({ titles: () => titlesRef.current })
loop(MANIFEST_RANGE, () =>
hauntBus.emit('manifest', { durationMs: 700 + Math.random() * 900 }),
)
loop(WHISPER_RANGE, () => {
const word = pickWhisperWord(wordsRef.current, Math.random)
if (!word) return
hauntBus.emit('whisper', {
word,
x: 0.15 + Math.random() * 0.7,
y: 0.2 + Math.random() * 0.55,
})
})
loop(FLARE_RANGE, () =>
hauntBus.emit('flare', { strength: 0.3 + Math.random() * 0.7 }),
)
}
later(awaken, GRACE_MS)
// Idle polling: pace the vignette, feed the bed's intensity (level 0..2).
const idlePoll = window.setInterval(() => {
const level = escalator.level()
setIdleLevel(level)
bed.setIntensity(level / 2)
}, 4_000)
return () => {
disposed = true
for (const id of timers) clearTimeout(id)
timers.clear()
window.clearInterval(idlePoll)
offManifest()
offWhisper()
for (const ev of ACTIVITY_EVENTS) window.removeEventListener(ev, onActivity)
stopTitle?.()
bed.stop()
}
}, [])
return (
<div className={`haunting-layer${awake ? ' haunting-layer--awake' : ''}`} aria-hidden="true">
<div className="haunt-vignette" style={{ animationDuration: `${breatheMs(idleLevel)}ms` }} />
{manifest && (
<div
key={manifest.id}
className="haunt-manifest"
style={{ animationDuration: `${manifest.durationMs}ms` }}
onAnimationEnd={() => setManifest(null)}
/>
)}
{whispers.map((w) => (
<span
key={w.id}
className="haunt-whisper"
style={{ left: `${w.x * 100}%`, top: `${w.y * 100}%` }}
>
{w.word}
</span>
))}
</div>
)
}

View File

@@ -5,6 +5,7 @@
import { useEffect, useRef } from 'react'
import { PlanchetteMachine } from '../lib/planchette'
import { armAudioOnFirstGesture, createTick, getAudioContextCtor } from '../lib/haunting'
type Point = { x: number; y: number }
@@ -95,6 +96,21 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
let initialized = false
const smoke: Smoke[] = []
// Spirit knock: an AudioContext created lazily on the first gesture
// (autoplay policy); a soft tick sounds as each letter is dwelled upon.
let tickCtx: AudioContext | null = null
let lastKnock: string | null = null
const disarmTick = armAudioOnFirstGesture(() => {
const AC = getAudioContextCtor()
if (!AC) return
try {
tickCtx = new AC()
if (tickCtx.state === 'suspended') void tickCtx.resume().catch(() => undefined)
} catch {
tickCtx = null
}
})
const resize = () => {
const parent = canvas.parentElement
if (!parent) return
@@ -136,6 +152,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
const accent = `hsl(${hueDeg} 90% 72%)`
const t = now / 1000
// Spirit knock on each newly dwelled letter (word:index identity so
// repeated letters in a word each knock).
const knockKey =
snap.phase === 'dwelling' && snap.current ? `${snap.word ?? ''}:${snap.index}` : null
if (knockKey && knockKey !== lastKnock && tickCtx && tickCtx.state === 'running') {
createTick(tickCtx)
}
lastKnock = knockKey
// --- target: spelled letter, else idle wander ---
let target: Point | null = null
if (snap.current) target = letterTarget(snap.current)
@@ -209,13 +234,14 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
// Letters + numbers.
const hovered = snap.current
const dwelling = snap.phase === 'dwelling' ? snap.current : null
ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
for (const [ch, p] of layout.letters) {
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg)
}
ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
for (const [ch, p] of layout.numbers) {
drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg)
}
// Smoke (additive).
@@ -262,6 +288,8 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
disarmTick()
if (tickCtx && tickCtx.state !== 'closed') void tickCtx.close().catch(() => undefined)
}
}, [])
@@ -294,19 +322,26 @@ function drawGlyph(
ch: string,
p: Point,
hovered: boolean,
dwell: boolean,
accent: string,
hueDeg: number,
): void {
if (hovered) {
ctx.save()
if (dwell) {
// Dwelled letter: bright glow, swollen in place — the spirit's finger.
ctx.translate(p.x, p.y)
ctx.scale(1.22, 1.22)
ctx.translate(-p.x, -p.y)
}
ctx.shadowColor = accent
ctx.shadowBlur = 16
ctx.shadowBlur = dwell ? 28 : 16
ctx.fillStyle = accent
ctx.fillText(ch, p.x, p.y)
ctx.restore()
ctx.beginPath()
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)`
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2)
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / ${dwell ? 0.85 : 0.5})`
ctx.arc(p.x, p.y, dwell ? 19 : 15, 0, Math.PI * 2)
ctx.stroke()
} else {
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`

View File

@@ -10,9 +10,11 @@ export type TranscriptProps = {
}
function fmtFreq(source: string, f: number): string {
// EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
// EVP reports Hz (voice band); radio reports MHz (FM band); emf reports a
// field-strength estimate; wire is ambient.
if (source === 'evp') return `${f.toFixed(0)}Hz`
if (source === 'radio') return `${f.toFixed(1)}MHz`
if (source === 'emf') return `field ${f.toFixed(2)}`
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
}