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

@@ -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) {

View File

@@ -278,9 +278,14 @@ export function LandingPage() {
>
{t('landing.cta')}
</button>
<Link className="cta-secondary" to="/codex">
{t('landing.ctaCodex')}
</Link>
<div className="hero-secondary-links">
<Link className="cta-secondary" to="/codex">
{t('landing.ctaCodex')}
</Link>
<Link className="cta-secondary" to="/shop">
{t('landing.ctaShop', { defaultValue: 'visit the armory' })}
</Link>
</div>
</div>
</div>
</section>
@@ -338,6 +343,23 @@ export function LandingPage() {
</section>
)}
<section className="landing-section armory-strip">
<div className="armory-strip-inner">
<h2 className="armory-title">
{t('landing.armory.title', { defaultValue: 'HARDWARE FROM THE ARMORY' })}
</h2>
<p className="armory-line">
{t('landing.armory.line', {
defaultValue:
'the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.',
})}
</p>
<Link className="armory-link" to="/shop">
{t('landing.armory.link', { defaultValue: 'enter the armory →' })}
</Link>
</div>
</section>
<footer className="landing-footer">
<p>{t('landing.footer')}</p>
<p className="footer-colophon">{t('landing.footerColophon')}</p>

View File

@@ -749,14 +749,16 @@
.evp-panel,
.radio-panel,
.ouija-panel,
.wire-panel {
.wire-panel,
.emf-panel {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.evp-scope,
.radio-waterfall {
.radio-waterfall,
.emf-gauge {
width: 100%;
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 4px;
@@ -772,8 +774,13 @@
height: 110px;
}
.emf-gauge {
height: 140px;
}
.evp-readout,
.radio-readout {
.radio-readout,
.emf-readout {
display: flex;
justify-content: space-between;
align-items: baseline;
@@ -798,6 +805,70 @@
color: rgba(211, 233, 219, 0.75);
}
/* emf */
.emf-level {
color: #7cffb2;
font-size: 1.5rem;
text-shadow: 0 0 14px rgba(124, 255, 178, 0.65);
font-variant-numeric: tabular-nums;
}
.emf-baseline {
font-size: 0.72rem;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
.emf-listening {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.76rem;
color: rgba(211, 233, 219, 0.75);
}
.emf-actions {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.7rem;
}
.emf-actions .panel-action {
margin-top: 0;
}
/* smaller secondary control (calibrate, try again) */
.panel-subaction {
align-self: flex-start;
background: transparent;
border: 1px solid rgba(124, 255, 178, 0.3);
border-radius: 3px;
color: rgba(124, 255, 178, 0.75);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.32rem 0.7rem;
cursor: pointer;
transition: box-shadow 150ms ease, color 150ms ease;
}
.panel-subaction:hover:not(:disabled) {
color: #7cffb2;
box-shadow: 0 0 10px rgba(124, 255, 178, 0.25);
}
.panel-subaction:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.panel-notice .panel-subaction {
margin-top: 0.2rem;
}
.rec-dot {
width: 8px;
height: 8px;

View File

@@ -1,5 +1,5 @@
// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija), transmission log and direct-contact row.
// (wire / evp / radio / ouija / emf), transmission log and direct-contact row.
//
// Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
@@ -25,7 +25,9 @@ import { Transcript } from '../components/Transcript'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import { EmfSensorListener } from '../lib/emf'
import {
isSecureContext,
isSupported,
RtlSdr,
SpectrumAnomalyDetector,
@@ -36,7 +38,9 @@ import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types'
import './SeancePage.css'
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf']
// Fallback tab labels for modes the i18n catalogs don't carry yet.
const MODE_LABEL_DEFAULTS: Partial<Record<Mode, string>> = { emf: 'FIELD' }
// Board-area views; labels fall back to these until the i18n JSONs land.
const VIEWS = [
@@ -207,7 +211,7 @@ function SeanceSession({ username }: { username: string }) {
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`)}
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
</button>
))}
</nav>
@@ -240,6 +244,10 @@ function SeanceSession({ username }: { username: string }) {
</button>
</div>
<Link to="/shop" className="seance-codex-link">
{t('nav.shop', { defaultValue: 'shop' })}
</Link>
<Link to="/codex" className="seance-codex-link">
{t('nav.codex')}
</Link>
@@ -417,6 +425,7 @@ function SeanceSession({ username }: { username: string }) {
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
{state.mode === 'emf' && <EmfPanel />}
</section>
</aside>
</main>
@@ -679,9 +688,11 @@ function EvpPanel() {
function RadioPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
// WebUSB hides itself entirely outside secure ground (https / localhost).
const secure = useMemo(() => isSecureContext(), [])
const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null)
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
@@ -743,12 +754,15 @@ function RadioPanel() {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const name = err instanceof DOMException ? err.name : ''
// NotFoundError: the seeker dismissed the picker — a gentle nudge only.
// Security/Network/claim failures: something else is holding the
// dongle (kernel DVB driver, missing WinUSB driver) — troubleshoot.
setError(
name === 'NotFoundError' || /no device selected/i.test(msg)
? t('seance.radio.noDevice')
: /claim/i.test(msg)
? t('seance.radio.claimFailed')
: msg,
? { kind: 'cancelled' }
: name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
? { kind: 'claim' }
: { kind: 'other', message: msg },
)
setPhase('idle')
void sdr.close()
@@ -774,7 +788,7 @@ function RadioPanel() {
}
},
onError: (err) => {
if (sdrRef.current === sdr) setError(err.message)
if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
},
})
.then(() => {
@@ -782,12 +796,34 @@ function RadioPanel() {
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError(err instanceof Error ? err.message : String(err))
setError({ kind: 'other', message: err instanceof Error ? err.message : String(err) })
setPhase('idle')
}
})
}
if (!secure) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>
{t('seance.radio.insecure', {
defaultValue:
'the radio dead only speak through consecrated ground — open this site via its https address.',
})}
</p>
<p className="dim troubleshoot">
{t('seance.radio.insecureHint', {
defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
if (!supported) {
return (
<div className="panel-notice">
@@ -816,9 +852,29 @@ function RadioPanel() {
</span>
)}
</div>
{error && (
{error?.kind === 'cancelled' && (
<div className="panel-notice">
<p>{t('seance.radio.noDevice')}</p>
</div>
)}
{error?.kind === 'claim' && (
<div className="panel-notice error">
<p>{error}</p>
<h4>{t('seance.radio.claimFailed')}</h4>
<p>
{t('seance.radio.claimFix', {
defaultValue:
'linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.',
})}
</p>
<button type="button" className="panel-subaction" onClick={() => void start()}>
{t('seance.radio.tryAgain', { defaultValue: 'try again' })}
</button>
</div>
)}
{error?.kind === 'other' && (
<div className="panel-notice error">
<h4>{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}</h4>
<p>{error.message}</p>
</div>
)}
<button
@@ -872,3 +928,206 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
</div>
)
}
// ---- EMF panel: the phone becomes a field meter ----
/** Highest field strength the gauge renders before pinning the needle. */
const EMF_GAUGE_MAX = 6
function EmfPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [field, setField] = useState<number | null>(null)
const [baseline, setBaseline] = useState<number | null>(null)
const listenerRef = useRef<EmfSensorListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
// Drop the sensor listeners when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) l.stop()
},
[],
)
const drawGauge = (strength: number) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const cx = w / 2
const cy = h - 14
const r = Math.min(w / 2 - 14, h - 30)
const norm = Math.max(0, Math.min(1, strength / EMF_GAUGE_MAX))
const hue = 150 + norm * 122 // phosphor at rest → violet when it surges
// Track arc.
ctx.lineWidth = 10
ctx.lineCap = 'round'
ctx.strokeStyle = 'rgba(124, 255, 178, 0.12)'
ctx.beginPath()
ctx.arc(cx, cy, r, Math.PI, 2 * Math.PI)
ctx.stroke()
// Lit arc.
if (norm > 0.004) {
ctx.strokeStyle = `hsla(${hue} 90% 68% / 0.92)`
ctx.shadowColor = `hsla(${hue} 90% 68% / 0.8)`
ctx.shadowBlur = 12
ctx.beginPath()
ctx.arc(cx, cy, r, Math.PI, Math.PI + norm * Math.PI)
ctx.stroke()
ctx.shadowBlur = 0
}
// Tick marks.
ctx.lineWidth = 1
ctx.strokeStyle = 'rgba(211, 233, 219, 0.25)'
for (let i = 0; i <= 8; i++) {
const a = Math.PI + (i / 8) * Math.PI
ctx.beginPath()
ctx.moveTo(cx + Math.cos(a) * (r - 10), cy + Math.sin(a) * (r - 10))
ctx.lineTo(cx + Math.cos(a) * (r - 15), cy + Math.sin(a) * (r - 15))
ctx.stroke()
}
// Needle.
const ang = Math.PI + norm * Math.PI
ctx.lineWidth = 2
ctx.strokeStyle = `hsla(${hue} 85% 78% / 0.95)`
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(ang) * (r - 20), cy + Math.sin(ang) * (r - 20))
ctx.stroke()
ctx.fillStyle = '#7cffb2'
ctx.beginPath()
ctx.arc(cx, cy, 3, 0, 2 * Math.PI)
ctx.fill()
}
// Paint the resting needle once (and whenever support is re-checked).
useEffect(() => {
if (supported) drawGauge(0)
}, [supported])
const start = async () => {
if (pending || listening || !supported) return
setDenied(false)
setPending(true)
const listener = listenerRef.current ?? new EmfSensorListener()
listenerRef.current = listener
try {
// iOS gates the senses behind an explicit, gesture-triggered grant.
if (EmfSensorListener.needsPermission()) {
const granted = await EmfSensorListener.requestPermission()
if (!granted) {
if (listenerRef.current === listener) setDenied(true)
return
}
}
if (listenerRef.current !== listener) return // unmounted mid-permission
listener.start({
onAnomaly: (a) => sendAnomaly('emf', a.frequency, a.magnitude),
onReading: (strength, base) => {
drawGauge(strength)
setField(strength)
setBaseline(base)
},
})
setListening(true)
} finally {
setPending(false)
}
}
const stop = () => {
const l = listenerRef.current
if (l) l.stop()
setListening(false)
setField(null)
setBaseline(null)
drawGauge(0)
}
const calibrate = () => {
listenerRef.current?.reset()
}
if (!supported) {
return (
<div className="panel-notice">
<h4>
{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}
</h4>
<p>
{t('seance.emf.unsupported', {
defaultValue:
'a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.',
})}
</p>
<button type="button" className="panel-action" disabled>
{t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
</div>
)
}
return (
<div className="emf-panel">
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
<div className="emf-readout">
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
<span className="emf-baseline dim">
{t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '}
{baseline !== null ? baseline.toFixed(2) : '—'}
</span>
</div>
{listening && (
<p className="emf-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}</h4>
<p>
{t('seance.emf.denied', {
defaultValue:
'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.',
})}
</p>
</div>
)}
<div className="emf-actions">
<button
type="button"
className="panel-action"
onClick={() => (listening ? stop() : void start())}
disabled={pending}
>
{listening
? t('seance.emf.stop', { defaultValue: 'ground the meter' })
: t('seance.emf.start', { defaultValue: 'raise the meter' })}
</button>
<button
type="button"
className="panel-subaction"
onClick={calibrate}
disabled={!listening}
>
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
</button>
</div>
</div>
)
}

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