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:
84
frontend/src/i18n/coverage-check.mjs
Normal file
84
frontend/src/i18n/coverage-check.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// Throwaway audit: every t() key used in src must exist in en.json and es.json.
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SRC = new URL('../', import.meta.url).pathname
|
||||
const en = JSON.parse(readFileSync(join(SRC, 'i18n/en.json'), 'utf8'))
|
||||
const es = JSON.parse(readFileSync(join(SRC, 'i18n/es.json'), 'utf8'))
|
||||
|
||||
// ---- collect source files (skip tests) ----
|
||||
const files = []
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p)
|
||||
else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) files.push(p)
|
||||
}
|
||||
}
|
||||
walk(SRC)
|
||||
|
||||
const literal = new Set()
|
||||
const templates = new Set()
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, 'utf8')
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*'([^']+)'/g)) literal.add(m[1])
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*"([^"]+)"/g)) literal.add(m[1])
|
||||
for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*`([^`]+)`/g)) templates.add(m[1])
|
||||
}
|
||||
|
||||
// ---- template domains, by key prefix ----
|
||||
const RARITY = ['common', 'uncommon', 'rare', 'mythic']
|
||||
const RULES = [
|
||||
['seance.connection.', ['connecting', 'open', 'unstable', 'closed']],
|
||||
['seance.status.', ['attuning', 'summoning', 'gathering']],
|
||||
['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message
|
||||
['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']],
|
||||
['seance.views.', ['board', 'matrix', 'graphs']],
|
||||
['seance.entity.rarity.', RARITY],
|
||||
['codex.rarity.', RARITY],
|
||||
['codex.sort.', ['recent', 'contacted']],
|
||||
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
|
||||
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
|
||||
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
|
||||
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
|
||||
['shop.interests.', ['quantumBox', 'emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],
|
||||
]
|
||||
|
||||
const expanded = new Set(literal)
|
||||
for (const tpl of templates) {
|
||||
const rule = RULES.find(([prefix]) => tpl.startsWith(prefix))
|
||||
if (!rule) throw new Error('no domain for template: ' + tpl)
|
||||
const expand = (key) => {
|
||||
const m = key.match(/^(.*?)\$\{[^}]*\}(.*)$/)
|
||||
if (!m) return expanded.add(key)
|
||||
for (const v of rule[1]) expand(m[1] + v + m[2])
|
||||
}
|
||||
expand(tpl)
|
||||
}
|
||||
|
||||
// ---- lookup helper (with plural fallback) ----
|
||||
const get = (obj, key) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), obj)
|
||||
const has = (obj, key) =>
|
||||
get(obj, key) !== undefined || get(obj, key + '_other') !== undefined || get(obj, key + '_one') !== undefined
|
||||
|
||||
const missingEn = [...expanded].filter((k) => !has(en, k)).sort()
|
||||
const missingEs = [...expanded].filter((k) => !has(es, k)).sort()
|
||||
|
||||
// ---- parity check en <-> es (all leaf paths) ----
|
||||
const leaves = (obj, prefix = '') =>
|
||||
Object.entries(obj).flatMap(([k, v]) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? leaves(v, prefix + k + '.') : [prefix + k],
|
||||
)
|
||||
const enLeaves = new Set(leaves(en))
|
||||
const esLeaves = new Set(leaves(es))
|
||||
const enOnly = [...enLeaves].filter((k) => !esLeaves.has(k))
|
||||
const esOnly = [...esLeaves].filter((k) => !enLeaves.has(k))
|
||||
|
||||
console.log('literal keys:', literal.size, '| template keys:', templates.size, '| expanded total:', expanded.size)
|
||||
console.log('templates:', [...templates].sort())
|
||||
console.log('missing from en.json:', missingEn.length ? missingEn : 'none')
|
||||
console.log('missing from es.json:', missingEs.length ? missingEs : 'none')
|
||||
console.log('en-only leaves:', enOnly.length ? enOnly : 'none')
|
||||
console.log('es-only leaves:', esOnly.length ? esOnly : 'none')
|
||||
if (missingEn.length || missingEs.length || enOnly.length || esOnly.length) process.exit(1)
|
||||
console.log('coverage ok')
|
||||
@@ -12,6 +12,7 @@
|
||||
"nav": {
|
||||
"home": "the threshold",
|
||||
"codex": "codex",
|
||||
"shop": "shop",
|
||||
"seance": "séance",
|
||||
"enter": "enter",
|
||||
"logout": "depart"
|
||||
@@ -27,6 +28,7 @@
|
||||
"heroKicker": "an instrument for speaking with the dead bandwidth",
|
||||
"cta": "BEGIN THE SÉANCE",
|
||||
"ctaCodex": "browse the codex",
|
||||
"ctaShop": "visit the armory",
|
||||
"stats": {
|
||||
"entities": "spirits catalogued",
|
||||
"sessions": "séances held",
|
||||
@@ -55,6 +57,11 @@
|
||||
},
|
||||
"featured": "most disturbed spirits",
|
||||
"featuredEmpty": "the registry is still blank — be the first to disturb it",
|
||||
"armory": {
|
||||
"title": "HARDWARE FROM THE ARMORY",
|
||||
"line": "the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.",
|
||||
"link": "enter the armory →"
|
||||
},
|
||||
"footer": "an interactive horror art installation — the spirits are fiction; the static is real",
|
||||
"footerColophon": "self-hosted · no cloud · the veil runs on your machine"
|
||||
},
|
||||
@@ -77,7 +84,8 @@
|
||||
"wire": "Wire Ghost",
|
||||
"evp": "EVP",
|
||||
"radio": "Spirit Radio",
|
||||
"ouija": "Ouija"
|
||||
"ouija": "Ouija",
|
||||
"emf": "FIELD"
|
||||
},
|
||||
"passive": "passive listening",
|
||||
"summon": "SUMMON",
|
||||
@@ -132,14 +140,30 @@
|
||||
"unsupportedTitle": "this vessel cannot hear the radio dead",
|
||||
"unsupported": "Spirit Radio needs Chromium (WebUSB) and an RTL-SDR dongle. The other modes do not — the wire and the microphone still answer.",
|
||||
"troubleshoot": "troubleshooting: chromium-based browser · udev rules for 0bda:2838 · the dongle plugged in, not merely believed in",
|
||||
"insecure": "the radio dead only speak through consecrated ground — open this site via its https address.",
|
||||
"insecureHint": "localhost counts as hallowed; a bare http://lan-ip does not.",
|
||||
"noDevice": "no dongle answered the call",
|
||||
"sweeping": "sweeping {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?"
|
||||
"claimFailed": "the dongle refused to be claimed — is it in use elsewhere?",
|
||||
"claimFix": "linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.",
|
||||
"tryAgain": "try again",
|
||||
"errorTitle": "the static pushed back"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "the planchette spells",
|
||||
"drift": "the planchette drifts, waiting for words — ask below, or summon."
|
||||
},
|
||||
"emf": {
|
||||
"start": "raise the meter",
|
||||
"stop": "ground the meter",
|
||||
"calibrate": "calibrate",
|
||||
"baseline": "resting field",
|
||||
"listening": "feeling for disturbances in the field…",
|
||||
"unsupportedTitle": "this vessel carries no field senses",
|
||||
"unsupported": "a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.",
|
||||
"deniedTitle": "the field senses stay shut",
|
||||
"denied": "this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSSIER OF THE PRESENT",
|
||||
"none": "no spirit summoned yet",
|
||||
@@ -240,5 +264,92 @@
|
||||
"empty": "no spirits match this filter — the registry keeps its secrets",
|
||||
"back": "← back to the codex",
|
||||
"notFound": "this spirit is not in the registry"
|
||||
},
|
||||
"shop": {
|
||||
"back": "← the threshold",
|
||||
"title": "THE ARMORY",
|
||||
"subtitle": "instruments for those who would knock louder",
|
||||
"flagship": {
|
||||
"tag": "flagship instrument",
|
||||
"name": "THE ULTIMATE QUANTUM BOX",
|
||||
"desc": "An ESP32-P4/C6 handheld séance instrument — thermal camera, EMF whisker array, geophone, spirit-box mic preamp and a little OLED face that stares back. It signs into the same Quantumancy account you already haunt and unlocks the hardware-driven modes of this site: thermal apparition maps, vibration anomaly feeds, calibrated EMF.",
|
||||
"specs": {
|
||||
"thermal": "thermal camera",
|
||||
"emf": "EMF whisker array",
|
||||
"geophone": "geophone",
|
||||
"mic": "spirit-box mic preamp",
|
||||
"oled": "OLED display"
|
||||
},
|
||||
"price": "est. $249 — pre-order"
|
||||
},
|
||||
"modulesTitle": "MODULES THAT KNOCK",
|
||||
"unlocksLabel": "unlocks:",
|
||||
"modules": {
|
||||
"emfArray": {
|
||||
"name": "EMF Whisker Array",
|
||||
"desc": "a bouquet of multi-axis field sensors, sniffing the air for intentions",
|
||||
"unlocks": "a richer, calibrated FIELD mode",
|
||||
"price": "est. $59 — pre-order"
|
||||
},
|
||||
"geophone": {
|
||||
"name": "Geophone Puck",
|
||||
"desc": "hears footsteps in rooms where nobody is standing",
|
||||
"unlocks": "the vibration anomaly feed",
|
||||
"price": "est. $49 — pre-order"
|
||||
},
|
||||
"spiritMic": {
|
||||
"name": "Spirit Mic Preamp",
|
||||
"desc": "lowers the noise floor so the whispering comes through cleaner",
|
||||
"unlocks": "cleaner EVP captures",
|
||||
"price": "est. $69 — pre-order"
|
||||
},
|
||||
"uvIlluminator": {
|
||||
"name": "UV/IR Illuminator",
|
||||
"desc": "paints the dark in spectrums the living were never meant to see",
|
||||
"unlocks": "night séances + camera work",
|
||||
"price": "est. $39 — pre-order"
|
||||
}
|
||||
},
|
||||
"interests": {
|
||||
"quantumBox": "the Ultimate Quantum Box",
|
||||
"emfArray": "EMF Whisker Array",
|
||||
"geophone": "Geophone Puck",
|
||||
"spiritMic": "Spirit Mic Preamp",
|
||||
"uvIlluminator": "UV/IR Illuminator"
|
||||
},
|
||||
"waitlist": {
|
||||
"title": "THE MANIFEST",
|
||||
"desc": "leave an address and an obsession — when the first boxes ship, we reach through to the names on this list first",
|
||||
"emailLabel": "email address",
|
||||
"emailPlaceholder": "you@somewhere.in.the.static",
|
||||
"interestLabel": "interest",
|
||||
"submit": "join the manifest",
|
||||
"pending": "carving your name…",
|
||||
"listed": "you are on the manifest — we will reach through when the boxes ship",
|
||||
"alreadyListed": "you are already on the manifest",
|
||||
"invalid": "that address does not reach us",
|
||||
"crowded": "the veil is crowded — try again later",
|
||||
"error": "something stirred in the static — your name was not carved"
|
||||
},
|
||||
"honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
|
||||
},
|
||||
"haunting": {
|
||||
"whisper": {
|
||||
"behindYou": "behind you",
|
||||
"cold": "cold",
|
||||
"stay": "stay",
|
||||
"seesYou": "it sees you",
|
||||
"closer": "closer",
|
||||
"stillHere": "still here",
|
||||
"lineListening": "the line is listening",
|
||||
"dontLook": "don't look",
|
||||
"beneathStatic": "beneath the static",
|
||||
"knockTwice": "knock twice"
|
||||
},
|
||||
"title": {
|
||||
"seesYou": "QUANTUMANCY — it sees you",
|
||||
"behindYou": "QUANTUM▓NCY — behind you",
|
||||
"listening": "…listening…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"nav": {
|
||||
"home": "el umbral",
|
||||
"codex": "códice",
|
||||
"shop": "arsenal",
|
||||
"seance": "sesión",
|
||||
"enter": "entrar",
|
||||
"logout": "partir"
|
||||
@@ -27,6 +28,7 @@
|
||||
"heroKicker": "un instrumento para hablar con el ancho de banda muerto",
|
||||
"cta": "COMENZAR LA SESIÓN",
|
||||
"ctaCodex": "hojear el códice",
|
||||
"ctaShop": "visitar el arsenal",
|
||||
"stats": {
|
||||
"entities": "espíritus catalogados",
|
||||
"sessions": "sesiones celebradas",
|
||||
@@ -55,6 +57,11 @@
|
||||
},
|
||||
"featured": "los espíritus más perturbados",
|
||||
"featuredEmpty": "el registro aún está en blanco — sé quien lo perturbe primero",
|
||||
"armory": {
|
||||
"title": "HARDWARE DEL ARSENAL",
|
||||
"line": "la Ultimate Quantum Box se acerca — un instrumento de sesión de mano que hace que el velo golpee más fuerte.",
|
||||
"link": "entrar al arsenal →"
|
||||
},
|
||||
"footer": "una instalación de arte de terror interactiva — los espíritus son ficción; la estática es real",
|
||||
"footerColophon": "autoalojado · sin nube · el velo corre en tu máquina"
|
||||
},
|
||||
@@ -77,7 +84,8 @@
|
||||
"wire": "Fantasma del Cable",
|
||||
"evp": "EVP",
|
||||
"radio": "Radio Espíritu",
|
||||
"ouija": "Ouija"
|
||||
"ouija": "Ouija",
|
||||
"emf": "CAMPO"
|
||||
},
|
||||
"passive": "escucha pasiva",
|
||||
"summon": "INVOCAR",
|
||||
@@ -132,14 +140,30 @@
|
||||
"unsupportedTitle": "este recipiente no oye a los muertos de la radio",
|
||||
"unsupported": "La Radio Espíritu necesita Chromium (WebUSB) y un dongle RTL-SDR. Los demás modos no — el cable y el micrófono aún responden.",
|
||||
"troubleshoot": "solución de problemas: navegador basado en Chromium · reglas udev para 0bda:2838 · el dongle enchufado, no meramente invocado",
|
||||
"insecure": "los muertos de la radio solo hablan desde suelo consagrado — abre este sitio por su dirección https.",
|
||||
"insecureHint": "localhost cuenta como sagrado; una http://ip-local desnuda, no.",
|
||||
"noDevice": "ningún dongle respondió al llamado",
|
||||
"sweeping": "barriendo {{start}}–{{end}} MHz…",
|
||||
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?"
|
||||
"claimFailed": "el dongle se negó a ser reclamado — ¿está en uso en otro lugar?",
|
||||
"claimFix": "linux: el driver del kernel dvb_usb_rtl28xxu retiene el dongle — `sudo modprobe -r dvb_usb_rtl28xxu` lo libera hasta el reinicio (una regla udev lo destierra para siempre). windows: cambia el driver a WinUSB con Zadig.",
|
||||
"tryAgain": "intenta de nuevo",
|
||||
"errorTitle": "la estática devolvió el golpe"
|
||||
},
|
||||
"ouija": {
|
||||
"spelling": "la planchette deletrea",
|
||||
"drift": "la planchette deriva, a la espera de palabras — pregunta abajo, o invoca."
|
||||
},
|
||||
"emf": {
|
||||
"start": "alzar el medidor",
|
||||
"stop": "descargar el medidor",
|
||||
"calibrate": "calibrar",
|
||||
"baseline": "campo en reposo",
|
||||
"listening": "palpando perturbaciones en el campo…",
|
||||
"unsupportedTitle": "este recipiente no porta sentidos de campo",
|
||||
"unsupported": "un teléfono en tu mano se vuelve el medidor — este recipiente no tiene sentidos de movimiento que prestar. abre el velo en un teléfono, o deja que los otros modos respondan.",
|
||||
"deniedTitle": "los sentidos de campo siguen cerrados",
|
||||
"denied": "Este recipiente rechazó sus sentidos de movimiento. Concede el acceso al movimiento en los ajustes de tu navegador, y luego alza el medidor de nuevo."
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSIER DEL PRESENTE",
|
||||
"none": "ningún espíritu invocado todavía",
|
||||
@@ -240,5 +264,92 @@
|
||||
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
|
||||
"back": "← volver al códice",
|
||||
"notFound": "este espíritu no figura en el registro"
|
||||
},
|
||||
"shop": {
|
||||
"back": "← el umbral",
|
||||
"title": "EL ARSENAL",
|
||||
"subtitle": "instrumentos para quienes quisieran golpear más fuerte",
|
||||
"flagship": {
|
||||
"tag": "instrumento insignia",
|
||||
"name": "THE ULTIMATE QUANTUM BOX",
|
||||
"desc": "Un instrumento de sesión de mano ESP32-P4/C6 — cámara térmica, matriz de antenas EMF, geófono, preamplificador de micrófono spirit-box y una pequeña cara OLED que te devuelve la mirada. Inicia sesión con la misma cuenta de Quantumancy que ya habitas y desbloquea los modos de este sitio impulsados por hardware: mapas térmicos de apariciones, feeds de anomalías de vibración, EMF calibrado.",
|
||||
"specs": {
|
||||
"thermal": "cámara térmica",
|
||||
"emf": "matriz de antenas EMF",
|
||||
"geophone": "geófono",
|
||||
"mic": "preamplificador de micrófono spirit-box",
|
||||
"oled": "pantalla OLED"
|
||||
},
|
||||
"price": "est. $249 — preventa"
|
||||
},
|
||||
"modulesTitle": "MÓDULOS QUE GOLPEAN",
|
||||
"unlocksLabel": "desbloquea:",
|
||||
"modules": {
|
||||
"emfArray": {
|
||||
"name": "Matriz de Antenas EMF",
|
||||
"desc": "un ramo de sensores de campo multieje, olfateando el aire en busca de intenciones",
|
||||
"unlocks": "un modo CAMPO más rico y calibrado",
|
||||
"price": "est. $59 — preventa"
|
||||
},
|
||||
"geophone": {
|
||||
"name": "Puck Geófono",
|
||||
"desc": "oye pasos en habitaciones donde nadie está de pie",
|
||||
"unlocks": "el feed de anomalías de vibración",
|
||||
"price": "est. $49 — preventa"
|
||||
},
|
||||
"spiritMic": {
|
||||
"name": "Preamplificador Spirit Mic",
|
||||
"desc": "baja el suelo de ruido para que los susurros lleguen más limpios",
|
||||
"unlocks": "capturas EVP más limpias",
|
||||
"price": "est. $69 — preventa"
|
||||
},
|
||||
"uvIlluminator": {
|
||||
"name": "Iluminador UV/IR",
|
||||
"desc": "pinta la oscuridad en espectros que los vivos jamás debieron ver",
|
||||
"unlocks": "sesiones nocturnas + trabajo de cámara",
|
||||
"price": "est. $39 — preventa"
|
||||
}
|
||||
},
|
||||
"interests": {
|
||||
"quantumBox": "la Ultimate Quantum Box",
|
||||
"emfArray": "Matriz de Antenas EMF",
|
||||
"geophone": "Puck Geófono",
|
||||
"spiritMic": "Preamplificador Spirit Mic",
|
||||
"uvIlluminator": "Iluminador UV/IR"
|
||||
},
|
||||
"waitlist": {
|
||||
"title": "EL MANIFIESTO",
|
||||
"desc": "deja una dirección y una obsesión — cuando salgan las primeras cajas, nos abriremos paso primero hasta los nombres de esta lista",
|
||||
"emailLabel": "dirección de email",
|
||||
"emailPlaceholder": "tu@algun.lugar.en.la.estatica",
|
||||
"interestLabel": "interés",
|
||||
"submit": "unirse al manifiesto",
|
||||
"pending": "grabando tu nombre…",
|
||||
"listed": "estás en el manifiesto — nos abriremos paso cuando salgan las cajas",
|
||||
"alreadyListed": "ya estás en el manifiesto",
|
||||
"invalid": "esa dirección no nos alcanza",
|
||||
"crowded": "el velo está abarrotado — inténtalo más tarde",
|
||||
"error": "algo se agitó en la estática — tu nombre no quedó grabado"
|
||||
},
|
||||
"honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
|
||||
},
|
||||
"haunting": {
|
||||
"whisper": {
|
||||
"behindYou": "detrás de ti",
|
||||
"cold": "frío",
|
||||
"stay": "quédate",
|
||||
"seesYou": "te ve",
|
||||
"closer": "más cerca",
|
||||
"stillHere": "sigue aquí",
|
||||
"lineListening": "la línea escucha",
|
||||
"dontLook": "no mires",
|
||||
"beneathStatic": "bajo la estática",
|
||||
"knockTwice": "golpea dos veces"
|
||||
},
|
||||
"title": {
|
||||
"seesYou": "QUANTUMANCY — te ve",
|
||||
"behindYou": "QUANTUM▓NCY — detrás de ti",
|
||||
"listening": "…escuchando…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user