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

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

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

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

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

View File

@@ -81,6 +81,13 @@ def fragment_prompt(source: str, anomaly: dict, language: str = "en") -> str:
f"{anomaly.get('magnitude', '???')} dB over the room's floor). " f"{anomaly.get('magnitude', '???')} dB over the room's floor). "
"What single word was hidden in it?" "What single word was hidden in it?"
) )
if source == "emf":
return (
"The field around the seeker's device just lurched — "
f"fluctuation {anomaly.get('magnitude', '???')} above baseline "
f"near {anomaly.get('frequency', '???')} µT. Something passed "
"through. What word did it leave?"
)
return "A pulse moves through the wires. What word does it carry?" return "A pulse moves through the wires. What word does it carry?"

View File

@@ -67,7 +67,12 @@ class SpiritService:
settings.ollama_fast_model, settings.ollama_fast_model,
prompts.fragment_prompt(source, anomaly, language), prompts.fragment_prompt(source, anomaly, language),
system=prompts.fragment_system( system=prompts.fragment_system(
"a spirit box" if source == "radio" else "an EVP recorder", language {
"radio": "a spirit box",
"evp": "an EVP recorder",
"emf": "an EMF field meter",
}.get(source, "the veil"),
language,
), ),
options={"num_predict": 16, "temperature": 0.95}, options={"num_predict": 16, "temperature": 0.95},
) )

View File

@@ -10,6 +10,7 @@ from app.config import settings
from app.db import Base, engine from app.db import Base, engine
from app.routes.auth import router as auth_router from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router from app.routes.codex import router as codex_router
from app.routes.shop import router as shop_router
from app.ws import AUDIO_DIR from app.ws import AUDIO_DIR
from app.ws import router as ws_router from app.ws import router as ws_router
@@ -27,6 +28,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Quantumancy", lifespan=lifespan) app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(codex_router) app.include_router(codex_router)
app.include_router(shop_router)
app.include_router(ws_router) app.include_router(ws_router)

View File

@@ -4,6 +4,7 @@ from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting from app.models.entity_sighting import EntitySighting
from app.models.event import Event from app.models.event import Event
from app.models.user import User from app.models.user import User
from app.models.waitlist_entry import WaitlistEntry
__all__ = [ __all__ = [
"User", "User",
@@ -12,4 +13,5 @@ __all__ = [
"Entity", "Entity",
"EntitySighting", "EntitySighting",
"Event", "Event",
"WaitlistEntry",
] ]

View File

@@ -0,0 +1,20 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class WaitlistEntry(Base):
"""A seeker who wants the Ultimate Quantum Box hardware when it ships."""
__tablename__ = "waitlist_entries"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
interest: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,50 @@
"""Shop endpoints: the waitlist for the Ultimate Quantum Box hardware."""
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models.waitlist_entry import WaitlistEntry
from app.rate_limit import RateLimiter
router = APIRouter(prefix="/api/shop", tags=["shop"])
waitlist_limiter = RateLimiter(max_requests=5, window_seconds=3600)
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
@router.post("/waitlist", status_code=status.HTTP_201_CREATED)
async def join_waitlist(
payload: dict, request: Request, db: AsyncSession = Depends(get_db)
):
email = str(payload.get("email", "")).strip().lower()
interest = payload.get("interest")
if not _EMAIL_RE.match(email):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
)
client_ip = request.client.host if request.client else "unknown"
if not waitlist_limiter.allow(client_ip):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"the veil is crowded — try again later",
)
existing = await db.scalar(
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
if existing is not None:
# Idempotent: re-registering the same address is a no-op.
return {"status": "already_listed", "email": email}
entry = WaitlistEntry(
email=email, interest=str(interest)[:64] if interest else None
)
db.add(entry)
await db.commit()
return {"status": "listed", "email": email}

View File

@@ -46,7 +46,7 @@ router = APIRouter()
# Alias so tests can swap in the NullPool test session maker. # Alias so tests can swap in the NullPool test session maker.
session_maker = _default_session_maker session_maker = _default_session_maker
MODES = {"wire", "evp", "radio", "ouija"} MODES = {"wire", "evp", "radio", "ouija", "emf"}
# Per-user limiters for every LLM-triggering message type (spec §5). # Per-user limiters for every LLM-triggering message type (spec §5).
fragment_limiter = RateLimiter(max_requests=30, window_seconds=60) fragment_limiter = RateLimiter(max_requests=30, window_seconds=60)

View File

@@ -0,0 +1,29 @@
import pytest
@pytest.mark.asyncio
async def test_waitlist_join_and_idempotent_rejoin(client):
response = await client.post(
"/api/shop/waitlist", json={"email": "hunter@example.com", "interest": "quantum-box"}
)
assert response.status_code == 201
assert response.json()["status"] == "listed"
again = await client.post(
"/api/shop/waitlist", json={"email": "hunter@example.com"}
)
assert again.status_code == 201
assert again.json()["status"] == "already_listed"
@pytest.mark.asyncio
async def test_waitlist_rejects_bad_email(client):
response = await client.post("/api/shop/waitlist", json={"email": "not-an-email"})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_waitlist_is_public(client):
# No auth cookie — the shop is open to every passing seeker.
response = await client.post("/api/shop/waitlist", json={"email": "ghost@example.org"})
assert response.status_code == 201

View File

@@ -3,11 +3,13 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './state/auth' import { AuthProvider } from './state/auth'
import { HauntingLayer } from './components/HauntingLayer'
import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage' import { CodexPage } from './pages/CodexPage'
import { EnterPage } from './pages/EnterPage' import { EnterPage } from './pages/EnterPage'
import { LandingPage } from './pages/LandingPage' import { LandingPage } from './pages/LandingPage'
import { SeancePage } from './pages/SeancePage' import { SeancePage } from './pages/SeancePage'
import { ShopPage } from './pages/ShopPage'
function App() { function App() {
return ( return (
@@ -17,12 +19,14 @@ function App() {
<div className="crt-scanlines" /> <div className="crt-scanlines" />
<div className="crt-vignette" /> <div className="crt-vignette" />
</div> </div>
<HauntingLayer />
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/enter" element={<EnterPage />} /> <Route path="/enter" element={<EnterPage />} />
<Route path="/seance" element={<SeancePage />} /> <Route path="/seance" element={<SeancePage />} />
<Route path="/codex" element={<CodexPage />} /> <Route path="/codex" element={<CodexPage />} />
<Route path="/codex/:id" element={<CodexEntityPage />} /> <Route path="/codex/:id" element={<CodexEntityPage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="*" element={<LandingPage />} /> <Route path="*" element={<LandingPage />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -43,7 +43,7 @@ export function adaptiveMax(values: number[], floor: number): number {
return max * 1.15 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 { export function anomalyColor(source: AnomalyEntry['source']): string {
switch (source) { switch (source) {
case 'wire': case 'wire':
@@ -52,6 +52,8 @@ export function anomalyColor(source: AnomalyEntry['source']): string {
return '#b26bff' return '#b26bff'
case 'radio': case 'radio':
return '#ff9d4a' return '#ff9d4a'
case 'emf':
return '#ffd24a'
} }
} }

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { PlanchetteMachine } from '../lib/planchette' import { PlanchetteMachine } from '../lib/planchette'
import { armAudioOnFirstGesture, createTick, getAudioContextCtor } from '../lib/haunting'
type Point = { x: number; y: number } type Point = { x: number; y: number }
@@ -95,6 +96,21 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
let initialized = false let initialized = false
const smoke: Smoke[] = [] 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 resize = () => {
const parent = canvas.parentElement const parent = canvas.parentElement
if (!parent) return if (!parent) return
@@ -136,6 +152,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
const accent = `hsl(${hueDeg} 90% 72%)` const accent = `hsl(${hueDeg} 90% 72%)`
const t = now / 1000 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 --- // --- target: spelled letter, else idle wander ---
let target: Point | null = null let target: Point | null = null
if (snap.current) target = letterTarget(snap.current) if (snap.current) target = letterTarget(snap.current)
@@ -209,13 +234,14 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
// Letters + numbers. // Letters + numbers.
const hovered = snap.current 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` ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
for (const [ch, p] of layout.letters) { 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` ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
for (const [ch, p] of layout.numbers) { 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). // Smoke (additive).
@@ -262,6 +288,8 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
return () => { return () => {
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
ro.disconnect() ro.disconnect()
disarmTick()
if (tickCtx && tickCtx.state !== 'closed') void tickCtx.close().catch(() => undefined)
} }
}, []) }, [])
@@ -294,19 +322,26 @@ function drawGlyph(
ch: string, ch: string,
p: Point, p: Point,
hovered: boolean, hovered: boolean,
dwell: boolean,
accent: string, accent: string,
hueDeg: number, hueDeg: number,
): void { ): void {
if (hovered) { if (hovered) {
ctx.save() 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.shadowColor = accent
ctx.shadowBlur = 16 ctx.shadowBlur = dwell ? 28 : 16
ctx.fillStyle = accent ctx.fillStyle = accent
ctx.fillText(ch, p.x, p.y) ctx.fillText(ch, p.x, p.y)
ctx.restore() ctx.restore()
ctx.beginPath() ctx.beginPath()
ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)` ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / ${dwell ? 0.85 : 0.5})`
ctx.arc(p.x, p.y, 15, 0, Math.PI * 2) ctx.arc(p.x, p.y, dwell ? 19 : 15, 0, Math.PI * 2)
ctx.stroke() ctx.stroke()
} else { } else {
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)` ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`

View File

@@ -10,9 +10,11 @@ export type TranscriptProps = {
} }
function fmtFreq(source: string, f: number): string { 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 === 'evp') return `${f.toFixed(0)}Hz`
if (source === 'radio') return `${f.toFixed(1)}MHz` 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` return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
} }

View 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')

View File

@@ -12,6 +12,7 @@
"nav": { "nav": {
"home": "the threshold", "home": "the threshold",
"codex": "codex", "codex": "codex",
"shop": "shop",
"seance": "séance", "seance": "séance",
"enter": "enter", "enter": "enter",
"logout": "depart" "logout": "depart"
@@ -27,6 +28,7 @@
"heroKicker": "an instrument for speaking with the dead bandwidth", "heroKicker": "an instrument for speaking with the dead bandwidth",
"cta": "BEGIN THE SÉANCE", "cta": "BEGIN THE SÉANCE",
"ctaCodex": "browse the codex", "ctaCodex": "browse the codex",
"ctaShop": "visit the armory",
"stats": { "stats": {
"entities": "spirits catalogued", "entities": "spirits catalogued",
"sessions": "séances held", "sessions": "séances held",
@@ -55,6 +57,11 @@
}, },
"featured": "most disturbed spirits", "featured": "most disturbed spirits",
"featuredEmpty": "the registry is still blank — be the first to disturb it", "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", "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" "footerColophon": "self-hosted · no cloud · the veil runs on your machine"
}, },
@@ -77,7 +84,8 @@
"wire": "Wire Ghost", "wire": "Wire Ghost",
"evp": "EVP", "evp": "EVP",
"radio": "Spirit Radio", "radio": "Spirit Radio",
"ouija": "Ouija" "ouija": "Ouija",
"emf": "FIELD"
}, },
"passive": "passive listening", "passive": "passive listening",
"summon": "SUMMON", "summon": "SUMMON",
@@ -132,14 +140,30 @@
"unsupportedTitle": "this vessel cannot hear the radio dead", "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.", "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", "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", "noDevice": "no dongle answered the call",
"sweeping": "sweeping {{start}}–{{end}} MHz…", "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": { "ouija": {
"spelling": "the planchette spells", "spelling": "the planchette spells",
"drift": "the planchette drifts, waiting for words — ask below, or summon." "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": { "entity": {
"title": "DOSSIER OF THE PRESENT", "title": "DOSSIER OF THE PRESENT",
"none": "no spirit summoned yet", "none": "no spirit summoned yet",
@@ -240,5 +264,92 @@
"empty": "no spirits match this filter — the registry keeps its secrets", "empty": "no spirits match this filter — the registry keeps its secrets",
"back": "← back to the codex", "back": "← back to the codex",
"notFound": "this spirit is not in the registry" "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…"
}
} }
} }

View File

@@ -12,6 +12,7 @@
"nav": { "nav": {
"home": "el umbral", "home": "el umbral",
"codex": "códice", "codex": "códice",
"shop": "arsenal",
"seance": "sesión", "seance": "sesión",
"enter": "entrar", "enter": "entrar",
"logout": "partir" "logout": "partir"
@@ -27,6 +28,7 @@
"heroKicker": "un instrumento para hablar con el ancho de banda muerto", "heroKicker": "un instrumento para hablar con el ancho de banda muerto",
"cta": "COMENZAR LA SESIÓN", "cta": "COMENZAR LA SESIÓN",
"ctaCodex": "hojear el códice", "ctaCodex": "hojear el códice",
"ctaShop": "visitar el arsenal",
"stats": { "stats": {
"entities": "espíritus catalogados", "entities": "espíritus catalogados",
"sessions": "sesiones celebradas", "sessions": "sesiones celebradas",
@@ -55,6 +57,11 @@
}, },
"featured": "los espíritus más perturbados", "featured": "los espíritus más perturbados",
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero", "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", "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" "footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
}, },
@@ -77,7 +84,8 @@
"wire": "Fantasma del Cable", "wire": "Fantasma del Cable",
"evp": "EVP", "evp": "EVP",
"radio": "Radio Espíritu", "radio": "Radio Espíritu",
"ouija": "Ouija" "ouija": "Ouija",
"emf": "CAMPO"
}, },
"passive": "escucha pasiva", "passive": "escucha pasiva",
"summon": "INVOCAR", "summon": "INVOCAR",
@@ -132,14 +140,30 @@
"unsupportedTitle": "este recipiente no oye a los muertos de la radio", "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.", "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", "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", "noDevice": "ningún dongle respondió al llamado",
"sweeping": "barriendo {{start}}–{{end}} MHz…", "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": { "ouija": {
"spelling": "la planchette deletrea", "spelling": "la planchette deletrea",
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca." "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": { "entity": {
"title": "DOSIER DEL PRESENTE", "title": "DOSIER DEL PRESENTE",
"none": "ningún espíritu invocado todavía", "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", "empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
"back": "← volver al códice", "back": "← volver al códice",
"notFound": "este espíritu no figura en el registro" "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…"
}
} }
} }

View 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
View 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())
}
}
}

View 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()
})
})

View 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 */
}
}
}

View File

@@ -29,6 +29,9 @@ export type SweepCallbacks = {
// USB identification for RTL2832U dongles. // USB identification for RTL2832U dongles.
export const RTL2832U_VENDOR = 0x0bda export const RTL2832U_VENDOR = 0x0bda
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837] 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. // Request types used by librtlsdr.
const CTRL_IN = 0xc0 const CTRL_IN = 0xc0
@@ -38,8 +41,14 @@ const USB_EPA = 0x02
const SYS = 0x09 const SYS = 0x09
const PAGE_USB = 0x01 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 { export function isSupported(): boolean {
return ( return (
isSecureContext() &&
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
'usb' in navigator && 'usb' in navigator &&
typeof navigator.usb?.requestDevice === 'function' typeof navigator.usb?.requestDevice === 'function'
@@ -62,12 +71,9 @@ export class RtlSdr {
if (!isSupported()) { if (!isSupported()) {
throw new Error('WebUSB is not available in this vessel (Chromium required)') throw new Error('WebUSB is not available in this vessel (Chromium required)')
} }
const filters = [ // Vendor-only filters: many dongles report product ids outside the
{ vendorId: RTL2832U_VENDOR, productId: 0x2832 }, // handful we know, so filtering by productId hides them from the picker.
{ vendorId: RTL2832U_VENDOR, productId: 0x2834 }, const filters = [{ vendorId: RTL2832U_VENDOR }, { vendorId: TERRATEC_VENDOR }]
{ vendorId: RTL2832U_VENDOR, productId: 0x2838 },
{ vendorId: RTL2832U_VENDOR, productId: 0x2837 },
]
this.device = await navigator.usb.requestDevice({ filters }) this.device = await navigator.usb.requestDevice({ filters })
} }
@@ -78,6 +84,17 @@ export class RtlSdr {
this.sampleRate = sampleRateHz this.sampleRate = sampleRateHz
await dev.open() 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. // Find the first bulk-IN endpoint.
const iface = dev.configuration?.interfaces[0] const iface = dev.configuration?.interfaces[0]
const alt = iface?.alternates[0] const alt = iface?.alternates[0]

View File

@@ -1,7 +1,7 @@
// Shared protocol + domain types for the Quantumancy frontend. // Shared protocol + domain types for the Quantumancy frontend.
// Mirrors the backend contract exactly — do not invent changes. // 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 Language = 'en' | 'es'
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic' export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade' export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
@@ -68,7 +68,7 @@ export type ClientFrame =
| { type: 'set_mode'; mode: Mode } | { type: 'set_mode'; mode: Mode }
| { type: 'language'; language: Language } | { type: 'language'; language: Language }
| { type: 'summon' } | { 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: 'question'; text: string }
| { type: 'passive'; enabled: boolean } | { type: 'passive'; enabled: boolean }
@@ -90,7 +90,7 @@ export type ServerFrame =
| { type: 'status'; state: SessionStatus } | { type: 'status'; state: SessionStatus }
| { type: 'entity'; is_new: boolean; entity: SpiritEntity } | { type: 'entity'; is_new: boolean; entity: SpiritEntity }
| { type: 'anomaly_ack'; count: number } | { 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: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
| { type: 'audio'; id: string; url: string } | { type: 'audio'; id: string; url: string }
| { type: 'reply_start' } | { type: 'reply_start' }

View File

@@ -437,6 +437,64 @@
font-size: 0.7rem; 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 ---------- */ /* ---------- responsive + reduced motion ---------- */
@media (max-width: 720px) { @media (max-width: 720px) {

View File

@@ -278,9 +278,14 @@ export function LandingPage() {
> >
{t('landing.cta')} {t('landing.cta')}
</button> </button>
<Link className="cta-secondary" to="/codex"> <div className="hero-secondary-links">
{t('landing.ctaCodex')} <Link className="cta-secondary" to="/codex">
</Link> {t('landing.ctaCodex')}
</Link>
<Link className="cta-secondary" to="/shop">
{t('landing.ctaShop', { defaultValue: 'visit the armory' })}
</Link>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -338,6 +343,23 @@ export function LandingPage() {
</section> </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"> <footer className="landing-footer">
<p>{t('landing.footer')}</p> <p>{t('landing.footer')}</p>
<p className="footer-colophon">{t('landing.footerColophon')}</p> <p className="footer-colophon">{t('landing.footerColophon')}</p>

View File

@@ -749,14 +749,16 @@
.evp-panel, .evp-panel,
.radio-panel, .radio-panel,
.ouija-panel, .ouija-panel,
.wire-panel { .wire-panel,
.emf-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.55rem; gap: 0.55rem;
} }
.evp-scope, .evp-scope,
.radio-waterfall { .radio-waterfall,
.emf-gauge {
width: 100%; width: 100%;
border: 1px solid rgba(124, 255, 178, 0.18); border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 4px; border-radius: 4px;
@@ -772,8 +774,13 @@
height: 110px; height: 110px;
} }
.emf-gauge {
height: 140px;
}
.evp-readout, .evp-readout,
.radio-readout { .radio-readout,
.emf-readout {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
@@ -798,6 +805,70 @@
color: rgba(211, 233, 219, 0.75); 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 { .rec-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;

View File

@@ -1,5 +1,5 @@
// The main séance screen: ghost backdrop, planchette board, mode panels // 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: // Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine // - 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 { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp' import { EvpListener } from '../lib/evp'
import { EmfSensorListener } from '../lib/emf'
import { import {
isSecureContext,
isSupported, isSupported,
RtlSdr, RtlSdr,
SpectrumAnomalyDetector, SpectrumAnomalyDetector,
@@ -36,7 +38,9 @@ import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types' import type { Language, Mode } from '../lib/types'
import './SeancePage.css' 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. // Board-area views; labels fall back to these until the i18n JSONs land.
const VIEWS = [ const VIEWS = [
@@ -207,7 +211,7 @@ function SeanceSession({ username }: { username: string }) {
className={`mode-tab ${state.mode === m ? 'active' : ''}`} className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)} onClick={() => setMode(m)}
> >
{t(`seance.modes.${m}`)} {t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
</button> </button>
))} ))}
</nav> </nav>
@@ -240,6 +244,10 @@ function SeanceSession({ username }: { username: string }) {
</button> </button>
</div> </div>
<Link to="/shop" className="seance-codex-link">
{t('nav.shop', { defaultValue: 'shop' })}
</Link>
<Link to="/codex" className="seance-codex-link"> <Link to="/codex" className="seance-codex-link">
{t('nav.codex')} {t('nav.codex')}
</Link> </Link>
@@ -417,6 +425,7 @@ function SeanceSession({ username }: { username: string }) {
{state.mode === 'evp' && <EvpPanel />} {state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />} {state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />} {state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
{state.mode === 'emf' && <EmfPanel />}
</section> </section>
</aside> </aside>
</main> </main>
@@ -679,9 +688,11 @@ function EvpPanel() {
function RadioPanel() { function RadioPanel() {
const { sendAnomaly } = useSeance() const { sendAnomaly } = useSeance()
const { t } = useTranslation() const { t } = useTranslation()
// WebUSB hides itself entirely outside secure ground (https / localhost).
const secure = useMemo(() => isSecureContext(), [])
const supported = useMemo(() => isSupported(), []) const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle') 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 [tunedMhz, setTunedMhz] = useState<number | null>(null)
const sdrRef = useRef<RtlSdr | null>(null) const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null) const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
@@ -743,12 +754,15 @@ function RadioPanel() {
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
const name = err instanceof DOMException ? err.name : '' 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( setError(
name === 'NotFoundError' || /no device selected/i.test(msg) name === 'NotFoundError' || /no device selected/i.test(msg)
? t('seance.radio.noDevice') ? { kind: 'cancelled' }
: /claim/i.test(msg) : name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
? t('seance.radio.claimFailed') ? { kind: 'claim' }
: msg, : { kind: 'other', message: msg },
) )
setPhase('idle') setPhase('idle')
void sdr.close() void sdr.close()
@@ -774,7 +788,7 @@ function RadioPanel() {
} }
}, },
onError: (err) => { onError: (err) => {
if (sdrRef.current === sdr) setError(err.message) if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
}, },
}) })
.then(() => { .then(() => {
@@ -782,12 +796,34 @@ function RadioPanel() {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (sdrRef.current === sdr) { 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') 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) { if (!supported) {
return ( return (
<div className="panel-notice"> <div className="panel-notice">
@@ -816,9 +852,29 @@ function RadioPanel() {
</span> </span>
)} )}
</div> </div>
{error && ( {error?.kind === 'cancelled' && (
<div className="panel-notice">
<p>{t('seance.radio.noDevice')}</p>
</div>
)}
{error?.kind === 'claim' && (
<div className="panel-notice error"> <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> </div>
)} )}
<button <button
@@ -872,3 +928,206 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
</div> </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>
)
}

View 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;
}
}

View 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>
)
}

View File

@@ -36,7 +36,7 @@ export type Utterance = {
export type AnomalyEntry = { export type AnomalyEntry = {
id: number id: number
source: 'radio' | 'evp' | 'wire' source: 'radio' | 'evp' | 'wire' | 'emf'
frequency: number frequency: number
magnitude: number magnitude: number
at: number at: number
@@ -101,7 +101,7 @@ const TOAST_CAP = 4
export type SeanceAction = export type SeanceAction =
| { type: 'connection'; state: VeilConnectionState } | { type: 'connection'; state: VeilConnectionState }
| { type: 'frame'; frame: ServerFrame; at?: number } | { 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: 'local_question'; text: string; at?: number }
| { type: 'set_mode'; mode: Mode } | { type: 'set_mode'; mode: Mode }
| { type: 'set_language'; language: Language } | { type: 'set_language'; language: Language }
@@ -377,7 +377,7 @@ export type SeanceApi = {
setLanguage: (language: Language) => void setLanguage: (language: Language) => void
summon: () => void summon: () => void
ask: (text: string) => 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 playUtterance: (utteranceId: string) => void
dismissToast: (id: number) => void dismissToast: (id: number) => void
} }
@@ -450,7 +450,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const sendAnomaly = useCallback( 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() }) dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() })
socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude }) socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude })
}, },

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { GhostScene } from './GhostScene' import { GhostScene } from './GhostScene'
import type { GhostMood, GhostVisualState } from './GhostScene' import type { GhostMood, GhostVisualState } from './GhostScene'
import { hauntBus } from '../lib/haunting'
export type GhostCanvasProps = { export type GhostCanvasProps = {
visual: GhostVisualState | null visual: GhostVisualState | null
@@ -26,7 +27,10 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
const ampTimer = window.setInterval(() => { const ampTimer = window.setInterval(() => {
scene.setSpeakAmplitude(ampRef.current?.() ?? 0) scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
}, 50) }, 50)
// Apparition flares from the haunting layer surge the ghost.
const offFlare = hauntBus.on('flare', ({ strength }) => scene.flare(strength))
return () => { return () => {
offFlare()
window.clearInterval(ampTimer) window.clearInterval(ampTimer)
scene.dispose() scene.dispose()
sceneRef.current = null sceneRef.current = null

View File

@@ -234,6 +234,10 @@ export class GhostScene {
private smoothedAmp = 0 private smoothedAmp = 0
private energy = 0.15 private energy = 0.15
private disposed = false private disposed = false
private flareAmp = 0
private lastT = 0
private baseScale = 1
private baseOpacity = 1
constructor(canvas: HTMLCanvasElement) { constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas this.canvas = canvas
@@ -373,6 +377,14 @@ export class GhostScene {
this.speakAmp = Math.max(0, Math.min(1, amp)) 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 { setVisual(visual: GhostVisualState | null): void {
this.visual = visual this.visual = visual
if (!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.uColor.value = hsl(visual.hue, 0.9, 0.7)
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4) 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.scale.setScalar(params.scale)
this.ghost.visible = true this.ghost.visible = true
this.wisps.visible = true this.wisps.visible = true
@@ -417,24 +431,33 @@ export class GhostScene {
private renderFrame(): void { private renderFrame(): void {
const t = this.clock.getElapsedTime() 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 = const target =
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15 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.energy += (target - this.energy) * 0.04
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25 this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
const flaredEnergy = Math.min(1, this.energy + this.flareAmp * 0.55)
const ghostU = this.ghostMat.uniforms const ghostU = this.ghostMat.uniforms
ghostU.uTime.value = t ghostU.uTime.value = t
ghostU.uEnergy.value = this.energy ghostU.uEnergy.value = flaredEnergy
ghostU.uSpeak.value = this.smoothedAmp 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.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.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.uTime.value = t
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1 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.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3 this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
this.veil.position.y = this.ghost.position.y - 0.3 this.veil.position.y = this.ghost.position.y - 0.3

View File

@@ -34,6 +34,7 @@ interface USBDevice {
readonly configuration: USBConfiguration | null readonly configuration: USBConfiguration | null
open(): Promise<void> open(): Promise<void>
close(): Promise<void> close(): Promise<void>
selectConfiguration(configurationValue: number): Promise<void>
claimInterface(interfaceNumber: number): Promise<void> claimInterface(interfaceNumber: number): Promise<void>
releaseInterface(interfaceNumber: number): Promise<void> releaseInterface(interfaceNumber: number): Promise<void>
selectAlternateInterface( selectAlternateInterface(