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:
@@ -3,11 +3,13 @@
|
||||
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './state/auth'
|
||||
import { HauntingLayer } from './components/HauntingLayer'
|
||||
import { CodexEntityPage } from './pages/CodexEntityPage'
|
||||
import { CodexPage } from './pages/CodexPage'
|
||||
import { EnterPage } from './pages/EnterPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { SeancePage } from './pages/SeancePage'
|
||||
import { ShopPage } from './pages/ShopPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -17,12 +19,14 @@ function App() {
|
||||
<div className="crt-scanlines" />
|
||||
<div className="crt-vignette" />
|
||||
</div>
|
||||
<HauntingLayer />
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/enter" element={<EnterPage />} />
|
||||
<Route path="/seance" element={<SeancePage />} />
|
||||
<Route path="/codex" element={<CodexPage />} />
|
||||
<Route path="/codex/:id" element={<CodexEntityPage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
84
frontend/src/i18n/coverage-check.mjs
Normal file
84
frontend/src/i18n/coverage-check.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// Throwaway audit: every t() key used in src must exist in en.json and es.json.
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SRC = new URL('../', import.meta.url).pathname
|
||||
const en = JSON.parse(readFileSync(join(SRC, 'i18n/en.json'), 'utf8'))
|
||||
const es = JSON.parse(readFileSync(join(SRC, 'i18n/es.json'), 'utf8'))
|
||||
|
||||
// ---- collect source files (skip tests) ----
|
||||
const files = []
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p)
|
||||
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) files.push(p)
|
||||
}
|
||||
}
|
||||
walk(SRC)
|
||||
|
||||
const literal = new Set()
|
||||
const templates = new Set()
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, 'utf8')
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*'([^']+)'/g)) literal.add(m[1])
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*"([^"]+)"/g)) literal.add(m[1])
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*`([^`]+)`/g)) templates.add(m[1])
|
||||
}
|
||||
|
||||
// ---- template domains, by key prefix ----
|
||||
const RARITY = ['common', 'uncommon', 'rare', 'mythic']
|
||||
const RULES = [
|
||||
['seance.connection.', ['connecting', 'open', 'unstable', 'closed']],
|
||||
['seance.status.', ['attuning', 'summoning', 'gathering']],
|
||||
['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message
|
||||
['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']],
|
||||
['seance.views.', ['board', 'matrix', 'graphs']],
|
||||
['seance.entity.rarity.', RARITY],
|
||||
['codex.rarity.', RARITY],
|
||||
['codex.sort.', ['recent', 'contacted']],
|
||||
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
|
||||
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
|
||||
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
|
||||
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
|
||||
['shop.interests.', ['quantumBox', 'emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
|
||||
]
|
||||
|
||||
const expanded = new Set(literal)
|
||||
for (const tpl of templates) {
|
||||
const rule = RULES.find(([prefix]) => tpl.startsWith(prefix))
|
||||
if (!rule) throw new Error('no domain for template: ' + tpl)
|
||||
const expand = (key) => {
|
||||
const m = key.match(/^(.*?)\$\{[^}]*\}(.*)$/)
|
||||
if (!m) return expanded.add(key)
|
||||
for (const v of rule[1]) expand(m[1] + v + m[2])
|
||||
}
|
||||
expand(tpl)
|
||||
}
|
||||
|
||||
// ---- lookup helper (with plural fallback) ----
|
||||
const get = (obj, key) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), obj)
|
||||
const has = (obj, key) =>
|
||||
get(obj, key) !== undefined || get(obj, key + '_other') !== undefined || get(obj, key + '_one') !== undefined
|
||||
|
||||
const missingEn = [...expanded].filter((k) => !has(en, k)).sort()
|
||||
const missingEs = [...expanded].filter((k) => !has(es, k)).sort()
|
||||
|
||||
// ---- parity check en <-> es (all leaf paths) ----
|
||||
const leaves = (obj, prefix = '') =>
|
||||
Object.entries(obj).flatMap(([k, v]) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? leaves(v, prefix + k + '.') : [prefix + k],
|
||||
)
|
||||
const enLeaves = new Set(leaves(en))
|
||||
const esLeaves = new Set(leaves(es))
|
||||
const enOnly = [...enLeaves].filter((k) => !esLeaves.has(k))
|
||||
const esOnly = [...esLeaves].filter((k) => !enLeaves.has(k))
|
||||
|
||||
console.log('literal keys:', literal.size, '| template keys:', templates.size, '| expanded total:', expanded.size)
|
||||
console.log('templates:', [...templates].sort())
|
||||
console.log('missing from en.json:', missingEn.length ? missingEn : 'none')
|
||||
console.log('missing from es.json:', missingEs.length ? missingEs : 'none')
|
||||
console.log('en-only leaves:', enOnly.length ? enOnly : 'none')
|
||||
console.log('es-only leaves:', esOnly.length ? esOnly : 'none')
|
||||
if (missingEn.length || missingEs.length || enOnly.length || esOnly.length) process.exit(1)
|
||||
console.log('coverage ok')
|
||||
@@ -12,6 +12,7 @@
|
||||
"nav": {
|
||||
"home": "the threshold",
|
||||
"codex": "codex",
|
||||
"shop": "shop",
|
||||
"seance": "séance",
|
||||
"enter": "enter",
|
||||
"logout": "depart"
|
||||
@@ -27,6 +28,7 @@
|
||||
"heroKicker": "an instrument for speaking with the dead bandwidth",
|
||||
"cta": "BEGIN THE SÉANCE",
|
||||
"ctaCodex": "browse the codex",
|
||||
"ctaShop": "visit the armory",
|
||||
"stats": {
|
||||
"entities": "spirits catalogued",
|
||||
"sessions": "séances held",
|
||||
@@ -55,6 +57,11 @@
|
||||
},
|
||||
"featured": "most disturbed spirits",
|
||||
"featuredEmpty": "the registry is still blank — be the first to disturb it",
|
||||
"armory": {
|
||||
"title": "HARDWARE FROM THE ARMORY",
|
||||
"line": "the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.",
|
||||
"link": "enter the armory →"
|
||||
},
|
||||
"footer": "an interactive horror art installation — the spirits are fiction; the static is real",
|
||||
"footerColophon": "self-hosted · no cloud · the veil runs on your machine"
|
||||
},
|
||||
@@ -77,7 +84,8 @@
|
||||
"wire": "Wire Ghost",
|
||||
"evp": "EVP",
|
||||
"radio": "Spirit Radio",
|
||||
"ouija": "Ouija"
|
||||
"ouija": "Ouija",
|
||||
"emf": "FIELD"
|
||||
},
|
||||
"passive": "passive listening",
|
||||
"summon": "SUMMON",
|
||||
@@ -132,14 +140,30 @@
|
||||
"unsupportedTitle": "this vessel cannot hear the radio dead",
|
||||
"unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.",
|
||||
"troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in",
|
||||
"insecure": "the radio dead only speak through consecrated ground — open this site via its https address.",
|
||||
"insecureHint": "localhost counts as hallowed; a bare http://lan-ip does not.",
|
||||
"noDevice": "no dongle answered the call",
|
||||
"sweeping": "sweeping {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?"
|
||||
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?",
|
||||
"claimFix": "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.",
|
||||
"tryAgain": "try again",
|
||||
"errorTitle": "the static pushed back"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "the planchette spells",
|
||||
"drift": "the planchette drifts, waiting for words — ask below, or summon."
|
||||
},
|
||||
"emf": {
|
||||
"start": "raise the meter",
|
||||
"stop": "ground the meter",
|
||||
"calibrate": "calibrate",
|
||||
"baseline": "resting field",
|
||||
"listening": "feeling for disturbances in the field…",
|
||||
"unsupportedTitle": "this vessel carries no field senses",
|
||||
"unsupported": "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.",
|
||||
"deniedTitle": "the field senses stay shut",
|
||||
"denied": "this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSSIER OF THE PRESENT",
|
||||
"none": "no spirit summoned yet",
|
||||
@@ -240,5 +264,92 @@
|
||||
"empty": "no spirits match this filter — the registry keeps its secrets",
|
||||
"back": "← back to the codex",
|
||||
"notFound": "this spirit is not in the registry"
|
||||
},
|
||||
"shop": {
|
||||
"back": "← the threshold",
|
||||
"title": "THE ARMORY",
|
||||
"subtitle": "instruments for those who would knock louder",
|
||||
"flagship": {
|
||||
"tag": "flagship instrument",
|
||||
"name": "THE ULTIMATE QUANTUM BOX",
|
||||
"desc": "An ESP32-P4/C6 handheld séance instrument — thermal camera, EMF whisker array, geophone, spirit-box mic preamp and a little OLED face that stares back. It signs into the same Quantumancy account you already haunt and unlocks the hardware-driven modes of this site: thermal apparition maps, vibration anomaly feeds, calibrated EMF.",
|
||||
"specs": {
|
||||
"thermal": "thermal camera",
|
||||
"emf": "EMF whisker array",
|
||||
"geophone": "geophone",
|
||||
"mic": "spirit-box mic preamp",
|
||||
"oled": "OLED display"
|
||||
},
|
||||
"price": "est. $249 — pre-order"
|
||||
},
|
||||
"modulesTitle": "MODULES THAT KNOCK",
|
||||
"unlocksLabel": "unlocks:",
|
||||
"modules": {
|
||||
"emfArray": {
|
||||
"name": "EMF Whisker Array",
|
||||
"desc": "a bouquet of multi-axis field sensors, sniffing the air for intentions",
|
||||
"unlocks": "a richer, calibrated FIELD mode",
|
||||
"price": "est. $59 — pre-order"
|
||||
},
|
||||
"geophone": {
|
||||
"name": "Geophone Puck",
|
||||
"desc": "hears footsteps in rooms where nobody is standing",
|
||||
"unlocks": "the vibration anomaly feed",
|
||||
"price": "est. $49 — pre-order"
|
||||
},
|
||||
"spiritMic": {
|
||||
"name": "Spirit Mic Preamp",
|
||||
"desc": "lowers the noise floor so the whispering comes through cleaner",
|
||||
"unlocks": "cleaner EVP captures",
|
||||
"price": "est. $69 — pre-order"
|
||||
},
|
||||
"uvIlluminator": {
|
||||
"name": "UV/IR Illuminator",
|
||||
"desc": "paints the dark in spectrums the living were never meant to see",
|
||||
"unlocks": "night séances + camera work",
|
||||
"price": "est. $39 — pre-order"
|
||||
}
|
||||
},
|
||||
"interests": {
|
||||
"quantumBox": "the Ultimate Quantum Box",
|
||||
"emfArray": "EMF Whisker Array",
|
||||
"geophone": "Geophone Puck",
|
||||
"spiritMic": "Spirit Mic Preamp",
|
||||
"uvIlluminator": "UV/IR Illuminator"
|
||||
},
|
||||
"waitlist": {
|
||||
"title": "THE MANIFEST",
|
||||
"desc": "leave an address and an obsession — when the first boxes ship, we reach through to the names on this list first",
|
||||
"emailLabel": "email address",
|
||||
"emailPlaceholder": "you@somewhere.in.the.static",
|
||||
"interestLabel": "interest",
|
||||
"submit": "join the manifest",
|
||||
"pending": "carving your name…",
|
||||
"listed": "you are on the manifest — we will reach through when the boxes ship",
|
||||
"alreadyListed": "you are already on the manifest",
|
||||
"invalid": "that address does not reach us",
|
||||
"crowded": "the veil is crowded — try again later",
|
||||
"error": "something stirred in the static — your name was not carved"
|
||||
},
|
||||
"honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
|
||||
},
|
||||
"haunting": {
|
||||
"whisper": {
|
||||
"behindYou": "behind you",
|
||||
"cold": "cold",
|
||||
"stay": "stay",
|
||||
"seesYou": "it sees you",
|
||||
"closer": "closer",
|
||||
"stillHere": "still here",
|
||||
"lineListening": "the line is listening",
|
||||
"dontLook": "don't look",
|
||||
"beneathStatic": "beneath the static",
|
||||
"knockTwice": "knock twice"
|
||||
},
|
||||
"title": {
|
||||
"seesYou": "QUANTUMANCY — it sees you",
|
||||
"behindYou": "QUANTUM▓NCY — behind you",
|
||||
"listening": "…listening…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"nav": {
|
||||
"home": "el umbral",
|
||||
"codex": "códice",
|
||||
"shop": "arsenal",
|
||||
"seance": "sesión",
|
||||
"enter": "entrar",
|
||||
"logout": "partir"
|
||||
@@ -27,6 +28,7 @@
|
||||
"heroKicker": "un instrumento para hablar con el ancho de banda muerto",
|
||||
"cta": "COMENZAR LA SESIÓN",
|
||||
"ctaCodex": "hojear el códice",
|
||||
"ctaShop": "visitar el arsenal",
|
||||
"stats": {
|
||||
"entities": "espíritus catalogados",
|
||||
"sessions": "sesiones celebradas",
|
||||
@@ -55,6 +57,11 @@
|
||||
},
|
||||
"featured": "los espíritus más perturbados",
|
||||
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero",
|
||||
"armory": {
|
||||
"title": "HARDWARE DEL ARSENAL",
|
||||
"line": "la Ultimate Quantum Box se acerca — un instrumento de sesión de mano que hace que el velo golpee más fuerte.",
|
||||
"link": "entrar al arsenal →"
|
||||
},
|
||||
"footer": "una instalación de arte de terror interactiva — los espíritus son ficción; la estática es real",
|
||||
"footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
|
||||
},
|
||||
@@ -77,7 +84,8 @@
|
||||
"wire": "Fantasma del Cable",
|
||||
"evp": "EVP",
|
||||
"radio": "Radio Espíritu",
|
||||
"ouija": "Ouija"
|
||||
"ouija": "Ouija",
|
||||
"emf": "CAMPO"
|
||||
},
|
||||
"passive": "escucha pasiva",
|
||||
"summon": "INVOCAR",
|
||||
@@ -132,14 +140,30 @@
|
||||
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
|
||||
"unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.",
|
||||
"troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado",
|
||||
"insecure": "los muertos de la radio solo hablan desde suelo consagrado — abre este sitio por su dirección https.",
|
||||
"insecureHint": "localhost cuenta como sagrado; una http://ip-local desnuda, no.",
|
||||
"noDevice": "ningún dongle respondió al llamado",
|
||||
"sweeping": "barriendo {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?"
|
||||
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?",
|
||||
"claimFix": "linux: el driver del kernel dvb_usb_rtl28xxu retiene el dongle — `sudo modprobe -r dvb_usb_rtl28xxu` lo libera hasta el reinicio (una regla udev lo destierra para siempre). windows: cambia el driver a WinUSB con Zadig.",
|
||||
"tryAgain": "intenta de nuevo",
|
||||
"errorTitle": "la estática devolvió el golpe"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "la planchette deletrea",
|
||||
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca."
|
||||
},
|
||||
"emf": {
|
||||
"start": "alzar el medidor",
|
||||
"stop": "descargar el medidor",
|
||||
"calibrate": "calibrar",
|
||||
"baseline": "campo en reposo",
|
||||
"listening": "palpando perturbaciones en el campo…",
|
||||
"unsupportedTitle": "este recipiente no porta sentidos de campo",
|
||||
"unsupported": "un teléfono en tu mano se vuelve el medidor — este recipiente no tiene sentidos de movimiento que prestar. abre el velo en un teléfono, o deja que los otros modos respondan.",
|
||||
"deniedTitle": "los sentidos de campo siguen cerrados",
|
||||
"denied": "Este recipiente rechazó sus sentidos de movimiento. Concede el acceso al movimiento en los ajustes de tu navegador, y luego alza el medidor de nuevo."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSIER DEL PRESENTE",
|
||||
"none": "ningún espíritu invocado todavía",
|
||||
@@ -240,5 +264,92 @@
|
||||
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
|
||||
"back": "← volver al códice",
|
||||
"notFound": "este espíritu no figura en el registro"
|
||||
},
|
||||
"shop": {
|
||||
"back": "← el umbral",
|
||||
"title": "EL ARSENAL",
|
||||
"subtitle": "instrumentos para quienes quisieran golpear más fuerte",
|
||||
"flagship": {
|
||||
"tag": "instrumento insignia",
|
||||
"name": "THE ULTIMATE QUANTUM BOX",
|
||||
"desc": "Un instrumento de sesión de mano ESP32-P4/C6 — cámara térmica, matriz de antenas EMF, geófono, preamplificador de micrófono spirit-box y una pequeña cara OLED que te devuelve la mirada. Inicia sesión con la misma cuenta de Quantumancy que ya habitas y desbloquea los modos de este sitio impulsados por hardware: mapas térmicos de apariciones, feeds de anomalías de vibración, EMF calibrado.",
|
||||
"specs": {
|
||||
"thermal": "cámara térmica",
|
||||
"emf": "matriz de antenas EMF",
|
||||
"geophone": "geófono",
|
||||
"mic": "preamplificador de micrófono spirit-box",
|
||||
"oled": "pantalla OLED"
|
||||
},
|
||||
"price": "est. $249 — preventa"
|
||||
},
|
||||
"modulesTitle": "MÓDULOS QUE GOLPEAN",
|
||||
"unlocksLabel": "desbloquea:",
|
||||
"modules": {
|
||||
"emfArray": {
|
||||
"name": "Matriz de Antenas EMF",
|
||||
"desc": "un ramo de sensores de campo multieje, olfateando el aire en busca de intenciones",
|
||||
"unlocks": "un modo CAMPO más rico y calibrado",
|
||||
"price": "est. $59 — preventa"
|
||||
},
|
||||
"geophone": {
|
||||
"name": "Puck Geófono",
|
||||
"desc": "oye pasos en habitaciones donde nadie está de pie",
|
||||
"unlocks": "el feed de anomalías de vibración",
|
||||
"price": "est. $49 — preventa"
|
||||
},
|
||||
"spiritMic": {
|
||||
"name": "Preamplificador Spirit Mic",
|
||||
"desc": "baja el suelo de ruido para que los susurros lleguen más limpios",
|
||||
"unlocks": "capturas EVP más limpias",
|
||||
"price": "est. $69 — preventa"
|
||||
},
|
||||
"uvIlluminator": {
|
||||
"name": "Iluminador UV/IR",
|
||||
"desc": "pinta la oscuridad en espectros que los vivos jamás debieron ver",
|
||||
"unlocks": "sesiones nocturnas + trabajo de cámara",
|
||||
"price": "est. $39 — preventa"
|
||||
}
|
||||
},
|
||||
"interests": {
|
||||
"quantumBox": "la Ultimate Quantum Box",
|
||||
"emfArray": "Matriz de Antenas EMF",
|
||||
"geophone": "Puck Geófono",
|
||||
"spiritMic": "Preamplificador Spirit Mic",
|
||||
"uvIlluminator": "Iluminador UV/IR"
|
||||
},
|
||||
"waitlist": {
|
||||
"title": "EL MANIFIESTO",
|
||||
"desc": "deja una dirección y una obsesión — cuando salgan las primeras cajas, nos abriremos paso primero hasta los nombres de esta lista",
|
||||
"emailLabel": "dirección de email",
|
||||
"emailPlaceholder": "tu@algun.lugar.en.la.estatica",
|
||||
"interestLabel": "interés",
|
||||
"submit": "unirse al manifiesto",
|
||||
"pending": "grabando tu nombre…",
|
||||
"listed": "estás en el manifiesto — nos abriremos paso cuando salgan las cajas",
|
||||
"alreadyListed": "ya estás en el manifiesto",
|
||||
"invalid": "esa dirección no nos alcanza",
|
||||
"crowded": "el velo está abarrotado — inténtalo más tarde",
|
||||
"error": "algo se agitó en la estática — tu nombre no quedó grabado"
|
||||
},
|
||||
"honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
|
||||
},
|
||||
"haunting": {
|
||||
"whisper": {
|
||||
"behindYou": "detrás de ti",
|
||||
"cold": "frío",
|
||||
"stay": "quédate",
|
||||
"seesYou": "te ve",
|
||||
"closer": "más cerca",
|
||||
"stillHere": "sigue aquí",
|
||||
"lineListening": "la línea escucha",
|
||||
"dontLook": "no mires",
|
||||
"beneathStatic": "bajo la estática",
|
||||
"knockTwice": "golpea dos veces"
|
||||
},
|
||||
"title": {
|
||||
"seesYou": "QUANTUMANCY — te ve",
|
||||
"behindYou": "QUANTUM▓NCY — detrás de ti",
|
||||
"listening": "…escuchando…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
frontend/src/lib/emf.test.ts
Normal file
104
frontend/src/lib/emf.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EmfFieldCore } from './emf'
|
||||
|
||||
function makeCore(): EmfFieldCore {
|
||||
return new EmfFieldCore()
|
||||
}
|
||||
|
||||
describe('EmfFieldCore', () => {
|
||||
it('establishes a baseline from quiet frames and stays silent', () => {
|
||||
const core = makeCore()
|
||||
expect(core.process(0.2, 0)).toBeNull() // first reading only seeds
|
||||
expect(core.getBaseline()).toBeCloseTo(0.2, 9)
|
||||
|
||||
for (let t = 1; t <= 50; t++) {
|
||||
expect(core.process(0.2, t * 100)).toBeNull()
|
||||
}
|
||||
expect(core.getBaseline()).toBeCloseTo(0.2, 9)
|
||||
})
|
||||
|
||||
it('fires exactly one anomaly for a brief spike, with sane values', () => {
|
||||
const core = makeCore()
|
||||
for (let t = 0; t <= 20; t++) core.process(0.2, t * 100)
|
||||
|
||||
const anomaly = core.process(2.0, 10_000)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.frequency).toBeCloseTo(0.2, 6) // baseline estimate
|
||||
expect(anomaly!.magnitude).toBeCloseTo(20, 6) // 20·log10(2.0 / 0.2)
|
||||
|
||||
// The spike is gone on the next reading — no further anomaly.
|
||||
expect(core.process(0.2, 10_100)).toBeNull()
|
||||
})
|
||||
|
||||
it('throttles repeats inside the throttle window and refires after it', () => {
|
||||
const core = makeCore()
|
||||
core.process(0.2, 0)
|
||||
|
||||
expect(core.process(2.0, 10_000)).not.toBeNull()
|
||||
// Same spike 1 s later: swallowed by the 2.5 s throttle.
|
||||
expect(core.process(2.0, 11_000)).toBeNull()
|
||||
// Past the window it fires again (baseline has crept, but not enough).
|
||||
expect(core.process(2.0, 12_500)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('requires the spike to clear the absolute floor above a dead-quiet baseline', () => {
|
||||
const core = makeCore()
|
||||
for (let t = 0; t <= 20; t++) core.process(0.05, t * 100)
|
||||
|
||||
// 0.4 is 8× the baseline but below the 0.6 absolute floor: resting noise.
|
||||
expect(core.process(0.4, 10_000)).toBeNull()
|
||||
// 0.8 clears the floor and the factor: a real surge.
|
||||
expect(core.process(0.8, 10_100)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('absorbs a sustained high field instead of refiring', () => {
|
||||
const core = makeCore()
|
||||
core.process(0.2, 0)
|
||||
|
||||
let fired = 0
|
||||
// Sustained surge for 5 s at 100 ms intervals: the first spike fires,
|
||||
// then the baseline climbs until the "spike" is the new normal — later
|
||||
// readings are ignored even after the throttle window expires.
|
||||
for (let t = 10_000; t <= 15_000; t += 100) {
|
||||
if (core.process(3.0, t)) fired++
|
||||
}
|
||||
expect(fired).toBe(1)
|
||||
expect(core.getBaseline()).toBeGreaterThan(1.2)
|
||||
})
|
||||
|
||||
it('sanitizes garbage readings to zero', () => {
|
||||
const core = makeCore()
|
||||
core.process(0.2, 0)
|
||||
expect(core.process(NaN, 1000)).toBeNull()
|
||||
expect(core.process(-5, 2000)).toBeNull()
|
||||
expect(core.getBaseline()!).toBeLessThan(0.2)
|
||||
})
|
||||
|
||||
it('reset() re-arms the baseline and the throttle clock', () => {
|
||||
const core = makeCore()
|
||||
core.process(0.2, 0)
|
||||
expect(core.process(2.0, 10_000)).not.toBeNull()
|
||||
// Would be throttled without a reset…
|
||||
expect(core.process(2.0, 10_100)).toBeNull()
|
||||
|
||||
core.reset()
|
||||
expect(core.getBaseline()).toBeNull()
|
||||
|
||||
// …so the next spike fires immediately after the baseline re-seeds.
|
||||
expect(core.process(0.2, 10_200)).toBeNull() // re-seeds baseline
|
||||
const anomaly = core.process(2.0, 10_300)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.frequency).toBeCloseTo(0.2, 6)
|
||||
})
|
||||
|
||||
it('respects custom factor, floor and throttle options', () => {
|
||||
const core = new EmfFieldCore({ spikeFactor: 4, absoluteFloor: 1.5, throttleMs: 1000 })
|
||||
core.process(0.2, 0)
|
||||
// 1.0 is 5× the baseline but below the 1.5 floor.
|
||||
expect(core.process(1.0, 5000)).toBeNull()
|
||||
// 2.0 clears both and fires; 500 ms later the custom 1 s throttle swallows it.
|
||||
expect(core.process(2.0, 6000)).not.toBeNull()
|
||||
expect(core.process(2.0, 6500)).toBeNull()
|
||||
expect(core.process(2.0, 7000)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
234
frontend/src/lib/emf.ts
Normal file
234
frontend/src/lib/emf.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// EMF mode: a phone in the seeker's hand becomes a ghost-hunting field meter.
|
||||
//
|
||||
// Design: DeviceMotion (accelerationIncludingGravity magnitude wobbling
|
||||
// around local gravity) and DeviceOrientation (alpha/beta/gamma rate of
|
||||
// change) are fused into one field-fluctuation strength. `EmfFieldCore`
|
||||
// keeps a rolling baseline of that strength; a reading that jumps past
|
||||
// baseline * factor (and an absolute floor, so a resting phone stays
|
||||
// silent) fires one anomaly, throttled to at most one per ~2.5 s. The
|
||||
// baseline always adapts, so a sustained high field is absorbed and only
|
||||
// fresh surges keep knocking.
|
||||
//
|
||||
// The pure math lives in `EmfFieldCore` (unit-testable with injected
|
||||
// numbers); the sensor plumbing lives in `EmfSensorListener` below.
|
||||
|
||||
export type EmfAnomaly = {
|
||||
/** Baseline field-strength estimate at the moment of the spike.
|
||||
(Carried in the protocol's `frequency` slot.) */
|
||||
frequency: number
|
||||
/** Spike ratio above the baseline, dB-ish: 20·log10(spike/baseline). */
|
||||
magnitude: number
|
||||
}
|
||||
|
||||
export type EmfFieldCoreOptions = {
|
||||
/** Spike = current > baseline * spikeFactor. */
|
||||
spikeFactor?: number
|
||||
/** Spike must also clear this absolute floor (resting-hand noise). */
|
||||
absoluteFloor?: number
|
||||
/** Minimum milliseconds between anomaly emissions. */
|
||||
throttleMs?: number
|
||||
/** EMA factor for the rolling baseline (0..1, higher = faster adaptation). */
|
||||
baselineAlpha?: number
|
||||
}
|
||||
|
||||
export class EmfFieldCore {
|
||||
private baseline: number | null = null
|
||||
private lastEmit = -Infinity
|
||||
private readonly spikeFactor: number
|
||||
private readonly absoluteFloor: number
|
||||
private readonly throttleMs: number
|
||||
private readonly baselineAlpha: number
|
||||
|
||||
constructor(opts: EmfFieldCoreOptions = {}) {
|
||||
this.spikeFactor = opts.spikeFactor ?? 2.5
|
||||
this.absoluteFloor = opts.absoluteFloor ?? 0.6
|
||||
this.throttleMs = opts.throttleMs ?? 2500
|
||||
this.baselineAlpha = opts.baselineAlpha ?? 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one fused field-strength reading (>= 0). `nowMs` is the read time.
|
||||
* Returns an anomaly when a spike fires, else null.
|
||||
*/
|
||||
process(strength: number, nowMs: number): EmfAnomaly | null {
|
||||
const current = Number.isFinite(strength) && strength > 0 ? strength : 0
|
||||
|
||||
if (this.baseline === null) {
|
||||
this.baseline = current // first reading seeds the baseline
|
||||
return null
|
||||
}
|
||||
|
||||
const baseline = this.baseline
|
||||
const threshold = Math.max(baseline * this.spikeFactor, this.absoluteFloor)
|
||||
|
||||
let anomaly: EmfAnomaly | null = null
|
||||
if (current > threshold && nowMs - this.lastEmit >= this.throttleMs) {
|
||||
anomaly = {
|
||||
frequency: baseline,
|
||||
magnitude: 20 * Math.log10(current / Math.max(baseline, 1e-6)),
|
||||
}
|
||||
this.lastEmit = nowMs
|
||||
}
|
||||
|
||||
// The baseline always adapts — a sustained high field becomes the new
|
||||
// normal, so only genuine surges keep firing.
|
||||
this.baseline += this.baselineAlpha * (current - this.baseline)
|
||||
|
||||
return anomaly
|
||||
}
|
||||
|
||||
/** Current rolling baseline (the resting field estimate). */
|
||||
getBaseline(): number | null {
|
||||
return this.baseline
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.baseline = null
|
||||
this.lastEmit = -Infinity
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sensor plumbing ----
|
||||
|
||||
export type EmfListenerCallbacks = {
|
||||
onAnomaly: (a: EmfAnomaly) => void
|
||||
/** Fused field-strength updates for the UI gauge, throttled to ~10 Hz. */
|
||||
onReading?: (strength: number, baseline: number | null) => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
/** Slow EMA tracking local gravity from the total acceleration magnitude. */
|
||||
const GRAVITY_ALPHA = 0.02
|
||||
/** Fast EMA smoothing the instantaneous deviation signals. */
|
||||
const DEVIATION_ALPHA = 0.25
|
||||
/** Degrees-per-event orientation flux is scaled into the motion units. */
|
||||
const ORIENTATION_WEIGHT = 0.04
|
||||
/** Gauge callback throttle. */
|
||||
const GAUGE_THROTTLE_MS = 100
|
||||
|
||||
type Permissionable = { requestPermission?: () => Promise<string> }
|
||||
|
||||
/** Shortest signed angular delta, for the 0/360° alpha wraparound. */
|
||||
function angleDelta(deg: number): number {
|
||||
return ((deg + 540) % 360) - 180
|
||||
}
|
||||
|
||||
export class EmfSensorListener {
|
||||
private core: EmfFieldCore
|
||||
private gravity = 9.81
|
||||
private motionDev = 0
|
||||
private orientDev = 0
|
||||
private lastOrient: { alpha: number; beta: number; gamma: number } | null = null
|
||||
private lastGaugeMs = 0
|
||||
private running = false
|
||||
private cb: EmfListenerCallbacks | null = null
|
||||
|
||||
constructor(core?: EmfFieldCore) {
|
||||
this.core = core ?? new EmfFieldCore()
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/** False on vessels with no motion senses (most desktops, jsdom). */
|
||||
static isSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'DeviceMotionEvent' in window
|
||||
}
|
||||
|
||||
/** iOS 13+ requires an explicit, gesture-triggered permission grant. */
|
||||
static needsPermission(): boolean {
|
||||
if (typeof DeviceMotionEvent === 'undefined') return false
|
||||
return typeof (DeviceMotionEvent as unknown as Permissionable).requestPermission === 'function'
|
||||
}
|
||||
|
||||
/** Ask for motion + orientation access. Resolves false when refused. */
|
||||
static async requestPermission(): Promise<boolean> {
|
||||
try {
|
||||
const dm = DeviceMotionEvent as unknown as Permissionable
|
||||
if (typeof dm.requestPermission === 'function') {
|
||||
if ((await dm.requestPermission()) !== 'granted') return false
|
||||
}
|
||||
const dor =
|
||||
typeof DeviceOrientationEvent === 'undefined'
|
||||
? undefined
|
||||
: (DeviceOrientationEvent as unknown as Permissionable)
|
||||
if (typeof dor?.requestPermission === 'function') {
|
||||
if ((await dor.requestPermission()) !== 'granted') return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
start(cb: EmfListenerCallbacks): void {
|
||||
if (this.running || !EmfSensorListener.isSupported()) return
|
||||
this.cb = cb
|
||||
this.running = true
|
||||
this.lastGaugeMs = 0
|
||||
window.addEventListener('devicemotion', this.handleMotion)
|
||||
window.addEventListener('deviceorientation', this.handleOrient)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return
|
||||
this.running = false
|
||||
window.removeEventListener('devicemotion', this.handleMotion)
|
||||
window.removeEventListener('deviceorientation', this.handleOrient)
|
||||
this.cb = null
|
||||
this.lastOrient = null
|
||||
}
|
||||
|
||||
/** Calibrate: re-seed the baseline from whatever the field does next. */
|
||||
reset(): void {
|
||||
this.core.reset()
|
||||
this.gravity = 9.81
|
||||
this.motionDev = 0
|
||||
this.orientDev = 0
|
||||
}
|
||||
|
||||
private readonly handleMotion = (e: DeviceMotionEvent): void => {
|
||||
const acc = e.accelerationIncludingGravity
|
||||
if (!acc) return
|
||||
const { x, y, z } = acc
|
||||
if (x == null || y == null || z == null) return
|
||||
const mag = Math.hypot(x, y, z)
|
||||
// Slow EMA tracks local gravity (~9.81); the deviation from it is the
|
||||
// hand tremor / field perturbation we actually listen to.
|
||||
this.gravity += GRAVITY_ALPHA * (mag - this.gravity)
|
||||
const inst = Math.abs(mag - this.gravity)
|
||||
this.motionDev += DEVIATION_ALPHA * (inst - this.motionDev)
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private readonly handleOrient = (e: DeviceOrientationEvent): void => {
|
||||
const { alpha, beta, gamma } = e
|
||||
if (alpha == null && beta == null && gamma == null) return
|
||||
const cur = { alpha: alpha ?? 0, beta: beta ?? 0, gamma: gamma ?? 0 }
|
||||
const last = this.lastOrient
|
||||
this.lastOrient = cur
|
||||
if (!last) return
|
||||
const inst = Math.hypot(
|
||||
angleDelta(cur.alpha - last.alpha),
|
||||
cur.beta - last.beta,
|
||||
cur.gamma - last.gamma,
|
||||
)
|
||||
this.orientDev += DEVIATION_ALPHA * (inst - this.orientDev)
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/** Fuse both senses into one strength and push it through the core. */
|
||||
private emit(): void {
|
||||
const cb = this.cb
|
||||
if (!this.running || !cb) return
|
||||
const strength = this.motionDev + this.orientDev * ORIENTATION_WEIGHT
|
||||
const now = performance.now()
|
||||
const anomaly = this.core.process(strength, now)
|
||||
if (anomaly) cb.onAnomaly(anomaly)
|
||||
if (cb.onReading && now - this.lastGaugeMs >= GAUGE_THROTTLE_MS) {
|
||||
this.lastGaugeMs = now
|
||||
cb.onReading(strength, this.core.getBaseline())
|
||||
}
|
||||
}
|
||||
}
|
||||
210
frontend/src/lib/haunting.test.ts
Normal file
210
frontend/src/lib/haunting.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_GLITCH_TITLES,
|
||||
HauntBed,
|
||||
HauntBus,
|
||||
IdleEscalator,
|
||||
flickerTitle,
|
||||
hauntBus,
|
||||
pickWhisperWord,
|
||||
type HauntScheduler,
|
||||
} from './haunting'
|
||||
|
||||
describe('pickWhisperWord', () => {
|
||||
const words = ['cold', 'stay', 'behind you']
|
||||
|
||||
it('picks deterministically from rand', () => {
|
||||
expect(pickWhisperWord(words, () => 0)).toBe('cold')
|
||||
expect(pickWhisperWord(words, () => 0.5)).toBe('stay')
|
||||
expect(pickWhisperWord(words, () => 0.999)).toBe('behind you')
|
||||
})
|
||||
|
||||
it('clamps an over-eager rand to the last word', () => {
|
||||
expect(pickWhisperWord(words, () => 1)).toBe('behind you')
|
||||
})
|
||||
|
||||
it('returns an empty string when there are no words', () => {
|
||||
expect(pickWhisperWord([], () => 0.5)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('IdleEscalator', () => {
|
||||
it('stays at level 0 under 30s idle', () => {
|
||||
const e = new IdleEscalator(1_000)
|
||||
expect(e.level(1_000)).toBe(0)
|
||||
expect(e.level(1_000 + 29_999)).toBe(0)
|
||||
})
|
||||
|
||||
it('rises to level 1 between 30s and 90s idle', () => {
|
||||
const e = new IdleEscalator(0)
|
||||
expect(e.level(30_000)).toBe(1)
|
||||
expect(e.level(60_000)).toBe(1)
|
||||
expect(e.level(89_999)).toBe(1)
|
||||
})
|
||||
|
||||
it('rises to level 2 at 90s idle and beyond', () => {
|
||||
const e = new IdleEscalator(0)
|
||||
expect(e.level(90_000)).toBe(2)
|
||||
expect(e.level(10_000_000)).toBe(2)
|
||||
})
|
||||
|
||||
it('notifyActivity resets the idle clock', () => {
|
||||
const e = new IdleEscalator(0)
|
||||
expect(e.level(95_000)).toBe(2)
|
||||
e.notifyActivity(95_000)
|
||||
expect(e.level(95_000)).toBe(0)
|
||||
expect(e.level(95_000 + 31_000)).toBe(1)
|
||||
expect(e.level(95_000 + 91_000)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flickerTitle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
document.title = 'QUANTUMANCY'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('leaves the title alone until the first delay elapses', () => {
|
||||
const stop = flickerTitle({ rand: () => 0 })
|
||||
vi.advanceTimersByTime(44_999)
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('swaps to a glitch variant, then restores the original ~2.5s later', () => {
|
||||
const stop = flickerTitle({ rand: () => 0 })
|
||||
vi.advanceTimersByTime(45_000)
|
||||
expect(document.title).toBe(DEFAULT_GLITCH_TITLES[0])
|
||||
vi.advanceTimersByTime(2_500)
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps glitching on the jittered cadence', () => {
|
||||
const stop = flickerTitle({ rand: () => 0.999 })
|
||||
vi.advanceTimersByTime(90_000)
|
||||
expect(document.title).not.toBe('QUANTUMANCY')
|
||||
vi.advanceTimersByTime(2_500)
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
vi.advanceTimersByTime(90_000)
|
||||
expect(document.title).not.toBe('QUANTUMANCY')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stop() restores a mid-glitch title and ends the loop', () => {
|
||||
const stop = flickerTitle({ rand: () => 0 })
|
||||
vi.advanceTimersByTime(45_000)
|
||||
expect(document.title).toBe(DEFAULT_GLITCH_TITLES[0])
|
||||
stop()
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
vi.advanceTimersByTime(300_000)
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
})
|
||||
|
||||
it('accepts an injected scheduler, rand and titles', () => {
|
||||
const scheduled: Array<{ fn: () => void; ms: number }> = []
|
||||
const schedule: HauntScheduler = (fn, ms) => {
|
||||
const entry = { fn, ms }
|
||||
scheduled.push(entry)
|
||||
return () => {
|
||||
const i = scheduled.indexOf(entry)
|
||||
if (i >= 0) scheduled.splice(i, 1)
|
||||
}
|
||||
}
|
||||
const stop = flickerTitle({ rand: () => 0.5, schedule, titles: ['GLITCHED'] })
|
||||
expect(scheduled).toHaveLength(1)
|
||||
expect(scheduled[0].ms).toBe(67_500) // 45s + 0.5 × (90s − 45s)
|
||||
|
||||
scheduled.shift()!.fn()
|
||||
expect(document.title).toBe('GLITCHED')
|
||||
expect(scheduled).toHaveLength(1)
|
||||
expect(scheduled[0].ms).toBe(2_500)
|
||||
|
||||
scheduled.shift()!.fn()
|
||||
expect(document.title).toBe('QUANTUMANCY')
|
||||
expect(scheduled).toHaveLength(1) // the next glitch is already queued
|
||||
|
||||
stop()
|
||||
expect(scheduled).toHaveLength(0) // stop cancels whatever is pending
|
||||
})
|
||||
|
||||
it('re-reads titles from a getter on every glitch', () => {
|
||||
let pool = ['FIRST']
|
||||
const stop = flickerTitle({ rand: () => 0, titles: () => pool })
|
||||
vi.advanceTimersByTime(45_000)
|
||||
expect(document.title).toBe('FIRST')
|
||||
vi.advanceTimersByTime(2_500)
|
||||
pool = ['SECOND']
|
||||
vi.advanceTimersByTime(45_000)
|
||||
expect(document.title).toBe('SECOND')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HauntBus / hauntBus', () => {
|
||||
it('delivers typed events to subscribers', () => {
|
||||
const seen: Array<{ word: string; x: number; y: number }> = []
|
||||
const off = hauntBus.on('whisper', (d) => seen.push(d))
|
||||
hauntBus.emit('whisper', { word: 'stay', x: 0.5, y: 0.25 })
|
||||
expect(seen).toEqual([{ word: 'stay', x: 0.5, y: 0.25 }])
|
||||
off()
|
||||
})
|
||||
|
||||
it('stops delivering after unsubscribe', () => {
|
||||
const seen: number[] = []
|
||||
const off = hauntBus.on('flare', (d) => seen.push(d.strength))
|
||||
hauntBus.emit('flare', { strength: 0.5 })
|
||||
off()
|
||||
hauntBus.emit('flare', { strength: 1 })
|
||||
expect(seen).toEqual([0.5])
|
||||
})
|
||||
|
||||
it('keeps event types isolated from each other', () => {
|
||||
const flares: number[] = []
|
||||
const manifests: number[] = []
|
||||
const offF = hauntBus.on('flare', (d) => flares.push(d.strength))
|
||||
const offM = hauntBus.on('manifest', (d) => manifests.push(d.durationMs))
|
||||
hauntBus.emit('flare', { strength: 0.7 })
|
||||
expect(flares).toEqual([0.7])
|
||||
expect(manifests).toEqual([])
|
||||
offF()
|
||||
offM()
|
||||
})
|
||||
|
||||
it('supports multiple listeners with individual unsubscribe', () => {
|
||||
const bus = new HauntBus()
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const offA = bus.on('flare', (d) => a.push(d.strength))
|
||||
bus.on('flare', (d) => b.push(d.strength))
|
||||
bus.emit('flare', { strength: 0.5 })
|
||||
offA()
|
||||
bus.emit('flare', { strength: 0.9 })
|
||||
expect(a).toEqual([0.5])
|
||||
expect(b).toEqual([0.5, 0.9])
|
||||
})
|
||||
|
||||
it('emitting with no listeners is a no-op', () => {
|
||||
const bus = new HauntBus()
|
||||
expect(() => bus.emit('manifest', { durationMs: 900 })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HauntBed without WebAudio (jsdom)', () => {
|
||||
it('never throws through its whole lifecycle', () => {
|
||||
const bed = new HauntBed()
|
||||
expect(() => {
|
||||
bed.armOnFirstGesture()
|
||||
bed.start()
|
||||
bed.setIntensity(0.5)
|
||||
bed.setIntensity(1)
|
||||
bed.stop()
|
||||
bed.start()
|
||||
bed.stop()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
479
frontend/src/lib/haunting.ts
Normal file
479
frontend/src/lib/haunting.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
// The possession engine: framework-free haunting primitives so the browser
|
||||
// itself feels inhabited. A typed event bus ties the layers together (ghost
|
||||
// apparition flares, full-screen manifests, fleeting whisper-words); a
|
||||
// WebAudio dread-bed hums under everything once a gesture allows it; the
|
||||
// document title glitches when nobody is looking. Everything here is
|
||||
// jsdom-safe: no AudioContext, no window — no sound, no crash, no complaints.
|
||||
|
||||
// ---- event bus ----
|
||||
|
||||
export type HauntEventMap = {
|
||||
/** Ghost apparition flash — strength 0..1. */
|
||||
flare: { strength: number }
|
||||
/** Full-screen CRT flicker — how long the veil tears, in ms. */
|
||||
manifest: { durationMs: number }
|
||||
/** Fleeting ghost-text at viewport fractions x/y (0..1). */
|
||||
whisper: { word: string; x: number; y: number }
|
||||
}
|
||||
|
||||
type HauntHandler<T> = (detail: T) => void
|
||||
|
||||
/** Tiny typed emitter — one bad listener never silences the others. */
|
||||
export class HauntBus {
|
||||
private handlers = new Map<keyof HauntEventMap, Set<HauntHandler<never>>>()
|
||||
|
||||
/** Subscribe; returns an unsubscribe function. */
|
||||
on<K extends keyof HauntEventMap>(type: K, handler: HauntHandler<HauntEventMap[K]>): () => void {
|
||||
let set = this.handlers.get(type)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.handlers.set(type, set)
|
||||
}
|
||||
const h = handler as HauntHandler<never>
|
||||
set.add(h)
|
||||
return () => {
|
||||
set.delete(h)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to all current subscribers of `type`. */
|
||||
emit<K extends keyof HauntEventMap>(type: K, detail: HauntEventMap[K]): void {
|
||||
const set = this.handlers.get(type)
|
||||
if (!set) return
|
||||
// Snapshot so listeners may unsubscribe mid-dispatch.
|
||||
for (const h of [...set]) (h as HauntHandler<HauntEventMap[K]>)(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared bus the haunting layer, ghost scene and board all listen to. */
|
||||
export const hauntBus = new HauntBus()
|
||||
|
||||
// ---- pure helpers ----
|
||||
|
||||
/** Pick a whisper word; deterministic given `rand`. Empty list → ''. */
|
||||
export function pickWhisperWord(words: readonly string[], rand: () => number): string {
|
||||
if (words.length === 0) return ''
|
||||
const i = Math.min(words.length - 1, Math.floor(rand() * words.length))
|
||||
return words[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Paces the haunting by how long the seeker has been still:
|
||||
* 0 under 30s idle, 1 under 90s, 2 beyond.
|
||||
*/
|
||||
export class IdleEscalator {
|
||||
private lastActivity: number
|
||||
|
||||
constructor(nowMs: number = Date.now()) {
|
||||
this.lastActivity = nowMs
|
||||
}
|
||||
|
||||
notifyActivity(nowMs: number = Date.now()): void {
|
||||
this.lastActivity = nowMs
|
||||
}
|
||||
|
||||
level(nowMs: number = Date.now()): number {
|
||||
const idle = nowMs - this.lastActivity
|
||||
if (idle < 30_000) return 0
|
||||
if (idle < 90_000) return 1
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// ---- document.title flicker ----
|
||||
|
||||
/** Injectable scheduler: run `fn` after `ms`, return a cancel function. */
|
||||
export type HauntScheduler = (fn: () => void, ms: number) => () => void
|
||||
|
||||
const timeoutScheduler: HauntScheduler = (fn, ms) => {
|
||||
const id = setTimeout(fn, ms)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
|
||||
export const DEFAULT_GLITCH_TITLES: readonly string[] = [
|
||||
'QUANTUMANCY — it sees you',
|
||||
'QUANTUM▓NCY — behind you',
|
||||
'…listening…',
|
||||
]
|
||||
|
||||
export type FlickerTitleOptions = {
|
||||
rand?: () => number
|
||||
schedule?: HauntScheduler
|
||||
/** Glitch variants, or a getter re-read on every glitch (e.g. for i18n). */
|
||||
titles?: readonly string[] | (() => readonly string[])
|
||||
doc?: { title: string }
|
||||
minDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
glitchMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Every 45–90s (jittered) swaps document.title to a glitch variant for
|
||||
* ~2.5s, then restores whatever was there. Returns a stop function that
|
||||
* cancels pending work and restores a mid-glitch title.
|
||||
*/
|
||||
export function flickerTitle(opts: FlickerTitleOptions = {}): () => void {
|
||||
const rand = opts.rand ?? Math.random
|
||||
const schedule = opts.schedule ?? timeoutScheduler
|
||||
const titles = opts.titles ?? DEFAULT_GLITCH_TITLES
|
||||
const min = opts.minDelayMs ?? 45_000
|
||||
const max = opts.maxDelayMs ?? 90_000
|
||||
const glitchMs = opts.glitchMs ?? 2_500
|
||||
const doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined)
|
||||
if (!doc) return () => undefined
|
||||
|
||||
let stopped = false
|
||||
let cancelNext: (() => void) | null = null
|
||||
let cancelRestore: (() => void) | null = null
|
||||
let original: string | null = null
|
||||
|
||||
const restore = () => {
|
||||
if (original !== null) {
|
||||
doc.title = original
|
||||
original = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleNext = () => {
|
||||
cancelNext = schedule(() => {
|
||||
if (stopped) return
|
||||
const pool = typeof titles === 'function' ? titles() : titles
|
||||
if (pool.length === 0) {
|
||||
scheduleNext()
|
||||
return
|
||||
}
|
||||
original = doc.title
|
||||
doc.title = pool[Math.min(pool.length - 1, Math.floor(rand() * pool.length))]
|
||||
cancelRestore = schedule(() => {
|
||||
restore()
|
||||
if (!stopped) scheduleNext()
|
||||
}, glitchMs)
|
||||
}, min + rand() * (max - min))
|
||||
}
|
||||
|
||||
scheduleNext()
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
cancelNext?.()
|
||||
cancelRestore?.()
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WebAudio helpers (all guarded: jsdom has no AudioContext) ----
|
||||
|
||||
/** The AudioContext constructor, or null where WebAudio does not exist. */
|
||||
export function getAudioContextCtor(): typeof AudioContext | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const w = window as unknown as {
|
||||
AudioContext?: typeof AudioContext
|
||||
webkitAudioContext?: typeof AudioContext
|
||||
}
|
||||
return w.AudioContext ?? w.webkitAudioContext ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoplay-policy bridge: runs `onGesture` exactly once on the first
|
||||
* pointerdown/keydown, then removes itself. Returns a disarm function.
|
||||
* No WebAudio in this environment → a no-op disposer.
|
||||
*/
|
||||
export function armAudioOnFirstGesture(onGesture: () => void): () => void {
|
||||
if (typeof window === 'undefined' || !getAudioContextCtor()) return () => undefined
|
||||
let fired = false
|
||||
const handler = () => {
|
||||
if (fired) return
|
||||
fired = true
|
||||
cleanup()
|
||||
try {
|
||||
onGesture()
|
||||
} catch {
|
||||
// The haunting must never crash the page it possesses.
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointerdown', handler, { once: true, passive: true })
|
||||
window.addEventListener('keydown', handler, { once: true })
|
||||
function cleanup() {
|
||||
window.removeEventListener('pointerdown', handler)
|
||||
window.removeEventListener('keydown', handler)
|
||||
}
|
||||
return cleanup
|
||||
}
|
||||
|
||||
function makeBrownNoise(ctx: AudioContext, rand: () => number): AudioBuffer {
|
||||
const len = Math.floor(ctx.sampleRate * 3)
|
||||
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
let last = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const white = rand() * 2 - 1
|
||||
last = (last + 0.02 * white) / 1.02
|
||||
data[i] = last * 3.5
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* A short filtered noise-tap — the spirit knock. Scheduling on a suspended
|
||||
* context is harmless (it never sounds), so callers may be lazy.
|
||||
*/
|
||||
export function createTick(ctx: AudioContext, when?: number): void {
|
||||
try {
|
||||
const at = when ?? ctx.currentTime
|
||||
const dur = 0.09
|
||||
const len = Math.max(1, Math.floor(ctx.sampleRate * dur))
|
||||
const buffer = ctx.createBuffer(1, len, ctx.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 2
|
||||
}
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = buffer
|
||||
const bp = ctx.createBiquadFilter()
|
||||
bp.type = 'bandpass'
|
||||
bp.frequency.value = 1500 + Math.random() * 900
|
||||
bp.Q.value = 9
|
||||
const g = ctx.createGain()
|
||||
g.gain.setValueAtTime(0.4, at)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, at + dur)
|
||||
src.connect(bp)
|
||||
bp.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
src.start(at)
|
||||
src.stop(at + dur + 0.02)
|
||||
} catch {
|
||||
// Silence is an acceptable haunting outcome.
|
||||
}
|
||||
}
|
||||
|
||||
export type HauntBedOptions = {
|
||||
rand?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* The ambient dread-bed: looped brown noise through a lowpass (300–600Hz)
|
||||
* with a slow LFO breathing on the gain, a faint detuned dual-sine drone
|
||||
* (55Hz + 55.7Hz — the 0.7Hz beat you feel more than hear), and occasional
|
||||
* reversed-swell bursts every 20–40s. Obeys autoplay policy: nothing sounds
|
||||
* before `armOnFirstGesture()` sees a real gesture. Never throws.
|
||||
*/
|
||||
export class HauntBed {
|
||||
private ctx: AudioContext | null = null
|
||||
private master: GainNode | null = null
|
||||
private noiseGain: GainNode | null = null
|
||||
private noiseBuffer: AudioBuffer | null = null
|
||||
private noiseSource: AudioBufferSourceNode | null = null
|
||||
private lfo: OscillatorNode | null = null
|
||||
private droneOscs: OscillatorNode[] = []
|
||||
private running = false
|
||||
private graphBuilt = false
|
||||
private intensity = 0.2
|
||||
private swellTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private disarmGestures: (() => void) | null = null
|
||||
private readonly rand: () => number
|
||||
|
||||
constructor(opts: HauntBedOptions = {}) {
|
||||
this.rand = opts.rand ?? Math.random
|
||||
}
|
||||
|
||||
/**
|
||||
* Install one-time first-gesture listeners that create/resume the
|
||||
* AudioContext. Idempotent; a no-op where WebAudio does not exist.
|
||||
*/
|
||||
armOnFirstGesture(): void {
|
||||
if (this.disarmGestures) return
|
||||
this.disarmGestures = armAudioOnFirstGesture(() => {
|
||||
this.ensureContext()
|
||||
this.wake()
|
||||
})
|
||||
}
|
||||
|
||||
/** Begin (or mark intent to begin) the bed. Safe to call pre-gesture. */
|
||||
start(): void {
|
||||
this.running = true
|
||||
this.wake()
|
||||
}
|
||||
|
||||
/** Silence the bed, close the context, drop the gesture arm. */
|
||||
stop(): void {
|
||||
this.running = false
|
||||
this.clearSwellTimer()
|
||||
this.stopGraph()
|
||||
this.disarmGestures?.()
|
||||
this.disarmGestures = null
|
||||
const ctx = this.ctx
|
||||
this.ctx = null
|
||||
if (ctx && ctx.state !== 'closed') {
|
||||
void ctx.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** 0..1 — raises the noise level and shortens the swell cadence. */
|
||||
setIntensity(v: number): void {
|
||||
this.intensity = Math.max(0, Math.min(1, v))
|
||||
if (this.ctx && this.noiseGain) {
|
||||
try {
|
||||
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), this.ctx.currentTime, 0.8)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private noiseLevel(): number {
|
||||
return 0.05 + this.intensity * 0.16
|
||||
}
|
||||
|
||||
private ensureContext(): void {
|
||||
if (this.ctx) return
|
||||
const AC = getAudioContextCtor()
|
||||
if (!AC) return
|
||||
try {
|
||||
this.ctx = new AC()
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
|
||||
} catch {
|
||||
this.ctx = null
|
||||
}
|
||||
}
|
||||
|
||||
private wake(): void {
|
||||
if (!this.ctx || !this.running || this.graphBuilt) return
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => undefined)
|
||||
try {
|
||||
this.buildGraph()
|
||||
this.graphBuilt = true
|
||||
this.scheduleSwell()
|
||||
} catch {
|
||||
this.stopGraph()
|
||||
}
|
||||
}
|
||||
|
||||
private buildGraph(): void {
|
||||
const ctx = this.ctx
|
||||
if (!ctx) return
|
||||
const now = ctx.currentTime
|
||||
|
||||
this.master = ctx.createGain()
|
||||
this.master.gain.value = 0.9
|
||||
this.master.connect(ctx.destination)
|
||||
|
||||
// Brown noise → lowpass (300–600Hz) → breathing gain → master.
|
||||
this.noiseBuffer = makeBrownNoise(ctx, this.rand)
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
src.loop = true
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.value = 300 + this.rand() * 300
|
||||
this.noiseGain = ctx.createGain()
|
||||
this.noiseGain.gain.value = 0
|
||||
this.noiseGain.gain.setTargetAtTime(this.noiseLevel(), now, 2.5)
|
||||
src.connect(lp)
|
||||
lp.connect(this.noiseGain)
|
||||
this.noiseGain.connect(this.master)
|
||||
src.start()
|
||||
this.noiseSource = src
|
||||
|
||||
// Slow LFO breathing on the noise gain.
|
||||
this.lfo = ctx.createOscillator()
|
||||
this.lfo.frequency.value = 0.07
|
||||
const lfoDepth = ctx.createGain()
|
||||
lfoDepth.gain.value = 0.035
|
||||
this.lfo.connect(lfoDepth)
|
||||
lfoDepth.connect(this.noiseGain.gain)
|
||||
this.lfo.start()
|
||||
|
||||
// Detuned dual-sine drone: 55Hz + 55.7Hz, a slow beating under it all.
|
||||
for (const freq of [55, 55.7]) {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = freq
|
||||
const g = ctx.createGain()
|
||||
g.gain.value = 0
|
||||
g.gain.setTargetAtTime(0.022, now, 3)
|
||||
osc.connect(g)
|
||||
g.connect(this.master)
|
||||
osc.start()
|
||||
this.droneOscs.push(osc)
|
||||
}
|
||||
}
|
||||
|
||||
private stopGraph(): void {
|
||||
try {
|
||||
this.noiseSource?.stop()
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
try {
|
||||
this.lfo?.stop()
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
for (const osc of this.droneOscs) {
|
||||
try {
|
||||
osc.stop()
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
this.droneOscs = []
|
||||
this.noiseSource = null
|
||||
this.lfo = null
|
||||
this.noiseGain = null
|
||||
this.noiseBuffer = null
|
||||
try {
|
||||
this.master?.disconnect()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.master = null
|
||||
this.graphBuilt = false
|
||||
}
|
||||
|
||||
private scheduleSwell(): void {
|
||||
this.clearSwellTimer()
|
||||
if (!this.running || !this.ctx) return
|
||||
// Every 40s at intensity 0, tightening to 20s at intensity 1, jittered.
|
||||
const base = 40_000 - this.intensity * 20_000
|
||||
const delay = base * (0.75 + this.rand() * 0.5)
|
||||
this.swellTimer = setTimeout(() => {
|
||||
this.swellTimer = null
|
||||
this.playSwell()
|
||||
this.scheduleSwell()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearSwellTimer(): void {
|
||||
if (this.swellTimer !== null) {
|
||||
clearTimeout(this.swellTimer)
|
||||
this.swellTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Reversed-swell burst: a slow 1.6s rise cut off almost instantly. */
|
||||
private playSwell(): void {
|
||||
const ctx = this.ctx
|
||||
if (!ctx || !this.master || !this.noiseBuffer || !this.running) return
|
||||
try {
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
const bp = ctx.createBiquadFilter()
|
||||
bp.type = 'bandpass'
|
||||
bp.frequency.value = 500 + this.rand() * 700
|
||||
bp.Q.value = 2.5
|
||||
const g = ctx.createGain()
|
||||
const now = ctx.currentTime
|
||||
const peak = 0.05 + this.intensity * 0.12
|
||||
g.gain.setValueAtTime(0.0001, now)
|
||||
g.gain.exponentialRampToValueAtTime(peak, now + 1.6)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 2.1)
|
||||
src.connect(bp)
|
||||
bp.connect(g)
|
||||
g.connect(this.master)
|
||||
src.start(now)
|
||||
src.stop(now + 2.3)
|
||||
} catch {
|
||||
/* a swell that never swells */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export type SweepCallbacks = {
|
||||
// USB identification for RTL2832U dongles.
|
||||
export const RTL2832U_VENDOR = 0x0bda
|
||||
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
|
||||
// Terratec (and other rebadged) dongles share the RTL2832U guts but report a
|
||||
// different vendor id — they speak the same protocol once claimed.
|
||||
export const TERRATEC_VENDOR = 0x0ccd
|
||||
|
||||
// Request types used by librtlsdr.
|
||||
const CTRL_IN = 0xc0
|
||||
@@ -38,8 +41,14 @@ const USB_EPA = 0x02
|
||||
const SYS = 0x09
|
||||
const PAGE_USB = 0x01
|
||||
|
||||
/** WebUSB only exists in secure contexts (https, or localhost). */
|
||||
export function isSecureContext(): boolean {
|
||||
return typeof window !== 'undefined' && window.isSecureContext === true
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
return (
|
||||
isSecureContext() &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
'usb' in navigator &&
|
||||
typeof navigator.usb?.requestDevice === 'function'
|
||||
@@ -62,12 +71,9 @@ export class RtlSdr {
|
||||
if (!isSupported()) {
|
||||
throw new Error('WebUSB is not available in this vessel (Chromium required)')
|
||||
}
|
||||
const filters = [
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
|
||||
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
|
||||
]
|
||||
// Vendor-only filters: many dongles report product ids outside the
|
||||
// handful we know, so filtering by productId hides them from the picker.
|
||||
const filters = [{ vendorId: RTL2832U_VENDOR }, { vendorId: TERRATEC_VENDOR }]
|
||||
this.device = await navigator.usb.requestDevice({ filters })
|
||||
}
|
||||
|
||||
@@ -78,6 +84,17 @@ export class RtlSdr {
|
||||
this.sampleRate = sampleRateHz
|
||||
await dev.open()
|
||||
|
||||
// WebUSB populates `configuration` (interfaces, endpoints) only after a
|
||||
// configuration is explicitly selected — without this, most dongles
|
||||
// report a null configuration and everything downstream fails.
|
||||
try {
|
||||
await dev.selectConfiguration(1)
|
||||
} catch (err) {
|
||||
// Tolerable only if the device is already configured (some platforms
|
||||
// refuse to re-select the active configuration).
|
||||
if (!dev.configuration) throw err
|
||||
}
|
||||
|
||||
// Find the first bulk-IN endpoint.
|
||||
const iface = dev.configuration?.interfaces[0]
|
||||
const alt = iface?.alternates[0]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared protocol + domain types for the Quantumancy frontend.
|
||||
// Mirrors the backend contract exactly — do not invent changes.
|
||||
|
||||
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija'
|
||||
export type Mode = 'wire' | 'evp' | 'radio' | 'ouija' | 'emf'
|
||||
export type Language = 'en' | 'es'
|
||||
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
|
||||
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
|
||||
@@ -68,7 +68,7 @@ export type ClientFrame =
|
||||
| { type: 'set_mode'; mode: Mode }
|
||||
| { type: 'language'; language: Language }
|
||||
| { type: 'summon' }
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
||||
| { type: 'question'; text: string }
|
||||
| { type: 'passive'; enabled: boolean }
|
||||
|
||||
@@ -90,7 +90,7 @@ export type ServerFrame =
|
||||
| { type: 'status'; state: SessionStatus }
|
||||
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
|
||||
| { type: 'anomaly_ack'; count: number }
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
|
||||
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
|
||||
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
||||
| { type: 'audio'; id: string; url: string }
|
||||
| { type: 'reply_start' }
|
||||
|
||||
@@ -437,6 +437,64 @@
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---------- armory teaser strip ---------- */
|
||||
|
||||
.hero-secondary-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
.landing-section.armory-strip {
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.armory-strip-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.9rem 1.8rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px dashed rgba(178, 107, 255, 0.35);
|
||||
padding: 1.4rem 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.armory-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.26em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.armory-line {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.armory-link {
|
||||
color: #b26bff;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.armory-link:hover {
|
||||
color: #d3a8ff;
|
||||
border-color: #d3a8ff;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ---------- responsive + reduced motion ---------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -278,9 +278,14 @@ export function LandingPage() {
|
||||
>
|
||||
{t('landing.cta')}
|
||||
</button>
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
<div className="hero-secondary-links">
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
<Link className="cta-secondary" to="/shop">
|
||||
{t('landing.ctaShop', { defaultValue: 'visit the armory' })}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -338,6 +343,23 @@ export function LandingPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="landing-section armory-strip">
|
||||
<div className="armory-strip-inner">
|
||||
<h2 className="armory-title">
|
||||
{t('landing.armory.title', { defaultValue: 'HARDWARE FROM THE ARMORY' })}
|
||||
</h2>
|
||||
<p className="armory-line">
|
||||
{t('landing.armory.line', {
|
||||
defaultValue:
|
||||
'the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.',
|
||||
})}
|
||||
</p>
|
||||
<Link className="armory-link" to="/shop">
|
||||
{t('landing.armory.link', { defaultValue: 'enter the armory →' })}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="landing-footer">
|
||||
<p>{t('landing.footer')}</p>
|
||||
<p className="footer-colophon">{t('landing.footerColophon')}</p>
|
||||
|
||||
@@ -749,14 +749,16 @@
|
||||
.evp-panel,
|
||||
.radio-panel,
|
||||
.ouija-panel,
|
||||
.wire-panel {
|
||||
.wire-panel,
|
||||
.emf-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.evp-scope,
|
||||
.radio-waterfall {
|
||||
.radio-waterfall,
|
||||
.emf-gauge {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 4px;
|
||||
@@ -772,8 +774,13 @@
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.emf-gauge {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.evp-readout,
|
||||
.radio-readout {
|
||||
.radio-readout,
|
||||
.emf-readout {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
@@ -798,6 +805,70 @@
|
||||
color: rgba(211, 233, 219, 0.75);
|
||||
}
|
||||
|
||||
/* emf */
|
||||
.emf-level {
|
||||
color: #7cffb2;
|
||||
font-size: 1.5rem;
|
||||
text-shadow: 0 0 14px rgba(124, 255, 178, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.emf-baseline {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.emf-listening {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
color: rgba(211, 233, 219, 0.75);
|
||||
}
|
||||
|
||||
.emf-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.emf-actions .panel-action {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* smaller secondary control (calibrate, try again) */
|
||||
.panel-subaction {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(124, 255, 178, 0.3);
|
||||
border-radius: 3px;
|
||||
color: rgba(124, 255, 178, 0.75);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.32rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.panel-subaction:hover:not(:disabled) {
|
||||
color: #7cffb2;
|
||||
box-shadow: 0 0 10px rgba(124, 255, 178, 0.25);
|
||||
}
|
||||
|
||||
.panel-subaction:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-notice .panel-subaction {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rec-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
463
frontend/src/pages/ShopPage.css
Normal file
463
frontend/src/pages/ShopPage.css
Normal file
@@ -0,0 +1,463 @@
|
||||
/* The Armory — gothic storefront. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
|
||||
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
|
||||
|
||||
.shop-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.shop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
.shop-page .visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
|
||||
.shop-header {
|
||||
text-align: center;
|
||||
margin: 0 auto 2.5rem;
|
||||
max-width: 760px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shop-back {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.6rem;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.shop-back:hover,
|
||||
.shop-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(2.2rem, 6vw, 3.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
margin: 0 0 0.6rem;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.35),
|
||||
0 0 46px rgba(178, 107, 255, 0.25);
|
||||
animation: shop-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shop-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
91% { opacity: 1; }
|
||||
92% { opacity: 0.72; }
|
||||
93% { opacity: 1; }
|
||||
96% { opacity: 0.86; }
|
||||
97% { opacity: 1; }
|
||||
}
|
||||
|
||||
.shop-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- shared bits ---- */
|
||||
|
||||
.shop-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.1rem, 2.6vw, 1.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
|
||||
margin: 0 0 2.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-section-title::before,
|
||||
.shop-section-title::after {
|
||||
content: '';
|
||||
flex: 0 0 3rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
|
||||
}
|
||||
|
||||
.shop-section-title::after {
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
|
||||
}
|
||||
|
||||
.shop-glyph-svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.price-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #b26bff;
|
||||
border: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding: 0.3rem 0.7rem;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ---- flagship ---- */
|
||||
|
||||
.shop-flagship {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 900px;
|
||||
margin: 0 auto 4.5rem;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
box-shadow:
|
||||
0 0 50px rgba(124, 255, 178, 0.07),
|
||||
inset 0 0 60px rgba(178, 107, 255, 0.04);
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.flagship-glyph {
|
||||
flex: none;
|
||||
color: #7cffb2;
|
||||
filter: drop-shadow(0 0 12px rgba(124, 255, 178, 0.45));
|
||||
animation: flagship-breathe 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flagship-glyph .shop-glyph-svg {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
}
|
||||
|
||||
@keyframes flagship-breathe {
|
||||
0%, 100% { filter: drop-shadow(0 0 8px rgba(124, 255, 178, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(178, 107, 255, 0.55)); }
|
||||
}
|
||||
|
||||
.flagship-tag {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.flagship-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.4rem, 3.4vw, 2.1rem);
|
||||
letter-spacing: 0.14em;
|
||||
color: #eafff3;
|
||||
margin: 0 0 1rem;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.flagship-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(216, 230, 220, 0.68);
|
||||
margin: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
.flagship-specs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.4rem;
|
||||
}
|
||||
|
||||
.flagship-specs li {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.75);
|
||||
background: rgba(124, 255, 178, 0.06);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 0.28rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ---- module grid ---- */
|
||||
|
||||
.shop-modules {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 4.5rem;
|
||||
}
|
||||
|
||||
.modules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.75rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.module-card:hover {
|
||||
border-color: rgba(124, 255, 178, 0.55);
|
||||
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.module-glyph {
|
||||
color: #7cffb2;
|
||||
transition: color 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
|
||||
.module-card:hover .module-glyph {
|
||||
color: #b26bff;
|
||||
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.02rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: #eafff3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.module-desc {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 220, 0.62);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-unlocks {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(216, 230, 220, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unlocks-label {
|
||||
color: rgba(178, 107, 255, 0.8);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.module-card .price-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ---- waitlist manifest ---- */
|
||||
|
||||
.shop-waitlist {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 4rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(178, 107, 255, 0.25);
|
||||
box-shadow: 0 0 40px rgba(178, 107, 255, 0.08);
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
.waitlist-desc {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: rgba(216, 230, 220, 0.6);
|
||||
margin: -1rem 0 1.8rem;
|
||||
}
|
||||
|
||||
.waitlist-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waitlist-input,
|
||||
.waitlist-select {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #d8e6dc;
|
||||
background: rgba(7, 7, 13, 0.8);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 0.7rem 0.9rem;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.waitlist-input {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waitlist-input::placeholder {
|
||||
color: rgba(216, 230, 220, 0.3);
|
||||
}
|
||||
|
||||
.waitlist-select {
|
||||
flex: 0 1 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.waitlist-input:focus-visible,
|
||||
.waitlist-select:focus-visible {
|
||||
outline: none;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 16px rgba(124, 255, 178, 0.2);
|
||||
}
|
||||
|
||||
.waitlist-submit {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
padding: 0.7rem 1.5rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 20px rgba(124, 255, 178, 0.3),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.waitlist-submit:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 36px rgba(124, 255, 178, 0.55),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.waitlist-submit:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.waitlist-submit:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.waitlist-message {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 1.4rem 0 0;
|
||||
animation: waitlist-in 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes waitlist-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.waitlist-message.is-success {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.waitlist-message.is-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 12px rgba(255, 59, 92, 0.4);
|
||||
}
|
||||
|
||||
/* ---- honesty footer ---- */
|
||||
|
||||
.shop-honesty {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-honesty p {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(216, 230, 220, 0.45);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- responsive + reduced motion ---- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shop-flagship {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flagship-specs {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shop-section-title::before,
|
||||
.shop-section-title::after {
|
||||
flex-basis: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shop-title,
|
||||
.flagship-glyph,
|
||||
.waitlist-message {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
349
frontend/src/pages/ShopPage.tsx
Normal file
349
frontend/src/pages/ShopPage.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
// The Armory — storefront for Quantumancy hardware. Flagship 'Ultimate
|
||||
// Quantum Box' hero, a grid of knock-louder modules, and the reservation
|
||||
// manifest (POST /api/shop/waitlist — public, no payment taken).
|
||||
// Honest by design: the site works fully without the hardware.
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import './ShopPage.css'
|
||||
|
||||
type ModuleId = 'emf-array' | 'geophone' | 'spirit-mic' | 'uv-illuminator'
|
||||
type Interest = 'quantum-box' | ModuleId
|
||||
type WaitlistState =
|
||||
| 'idle'
|
||||
| 'pending'
|
||||
| 'listed'
|
||||
| 'already_listed'
|
||||
| 'invalid'
|
||||
| 'crowded'
|
||||
| 'error'
|
||||
|
||||
type ModuleData = { key: string; name: string; desc: string; unlocks: string; price: string }
|
||||
|
||||
/** Module copy: i18n key stem (camelCase) plus the English defaultValue. */
|
||||
const MODULE_DATA: Record<ModuleId, ModuleData> = {
|
||||
'emf-array': {
|
||||
key: 'emfArray',
|
||||
name: 'EMF Whisker Array',
|
||||
desc: 'a bouquet of multi-axis field sensors, sniffing the air for intentions',
|
||||
unlocks: 'a richer, calibrated FIELD mode',
|
||||
price: 'est. $59 — pre-order',
|
||||
},
|
||||
geophone: {
|
||||
key: 'geophone',
|
||||
name: 'Geophone Puck',
|
||||
desc: 'hears footsteps in rooms where nobody is standing',
|
||||
unlocks: 'the vibration anomaly feed',
|
||||
price: 'est. $49 — pre-order',
|
||||
},
|
||||
'spirit-mic': {
|
||||
key: 'spiritMic',
|
||||
name: 'Spirit Mic Preamp',
|
||||
desc: 'lowers the noise floor so the whispering comes through cleaner',
|
||||
unlocks: 'cleaner EVP captures',
|
||||
price: 'est. $69 — pre-order',
|
||||
},
|
||||
'uv-illuminator': {
|
||||
key: 'uvIlluminator',
|
||||
name: 'UV/IR Illuminator',
|
||||
desc: 'paints the dark in spectrums the living were never meant to see',
|
||||
unlocks: 'night séances + camera work',
|
||||
price: 'est. $39 — pre-order',
|
||||
},
|
||||
}
|
||||
|
||||
const MODULES: readonly ModuleId[] = ['emf-array', 'geophone', 'spirit-mic', 'uv-illuminator']
|
||||
|
||||
/** Waitlist interests: value posted to the API → i18n key stem + fallback label. */
|
||||
const INTEREST_DATA: Record<Interest, { key: string; fallback: string }> = {
|
||||
'quantum-box': { key: 'quantumBox', fallback: 'the Ultimate Quantum Box' },
|
||||
'emf-array': { key: 'emfArray', fallback: 'EMF Whisker Array' },
|
||||
geophone: { key: 'geophone', fallback: 'Geophone Puck' },
|
||||
'spirit-mic': { key: 'spiritMic', fallback: 'Spirit Mic Preamp' },
|
||||
'uv-illuminator': { key: 'uvIlluminator', fallback: 'UV/IR Illuminator' },
|
||||
}
|
||||
|
||||
const INTERESTS: readonly Interest[] = [
|
||||
'quantum-box',
|
||||
'emf-array',
|
||||
'geophone',
|
||||
'spirit-mic',
|
||||
'uv-illuminator',
|
||||
]
|
||||
|
||||
/** Hacker-occult stroke glyphs for the hardware line (no emoji, no fonts). */
|
||||
function HardwareGlyph({ id }: { id: Interest }) {
|
||||
const common = {
|
||||
viewBox: '0 0 48 48',
|
||||
className: 'shop-glyph-svg',
|
||||
'aria-hidden': true as const,
|
||||
}
|
||||
switch (id) {
|
||||
case 'quantum-box':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<rect x="12" y="14" width="24" height="30" rx="2" />
|
||||
<rect x="17" y="20" width="14" height="8" rx="1" />
|
||||
<path d="M20 38 h8 M24 14 V7 M21 7 h6" />
|
||||
<path d="M17.5 24.5 l2.5 -2.5 2.5 2.5 2.5 -2.5 2.5 2.5" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'emf-array':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="24" cy="30" r="4" />
|
||||
<path d="M24 26 V12 M24 12 l-4 -4 M24 12 l4 -4" />
|
||||
<path d="M20.5 28.5 L10 18 M27.5 28.5 L38 18" />
|
||||
<path d="M15 36 a13 13 0 0 0 18 0 M11 40 a19 19 0 0 0 26 0" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'geophone':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<ellipse cx="24" cy="20" rx="11" ry="5" />
|
||||
<path d="M13 20 v6 c0 2.8 4.9 5 11 5 s11 -2.2 11 -5 v-6" />
|
||||
<path d="M24 31 v6" />
|
||||
<path d="M8 40 h32 M12 40 l3 -3 M20 40 l3 -3 M28 40 l3 -3 M36 40 l3 -3" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'spirit-mic':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<rect x="19" y="6" width="10" height="18" rx="5" />
|
||||
<path d="M14 20 a10 10 0 0 0 20 0 M24 30 v6 M18 36 h12" />
|
||||
<path d="M8 13 v4 M40 13 v4" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'uv-illuminator':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 10 h12 l3 8 h-18 Z" />
|
||||
<path d="M24 18 v6" />
|
||||
<path d="M12 32 l6 -6 M24 34 v-8 M36 32 l-6 -6" />
|
||||
<path d="M10 40 h28" strokeWidth="1.4" />
|
||||
<path d="M15 40 l2 -3 M22 40 l2 -3 M29 40 l2 -3 M36 40 l2 -3" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ShopPage() {
|
||||
const { t } = useTranslation()
|
||||
const [email, setEmail] = useState('')
|
||||
const [interest, setInterest] = useState<Interest>('quantum-box')
|
||||
const [state, setState] = useState<WaitlistState>('idle')
|
||||
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (state === 'pending') return
|
||||
setState('pending')
|
||||
fetch('/api/shop/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, interest }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 201) {
|
||||
const data = (await res.json()) as { status?: string }
|
||||
setState(data.status === 'already_listed' ? 'already_listed' : 'listed')
|
||||
} else if (res.status === 422) {
|
||||
setState('invalid')
|
||||
} else if (res.status === 429) {
|
||||
setState('crowded')
|
||||
} else {
|
||||
setState('error')
|
||||
}
|
||||
})
|
||||
.catch(() => setState('error'))
|
||||
}
|
||||
|
||||
const message =
|
||||
state === 'listed'
|
||||
? t('shop.waitlist.listed', {
|
||||
defaultValue: 'you are on the manifest — we will reach through when the boxes ship',
|
||||
})
|
||||
: state === 'already_listed'
|
||||
? t('shop.waitlist.alreadyListed', {
|
||||
defaultValue: 'you are already on the manifest',
|
||||
})
|
||||
: state === 'invalid'
|
||||
? t('shop.waitlist.invalid', { defaultValue: 'that address does not reach us' })
|
||||
: state === 'crowded'
|
||||
? t('shop.waitlist.crowded', {
|
||||
defaultValue: 'the veil is crowded — try again later',
|
||||
})
|
||||
: state === 'error'
|
||||
? t('shop.waitlist.error', {
|
||||
defaultValue: 'something stirred in the static — your name was not carved',
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="shop-page">
|
||||
<div className="shop-overlay" aria-hidden="true" />
|
||||
|
||||
<header className="shop-header">
|
||||
<Link to="/" className="shop-back">
|
||||
{t('shop.back', { defaultValue: '← the threshold' })}
|
||||
</Link>
|
||||
<h1 className="shop-title">{t('shop.title', { defaultValue: 'THE ARMORY' })}</h1>
|
||||
<p className="shop-subtitle">
|
||||
{t('shop.subtitle', { defaultValue: 'instruments for those who would knock louder' })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="shop-flagship"
|
||||
aria-label={t('shop.flagship.name', { defaultValue: 'THE ULTIMATE QUANTUM BOX' })}
|
||||
>
|
||||
<div className="flagship-glyph">
|
||||
<HardwareGlyph id="quantum-box" />
|
||||
</div>
|
||||
<div className="flagship-body">
|
||||
<p className="flagship-tag">
|
||||
{t('shop.flagship.tag', { defaultValue: 'flagship instrument' })}
|
||||
</p>
|
||||
<h2 className="flagship-name">
|
||||
{t('shop.flagship.name', { defaultValue: 'THE ULTIMATE QUANTUM BOX' })}
|
||||
</h2>
|
||||
<p className="flagship-desc">
|
||||
{t('shop.flagship.desc', {
|
||||
defaultValue:
|
||||
'An ESP32-P4/C6 handheld séance instrument — thermal camera, EMF whisker array, geophone, spirit-box mic preamp and a little OLED face that stares back. It signs into the same Quantumancy account you already haunt and unlocks the hardware-driven modes of this site: thermal apparition maps, vibration anomaly feeds, calibrated EMF.',
|
||||
})}
|
||||
</p>
|
||||
<ul className="flagship-specs">
|
||||
<li>{t('shop.flagship.specs.thermal', { defaultValue: 'thermal camera' })}</li>
|
||||
<li>{t('shop.flagship.specs.emf', { defaultValue: 'EMF whisker array' })}</li>
|
||||
<li>{t('shop.flagship.specs.geophone', { defaultValue: 'geophone' })}</li>
|
||||
<li>{t('shop.flagship.specs.mic', { defaultValue: 'spirit-box mic preamp' })}</li>
|
||||
<li>{t('shop.flagship.specs.oled', { defaultValue: 'OLED display' })}</li>
|
||||
</ul>
|
||||
<p className="price-tag">
|
||||
{t('shop.flagship.price', { defaultValue: 'est. $249 — pre-order' })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="shop-modules"
|
||||
aria-label={t('shop.modulesTitle', { defaultValue: 'MODULES THAT KNOCK' })}
|
||||
>
|
||||
<h2 className="shop-section-title">
|
||||
{t('shop.modulesTitle', { defaultValue: 'MODULES THAT KNOCK' })}
|
||||
</h2>
|
||||
<div className="modules-grid">
|
||||
{MODULES.map((id) => {
|
||||
const m = MODULE_DATA[id]
|
||||
return (
|
||||
<article className="module-card" key={id}>
|
||||
<div className="module-glyph">
|
||||
<HardwareGlyph id={id} />
|
||||
</div>
|
||||
<h3 className="module-name">
|
||||
{t(`shop.modules.${m.key}.name`, { defaultValue: m.name })}
|
||||
</h3>
|
||||
<p className="module-desc">
|
||||
{t(`shop.modules.${m.key}.desc`, { defaultValue: m.desc })}
|
||||
</p>
|
||||
<p className="module-unlocks">
|
||||
<span className="unlocks-label">
|
||||
{t('shop.unlocksLabel', { defaultValue: 'unlocks:' })}
|
||||
</span>{' '}
|
||||
{t(`shop.modules.${m.key}.unlocks`, { defaultValue: m.unlocks })}
|
||||
</p>
|
||||
<p className="price-tag">
|
||||
{t(`shop.modules.${m.key}.price`, { defaultValue: m.price })}
|
||||
</p>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="shop-waitlist"
|
||||
aria-label={t('shop.waitlist.title', { defaultValue: 'THE MANIFEST' })}
|
||||
>
|
||||
<h2 className="shop-section-title">
|
||||
{t('shop.waitlist.title', { defaultValue: 'THE MANIFEST' })}
|
||||
</h2>
|
||||
<p className="waitlist-desc">
|
||||
{t('shop.waitlist.desc', {
|
||||
defaultValue:
|
||||
'leave an address and an obsession — when the first boxes ship, we reach through to the names on this list first',
|
||||
})}
|
||||
</p>
|
||||
<form className="waitlist-form" onSubmit={submit}>
|
||||
<label className="visually-hidden" htmlFor="waitlist-email">
|
||||
{t('shop.waitlist.emailLabel', { defaultValue: 'email address' })}
|
||||
</label>
|
||||
<input
|
||||
id="waitlist-email"
|
||||
className="waitlist-input"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('shop.waitlist.emailPlaceholder', {
|
||||
defaultValue: 'you@somewhere.in.the.static',
|
||||
})}
|
||||
/>
|
||||
<label className="visually-hidden" htmlFor="waitlist-interest">
|
||||
{t('shop.waitlist.interestLabel', { defaultValue: 'interest' })}
|
||||
</label>
|
||||
<select
|
||||
id="waitlist-interest"
|
||||
className="waitlist-select"
|
||||
value={interest}
|
||||
onChange={(e) => setInterest(e.target.value as Interest)}
|
||||
>
|
||||
{INTERESTS.map((id) => (
|
||||
<option value={id} key={id}>
|
||||
{t(`shop.interests.${INTEREST_DATA[id].key}`, {
|
||||
defaultValue: INTEREST_DATA[id].fallback,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="waitlist-submit" type="submit" disabled={state === 'pending'}>
|
||||
{state === 'pending'
|
||||
? t('shop.waitlist.pending', { defaultValue: 'carving your name…' })
|
||||
: t('shop.waitlist.submit', { defaultValue: 'join the manifest' })}
|
||||
</button>
|
||||
</form>
|
||||
{message !== null && (
|
||||
<p
|
||||
className={`waitlist-message ${
|
||||
state === 'listed' || state === 'already_listed' ? 'is-success' : 'is-error'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="shop-honesty">
|
||||
<p>
|
||||
{t('shop.honesty', {
|
||||
defaultValue:
|
||||
'the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder.',
|
||||
})}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export type Utterance = {
|
||||
|
||||
export type AnomalyEntry = {
|
||||
id: number
|
||||
source: 'radio' | 'evp' | 'wire'
|
||||
source: 'radio' | 'evp' | 'wire' | 'emf'
|
||||
frequency: number
|
||||
magnitude: number
|
||||
at: number
|
||||
@@ -101,7 +101,7 @@ const TOAST_CAP = 4
|
||||
export type SeanceAction =
|
||||
| { type: 'connection'; state: VeilConnectionState }
|
||||
| { type: 'frame'; frame: ServerFrame; at?: number }
|
||||
| { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number; at?: number }
|
||||
| { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number; at?: number }
|
||||
| { type: 'local_question'; text: string; at?: number }
|
||||
| { type: 'set_mode'; mode: Mode }
|
||||
| { type: 'set_language'; language: Language }
|
||||
@@ -377,7 +377,7 @@ export type SeanceApi = {
|
||||
setLanguage: (language: Language) => void
|
||||
summon: () => void
|
||||
ask: (text: string) => void
|
||||
sendAnomaly: (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => void
|
||||
sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void
|
||||
playUtterance: (utteranceId: string) => void
|
||||
dismissToast: (id: number) => void
|
||||
}
|
||||
@@ -450,7 +450,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const sendAnomaly = useCallback(
|
||||
(source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => {
|
||||
(source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => {
|
||||
dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() })
|
||||
socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude })
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { GhostScene } from './GhostScene'
|
||||
import type { GhostMood, GhostVisualState } from './GhostScene'
|
||||
import { hauntBus } from '../lib/haunting'
|
||||
|
||||
export type GhostCanvasProps = {
|
||||
visual: GhostVisualState | null
|
||||
@@ -26,7 +27,10 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
|
||||
const ampTimer = window.setInterval(() => {
|
||||
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
||||
}, 50)
|
||||
// Apparition flares from the haunting layer surge the ghost.
|
||||
const offFlare = hauntBus.on('flare', ({ strength }) => scene.flare(strength))
|
||||
return () => {
|
||||
offFlare()
|
||||
window.clearInterval(ampTimer)
|
||||
scene.dispose()
|
||||
sceneRef.current = null
|
||||
|
||||
@@ -234,6 +234,10 @@ export class GhostScene {
|
||||
private smoothedAmp = 0
|
||||
private energy = 0.15
|
||||
private disposed = false
|
||||
private flareAmp = 0
|
||||
private lastT = 0
|
||||
private baseScale = 1
|
||||
private baseOpacity = 1
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
@@ -373,6 +377,14 @@ export class GhostScene {
|
||||
this.speakAmp = Math.max(0, Math.min(1, amp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Brief apparition surge (strength 0..1): brightness, scale and agitation
|
||||
* spike, decaying back to normal over ~1.2s. Stacking flares take the max.
|
||||
*/
|
||||
flare(strength: number): void {
|
||||
this.flareAmp = Math.max(this.flareAmp, Math.max(0, Math.min(1, strength)))
|
||||
}
|
||||
|
||||
setVisual(visual: GhostVisualState | null): void {
|
||||
this.visual = visual
|
||||
if (!visual) {
|
||||
@@ -391,6 +403,8 @@ export class GhostScene {
|
||||
this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7)
|
||||
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
|
||||
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4)
|
||||
this.baseScale = params.scale
|
||||
this.baseOpacity = params.opacity
|
||||
this.ghost.scale.setScalar(params.scale)
|
||||
this.ghost.visible = true
|
||||
this.wisps.visible = true
|
||||
@@ -417,24 +431,33 @@ export class GhostScene {
|
||||
|
||||
private renderFrame(): void {
|
||||
const t = this.clock.getElapsedTime()
|
||||
const dt = Math.min(0.1, Math.max(0, t - this.lastT))
|
||||
this.lastT = t
|
||||
|
||||
// Energy target from mood; smooth toward it.
|
||||
// Apparition surge decays linearly over ~1.2s.
|
||||
this.flareAmp = Math.max(0, this.flareAmp - dt / 1.2)
|
||||
|
||||
// Energy target from mood; smooth toward it. A flare agitates on top.
|
||||
const target =
|
||||
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15
|
||||
this.energy += (target - this.energy) * 0.04
|
||||
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
|
||||
const flaredEnergy = Math.min(1, this.energy + this.flareAmp * 0.55)
|
||||
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uTime.value = t
|
||||
ghostU.uEnergy.value = this.energy
|
||||
ghostU.uEnergy.value = flaredEnergy
|
||||
ghostU.uSpeak.value = this.smoothedAmp
|
||||
ghostU.uOpacity.value = Math.min(1.8, this.baseOpacity * (1 + this.flareAmp * 0.9))
|
||||
this.wispMat.uniforms.uTime.value = t
|
||||
this.wispMat.uniforms.uEnergy.value = this.energy
|
||||
this.wispMat.uniforms.uEnergy.value = flaredEnergy
|
||||
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
|
||||
this.wispMat.uniforms.uOpacity.value = 0.55 * (1 + this.flareAmp * 0.9)
|
||||
this.mistMat.uniforms.uTime.value = t
|
||||
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
|
||||
|
||||
// Idle drift / slow camera wander.
|
||||
// Idle drift / slow camera wander; the flare swells the apparition.
|
||||
this.ghost.scale.setScalar(this.baseScale * (1 + this.flareAmp * 0.14))
|
||||
this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
|
||||
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
|
||||
this.veil.position.y = this.ghost.position.y - 0.3
|
||||
|
||||
1
frontend/src/types/webusb.d.ts
vendored
1
frontend/src/types/webusb.d.ts
vendored
@@ -34,6 +34,7 @@ interface USBDevice {
|
||||
readonly configuration: USBConfiguration | null
|
||||
open(): Promise<void>
|
||||
close(): Promise<void>
|
||||
selectConfiguration(configurationValue: number): Promise<void>
|
||||
claimInterface(interfaceNumber: number): Promise<void>
|
||||
releaseInterface(interfaceNumber: number): Promise<void>
|
||||
selectAlternateInterface(
|
||||
|
||||
Reference in New Issue
Block a user