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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
frontend/src/components/HauntingLayer.css
Normal file
145
frontend/src/components/HauntingLayer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
197
frontend/src/components/HauntingLayer.tsx
Normal file
197
frontend/src/components/HauntingLayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)`
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user