+
+ {manifest && (
+
setManifest(null)}
+ />
+ )}
+ {whispers.map((w) => (
+
+ {w.word}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/PlanchetteBoard.tsx b/frontend/src/components/PlanchetteBoard.tsx
index 4269c20..5928cc0 100644
--- a/frontend/src/components/PlanchetteBoard.tsx
+++ b/frontend/src/components/PlanchetteBoard.tsx
@@ -5,6 +5,7 @@
import { useEffect, useRef } from 'react'
import { PlanchetteMachine } from '../lib/planchette'
+import { armAudioOnFirstGesture, createTick, getAudioContextCtor } from '../lib/haunting'
type Point = { x: number; y: number }
@@ -95,6 +96,21 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
let initialized = false
const smoke: Smoke[] = []
+ // Spirit knock: an AudioContext created lazily on the first gesture
+ // (autoplay policy); a soft tick sounds as each letter is dwelled upon.
+ let tickCtx: AudioContext | null = null
+ let lastKnock: string | null = null
+ const disarmTick = armAudioOnFirstGesture(() => {
+ const AC = getAudioContextCtor()
+ if (!AC) return
+ try {
+ tickCtx = new AC()
+ if (tickCtx.state === 'suspended') void tickCtx.resume().catch(() => undefined)
+ } catch {
+ tickCtx = null
+ }
+ })
+
const resize = () => {
const parent = canvas.parentElement
if (!parent) return
@@ -136,6 +152,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
const accent = `hsl(${hueDeg} 90% 72%)`
const t = now / 1000
+ // Spirit knock on each newly dwelled letter (word:index identity so
+ // repeated letters in a word each knock).
+ const knockKey =
+ snap.phase === 'dwelling' && snap.current ? `${snap.word ?? ''}:${snap.index}` : null
+ if (knockKey && knockKey !== lastKnock && tickCtx && tickCtx.state === 'running') {
+ createTick(tickCtx)
+ }
+ lastKnock = knockKey
+
// --- target: spelled letter, else idle wander ---
let target: Point | null = null
if (snap.current) target = letterTarget(snap.current)
@@ -209,13 +234,14 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
// Letters + numbers.
const hovered = snap.current
+ const dwelling = snap.phase === 'dwelling' ? snap.current : null
ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif`
for (const [ch, p] of layout.letters) {
- drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
+ drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg)
}
ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace`
for (const [ch, p] of layout.numbers) {
- drawGlyph(ctx, ch, p, hovered === ch, accent, hueDeg)
+ drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg)
}
// Smoke (additive).
@@ -262,6 +288,8 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
+ disarmTick()
+ if (tickCtx && tickCtx.state !== 'closed') void tickCtx.close().catch(() => undefined)
}
}, [])
@@ -294,19 +322,26 @@ function drawGlyph(
ch: string,
p: Point,
hovered: boolean,
+ dwell: boolean,
accent: string,
hueDeg: number,
): void {
if (hovered) {
ctx.save()
+ if (dwell) {
+ // Dwelled letter: bright glow, swollen in place — the spirit's finger.
+ ctx.translate(p.x, p.y)
+ ctx.scale(1.22, 1.22)
+ ctx.translate(-p.x, -p.y)
+ }
ctx.shadowColor = accent
- ctx.shadowBlur = 16
+ ctx.shadowBlur = dwell ? 28 : 16
ctx.fillStyle = accent
ctx.fillText(ch, p.x, p.y)
ctx.restore()
ctx.beginPath()
- ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / 0.5)`
- ctx.arc(p.x, p.y, 15, 0, Math.PI * 2)
+ ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / ${dwell ? 0.85 : 0.5})`
+ ctx.arc(p.x, p.y, dwell ? 19 : 15, 0, Math.PI * 2)
ctx.stroke()
} else {
ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`
diff --git a/frontend/src/components/Transcript.tsx b/frontend/src/components/Transcript.tsx
index bc25e91..aa9be87 100644
--- a/frontend/src/components/Transcript.tsx
+++ b/frontend/src/components/Transcript.tsx
@@ -10,9 +10,11 @@ export type TranscriptProps = {
}
function fmtFreq(source: string, f: number): string {
- // EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
+ // EVP reports Hz (voice band); radio reports MHz (FM band); emf reports a
+ // field-strength estimate; wire is ambient.
if (source === 'evp') return `${f.toFixed(0)}Hz`
if (source === 'radio') return `${f.toFixed(1)}MHz`
+ if (source === 'emf') return `field ${f.toFixed(2)}`
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
}
diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs
new file mode 100644
index 0000000..4a5c9f0
--- /dev/null
+++ b/frontend/src/i18n/coverage-check.mjs
@@ -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')
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 5d6c726..e4cb5ea 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -12,6 +12,7 @@
"nav": {
"home": "the threshold",
"codex": "codex",
+ "shop": "shop",
"seance": "séance",
"enter": "enter",
"logout": "depart"
@@ -27,6 +28,7 @@
"heroKicker": "an instrument for speaking with the dead bandwidth",
"cta": "BEGIN THE SÉANCE",
"ctaCodex": "browse the codex",
+ "ctaShop": "visit the armory",
"stats": {
"entities": "spirits catalogued",
"sessions": "séances held",
@@ -55,6 +57,11 @@
},
"featured": "most disturbed spirits",
"featuredEmpty": "the registry is still blank — be the first to disturb it",
+ "armory": {
+ "title": "HARDWARE FROM THE ARMORY",
+ "line": "the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.",
+ "link": "enter the armory →"
+ },
"footer": "an interactive horror art installation — the spirits are fiction; the static is real",
"footerColophon": "self-hosted · no cloud · the veil runs on your machine"
},
@@ -77,7 +84,8 @@
"wire": "Wire Ghost",
"evp": "EVP",
"radio": "Spirit Radio",
- "ouija": "Ouija"
+ "ouija": "Ouija",
+ "emf": "FIELD"
},
"passive": "passive listening",
"summon": "SUMMON",
@@ -132,14 +140,30 @@
"unsupportedTitle": "this vessel cannot hear the radio dead",
"unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.",
"troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in",
+ "insecure": "the radio dead only speak through consecrated ground — open this site via its https address.",
+ "insecureHint": "localhost counts as hallowed; a bare http://lan-ip does not.",
"noDevice": "no dongle answered the call",
"sweeping": "sweeping {{start}}–{{end}} MHz…",
- "claimFailed": "the dongle refused to be claimed — is it in use elsewhere?"
+ "claimFailed": "the dongle refused to be claimed — is it in use elsewhere?",
+ "claimFix": "linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.",
+ "tryAgain": "try again",
+ "errorTitle": "the static pushed back"
},
"ouija": {
"spelling": "the planchette spells",
"drift": "the planchette drifts, waiting for words — ask below, or summon."
},
+ "emf": {
+ "start": "raise the meter",
+ "stop": "ground the meter",
+ "calibrate": "calibrate",
+ "baseline": "resting field",
+ "listening": "feeling for disturbances in the field…",
+ "unsupportedTitle": "this vessel carries no field senses",
+ "unsupported": "a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.",
+ "deniedTitle": "the field senses stay shut",
+ "denied": "this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again."
+ },
"entity": {
"title": "DOSSIER OF THE PRESENT",
"none": "no spirit summoned yet",
@@ -240,5 +264,92 @@
"empty": "no spirits match this filter — the registry keeps its secrets",
"back": "← back to the codex",
"notFound": "this spirit is not in the registry"
+ },
+ "shop": {
+ "back": "← the threshold",
+ "title": "THE ARMORY",
+ "subtitle": "instruments for those who would knock louder",
+ "flagship": {
+ "tag": "flagship instrument",
+ "name": "THE ULTIMATE QUANTUM BOX",
+ "desc": "An ESP32-P4/C6 handheld séance instrument — thermal camera, EMF whisker array, geophone, spirit-box mic preamp and a little OLED face that stares back. It signs into the same Quantumancy account you already haunt and unlocks the hardware-driven modes of this site: thermal apparition maps, vibration anomaly feeds, calibrated EMF.",
+ "specs": {
+ "thermal": "thermal camera",
+ "emf": "EMF whisker array",
+ "geophone": "geophone",
+ "mic": "spirit-box mic preamp",
+ "oled": "OLED display"
+ },
+ "price": "est. $249 — pre-order"
+ },
+ "modulesTitle": "MODULES THAT KNOCK",
+ "unlocksLabel": "unlocks:",
+ "modules": {
+ "emfArray": {
+ "name": "EMF Whisker Array",
+ "desc": "a bouquet of multi-axis field sensors, sniffing the air for intentions",
+ "unlocks": "a richer, calibrated FIELD mode",
+ "price": "est. $59 — pre-order"
+ },
+ "geophone": {
+ "name": "Geophone Puck",
+ "desc": "hears footsteps in rooms where nobody is standing",
+ "unlocks": "the vibration anomaly feed",
+ "price": "est. $49 — pre-order"
+ },
+ "spiritMic": {
+ "name": "Spirit Mic Preamp",
+ "desc": "lowers the noise floor so the whispering comes through cleaner",
+ "unlocks": "cleaner EVP captures",
+ "price": "est. $69 — pre-order"
+ },
+ "uvIlluminator": {
+ "name": "UV/IR Illuminator",
+ "desc": "paints the dark in spectrums the living were never meant to see",
+ "unlocks": "night séances + camera work",
+ "price": "est. $39 — pre-order"
+ }
+ },
+ "interests": {
+ "quantumBox": "the Ultimate Quantum Box",
+ "emfArray": "EMF Whisker Array",
+ "geophone": "Geophone Puck",
+ "spiritMic": "Spirit Mic Preamp",
+ "uvIlluminator": "UV/IR Illuminator"
+ },
+ "waitlist": {
+ "title": "THE MANIFEST",
+ "desc": "leave an address and an obsession — when the first boxes ship, we reach through to the names on this list first",
+ "emailLabel": "email address",
+ "emailPlaceholder": "you@somewhere.in.the.static",
+ "interestLabel": "interest",
+ "submit": "join the manifest",
+ "pending": "carving your name…",
+ "listed": "you are on the manifest — we will reach through when the boxes ship",
+ "alreadyListed": "you are already on the manifest",
+ "invalid": "that address does not reach us",
+ "crowded": "the veil is crowded — try again later",
+ "error": "something stirred in the static — your name was not carved"
+ },
+ "honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
+ },
+ "haunting": {
+ "whisper": {
+ "behindYou": "behind you",
+ "cold": "cold",
+ "stay": "stay",
+ "seesYou": "it sees you",
+ "closer": "closer",
+ "stillHere": "still here",
+ "lineListening": "the line is listening",
+ "dontLook": "don't look",
+ "beneathStatic": "beneath the static",
+ "knockTwice": "knock twice"
+ },
+ "title": {
+ "seesYou": "QUANTUMANCY — it sees you",
+ "behindYou": "QUANTUM▓NCY — behind you",
+ "listening": "…listening…"
+ }
}
}
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 27a290e..b477bfd 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -12,6 +12,7 @@
"nav": {
"home": "el umbral",
"codex": "códice",
+ "shop": "arsenal",
"seance": "sesión",
"enter": "entrar",
"logout": "partir"
@@ -27,6 +28,7 @@
"heroKicker": "un instrumento para hablar con el ancho de banda muerto",
"cta": "COMENZAR LA SESIÓN",
"ctaCodex": "hojear el códice",
+ "ctaShop": "visitar el arsenal",
"stats": {
"entities": "espíritus catalogados",
"sessions": "sesiones celebradas",
@@ -55,6 +57,11 @@
},
"featured": "los espíritus más perturbados",
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero",
+ "armory": {
+ "title": "HARDWARE DEL ARSENAL",
+ "line": "la Ultimate Quantum Box se acerca — un instrumento de sesión de mano que hace que el velo golpee más fuerte.",
+ "link": "entrar al arsenal →"
+ },
"footer": "una instalación de arte de terror interactiva — los espíritus son ficción; la estática es real",
"footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
},
@@ -77,7 +84,8 @@
"wire": "Fantasma del Cable",
"evp": "EVP",
"radio": "Radio Espíritu",
- "ouija": "Ouija"
+ "ouija": "Ouija",
+ "emf": "CAMPO"
},
"passive": "escucha pasiva",
"summon": "INVOCAR",
@@ -132,14 +140,30 @@
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
"unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.",
"troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado",
+ "insecure": "los muertos de la radio solo hablan desde suelo consagrado — abre este sitio por su dirección https.",
+ "insecureHint": "localhost cuenta como sagrado; una http://ip-local desnuda, no.",
"noDevice": "ningún dongle respondió al llamado",
"sweeping": "barriendo {{start}}–{{end}} MHz…",
- "claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?"
+ "claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?",
+ "claimFix": "linux: el driver del kernel dvb_usb_rtl28xxu retiene el dongle — `sudo modprobe -r dvb_usb_rtl28xxu` lo libera hasta el reinicio (una regla udev lo destierra para siempre). windows: cambia el driver a WinUSB con Zadig.",
+ "tryAgain": "intenta de nuevo",
+ "errorTitle": "la estática devolvió el golpe"
},
"ouija": {
"spelling": "la planchette deletrea",
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca."
},
+ "emf": {
+ "start": "alzar el medidor",
+ "stop": "descargar el medidor",
+ "calibrate": "calibrar",
+ "baseline": "campo en reposo",
+ "listening": "palpando perturbaciones en el campo…",
+ "unsupportedTitle": "este recipiente no porta sentidos de campo",
+ "unsupported": "un teléfono en tu mano se vuelve el medidor — este recipiente no tiene sentidos de movimiento que prestar. abre el velo en un teléfono, o deja que los otros modos respondan.",
+ "deniedTitle": "los sentidos de campo siguen cerrados",
+ "denied": "Este recipiente rechazó sus sentidos de movimiento. Concede el acceso al movimiento en los ajustes de tu navegador, y luego alza el medidor de nuevo."
+ },
"entity": {
"title": "DOSIER DEL PRESENTE",
"none": "ningún espíritu invocado todavía",
@@ -240,5 +264,92 @@
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
"back": "← volver al códice",
"notFound": "este espíritu no figura en el registro"
+ },
+ "shop": {
+ "back": "← el umbral",
+ "title": "EL ARSENAL",
+ "subtitle": "instrumentos para quienes quisieran golpear más fuerte",
+ "flagship": {
+ "tag": "instrumento insignia",
+ "name": "THE ULTIMATE QUANTUM BOX",
+ "desc": "Un instrumento de sesión de mano ESP32-P4/C6 — cámara térmica, matriz de antenas EMF, geófono, preamplificador de micrófono spirit-box y una pequeña cara OLED que te devuelve la mirada. Inicia sesión con la misma cuenta de Quantumancy que ya habitas y desbloquea los modos de este sitio impulsados por hardware: mapas térmicos de apariciones, feeds de anomalías de vibración, EMF calibrado.",
+ "specs": {
+ "thermal": "cámara térmica",
+ "emf": "matriz de antenas EMF",
+ "geophone": "geófono",
+ "mic": "preamplificador de micrófono spirit-box",
+ "oled": "pantalla OLED"
+ },
+ "price": "est. $249 — preventa"
+ },
+ "modulesTitle": "MÓDULOS QUE GOLPEAN",
+ "unlocksLabel": "desbloquea:",
+ "modules": {
+ "emfArray": {
+ "name": "Matriz de Antenas EMF",
+ "desc": "un ramo de sensores de campo multieje, olfateando el aire en busca de intenciones",
+ "unlocks": "un modo CAMPO más rico y calibrado",
+ "price": "est. $59 — preventa"
+ },
+ "geophone": {
+ "name": "Puck Geófono",
+ "desc": "oye pasos en habitaciones donde nadie está de pie",
+ "unlocks": "el feed de anomalías de vibración",
+ "price": "est. $49 — preventa"
+ },
+ "spiritMic": {
+ "name": "Preamplificador Spirit Mic",
+ "desc": "baja el suelo de ruido para que los susurros lleguen más limpios",
+ "unlocks": "capturas EVP más limpias",
+ "price": "est. $69 — preventa"
+ },
+ "uvIlluminator": {
+ "name": "Iluminador UV/IR",
+ "desc": "pinta la oscuridad en espectros que los vivos jamás debieron ver",
+ "unlocks": "sesiones nocturnas + trabajo de cámara",
+ "price": "est. $39 — preventa"
+ }
+ },
+ "interests": {
+ "quantumBox": "la Ultimate Quantum Box",
+ "emfArray": "Matriz de Antenas EMF",
+ "geophone": "Puck Geófono",
+ "spiritMic": "Preamplificador Spirit Mic",
+ "uvIlluminator": "Iluminador UV/IR"
+ },
+ "waitlist": {
+ "title": "EL MANIFIESTO",
+ "desc": "deja una dirección y una obsesión — cuando salgan las primeras cajas, nos abriremos paso primero hasta los nombres de esta lista",
+ "emailLabel": "dirección de email",
+ "emailPlaceholder": "tu@algun.lugar.en.la.estatica",
+ "interestLabel": "interés",
+ "submit": "unirse al manifiesto",
+ "pending": "grabando tu nombre…",
+ "listed": "estás en el manifiesto — nos abriremos paso cuando salgan las cajas",
+ "alreadyListed": "ya estás en el manifiesto",
+ "invalid": "esa dirección no nos alcanza",
+ "crowded": "el velo está abarrotado — inténtalo más tarde",
+ "error": "algo se agitó en la estática — tu nombre no quedó grabado"
+ },
+ "honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
+ },
+ "haunting": {
+ "whisper": {
+ "behindYou": "detrás de ti",
+ "cold": "frío",
+ "stay": "quédate",
+ "seesYou": "te ve",
+ "closer": "más cerca",
+ "stillHere": "sigue aquí",
+ "lineListening": "la línea escucha",
+ "dontLook": "no mires",
+ "beneathStatic": "bajo la estática",
+ "knockTwice": "golpea dos veces"
+ },
+ "title": {
+ "seesYou": "QUANTUMANCY — te ve",
+ "behindYou": "QUANTUM▓NCY — detrás de ti",
+ "listening": "…escuchando…"
+ }
}
}
diff --git a/frontend/src/lib/emf.test.ts b/frontend/src/lib/emf.test.ts
new file mode 100644
index 0000000..4fe1400
--- /dev/null
+++ b/frontend/src/lib/emf.test.ts
@@ -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()
+ })
+})
diff --git a/frontend/src/lib/emf.ts b/frontend/src/lib/emf.ts
new file mode 100644
index 0000000..08ce665
--- /dev/null
+++ b/frontend/src/lib/emf.ts
@@ -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
}
+
+/** 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 {
+ 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())
+ }
+ }
+}
diff --git a/frontend/src/lib/haunting.test.ts b/frontend/src/lib/haunting.test.ts
new file mode 100644
index 0000000..4390a48
--- /dev/null
+++ b/frontend/src/lib/haunting.test.ts
@@ -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()
+ })
+})
diff --git a/frontend/src/lib/haunting.ts b/frontend/src/lib/haunting.ts
new file mode 100644
index 0000000..557b783
--- /dev/null
+++ b/frontend/src/lib/haunting.ts
@@ -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 = (detail: T) => void
+
+/** Tiny typed emitter — one bad listener never silences the others. */
+export class HauntBus {
+ private handlers = new Map>>()
+
+ /** Subscribe; returns an unsubscribe function. */
+ on(type: K, handler: HauntHandler): () => void {
+ let set = this.handlers.get(type)
+ if (!set) {
+ set = new Set()
+ this.handlers.set(type, set)
+ }
+ const h = handler as HauntHandler
+ set.add(h)
+ return () => {
+ set.delete(h)
+ }
+ }
+
+ /** Dispatch to all current subscribers of `type`. */
+ emit(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)(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 | 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 */
+ }
+ }
+}
diff --git a/frontend/src/lib/sdr.ts b/frontend/src/lib/sdr.ts
index a321d47..66e6e54 100644
--- a/frontend/src/lib/sdr.ts
+++ b/frontend/src/lib/sdr.ts
@@ -29,6 +29,9 @@ export type SweepCallbacks = {
// USB identification for RTL2832U dongles.
export const RTL2832U_VENDOR = 0x0bda
export const RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]
+// Terratec (and other rebadged) dongles share the RTL2832U guts but report a
+// different vendor id — they speak the same protocol once claimed.
+export const TERRATEC_VENDOR = 0x0ccd
// Request types used by librtlsdr.
const CTRL_IN = 0xc0
@@ -38,8 +41,14 @@ const USB_EPA = 0x02
const SYS = 0x09
const PAGE_USB = 0x01
+/** WebUSB only exists in secure contexts (https, or localhost). */
+export function isSecureContext(): boolean {
+ return typeof window !== 'undefined' && window.isSecureContext === true
+}
+
export function isSupported(): boolean {
return (
+ isSecureContext() &&
typeof navigator !== 'undefined' &&
'usb' in navigator &&
typeof navigator.usb?.requestDevice === 'function'
@@ -62,12 +71,9 @@ export class RtlSdr {
if (!isSupported()) {
throw new Error('WebUSB is not available in this vessel (Chromium required)')
}
- const filters = [
- { vendorId: RTL2832U_VENDOR, productId: 0x2832 },
- { vendorId: RTL2832U_VENDOR, productId: 0x2834 },
- { vendorId: RTL2832U_VENDOR, productId: 0x2838 },
- { vendorId: RTL2832U_VENDOR, productId: 0x2837 },
- ]
+ // Vendor-only filters: many dongles report product ids outside the
+ // handful we know, so filtering by productId hides them from the picker.
+ const filters = [{ vendorId: RTL2832U_VENDOR }, { vendorId: TERRATEC_VENDOR }]
this.device = await navigator.usb.requestDevice({ filters })
}
@@ -78,6 +84,17 @@ export class RtlSdr {
this.sampleRate = sampleRateHz
await dev.open()
+ // WebUSB populates `configuration` (interfaces, endpoints) only after a
+ // configuration is explicitly selected — without this, most dongles
+ // report a null configuration and everything downstream fails.
+ try {
+ await dev.selectConfiguration(1)
+ } catch (err) {
+ // Tolerable only if the device is already configured (some platforms
+ // refuse to re-select the active configuration).
+ if (!dev.configuration) throw err
+ }
+
// Find the first bulk-IN endpoint.
const iface = dev.configuration?.interfaces[0]
const alt = iface?.alternates[0]
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 2875b63..79b171a 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -1,7 +1,7 @@
// Shared protocol + domain types for the Quantumancy frontend.
// Mirrors the backend contract exactly — do not invent changes.
-export type Mode = 'wire' | 'evp' | 'radio' | 'ouija'
+export type Mode = 'wire' | 'evp' | 'radio' | 'ouija' | 'emf'
export type Language = 'en' | 'es'
export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic'
export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade'
@@ -68,7 +68,7 @@ export type ClientFrame =
| { type: 'set_mode'; mode: Mode }
| { type: 'language'; language: Language }
| { type: 'summon' }
- | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
+ | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'question'; text: string }
| { type: 'passive'; enabled: boolean }
@@ -90,7 +90,7 @@ export type ServerFrame =
| { type: 'status'; state: SessionStatus }
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
| { type: 'anomaly_ack'; count: number }
- | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
+ | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
| { type: 'audio'; id: string; url: string }
| { type: 'reply_start' }
diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css
index ac4b208..5a85a66 100644
--- a/frontend/src/pages/LandingPage.css
+++ b/frontend/src/pages/LandingPage.css
@@ -437,6 +437,64 @@
font-size: 0.7rem;
}
+/* ---------- armory teaser strip ---------- */
+
+.hero-secondary-links {
+ display: flex;
+ align-items: center;
+ gap: 1.6rem;
+}
+
+.landing-section.armory-strip {
+ padding-top: 2.5rem;
+ padding-bottom: 2.5rem;
+}
+
+.armory-strip-inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.9rem 1.8rem;
+ background: rgba(16, 16, 26, 0.85);
+ border: 1px dashed rgba(178, 107, 255, 0.35);
+ padding: 1.4rem 1.8rem;
+ text-align: center;
+}
+
+.armory-title {
+ font-family: 'Cinzel', serif;
+ font-size: 0.85rem;
+ font-weight: 700;
+ letter-spacing: 0.26em;
+ color: #eafff3;
+ text-shadow: 0 0 14px rgba(178, 107, 255, 0.35);
+ margin: 0;
+}
+
+.armory-line {
+ font-size: 0.78rem;
+ color: rgba(216, 230, 221, 0.55);
+ margin: 0;
+}
+
+.armory-link {
+ color: #b26bff;
+ font-size: 0.78rem;
+ letter-spacing: 0.14em;
+ text-decoration: none;
+ border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
+ padding-bottom: 2px;
+ white-space: nowrap;
+ transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
+}
+
+.armory-link:hover {
+ color: #d3a8ff;
+ border-color: #d3a8ff;
+ text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
+}
+
/* ---------- responsive + reduced motion ---------- */
@media (max-width: 720px) {
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index 0668d35..26c84f0 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -278,9 +278,14 @@ export function LandingPage() {
>
{t('landing.cta')}
-
- {t('landing.ctaCodex')}
-
+
+
+ {t('landing.ctaCodex')}
+
+
+ {t('landing.ctaShop', { defaultValue: 'visit the armory' })}
+
+
@@ -338,6 +343,23 @@ export function LandingPage() {
)}
+