diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index f1f62b4..4561e86 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -81,6 +81,13 @@ def fragment_prompt(source: str, anomaly: dict, language: str = "en") -> str: f"{anomaly.get('magnitude', '???')} dB over the room's floor). " "What single word was hidden in it?" ) + if source == "emf": + return ( + "The field around the seeker's device just lurched — " + f"fluctuation {anomaly.get('magnitude', '???')} above baseline " + f"near {anomaly.get('frequency', '???')} µT. Something passed " + "through. What word did it leave?" + ) return "A pulse moves through the wires. What word does it carry?" diff --git a/backend/app/llm/service.py b/backend/app/llm/service.py index e3bdaf5..0e91b4a 100644 --- a/backend/app/llm/service.py +++ b/backend/app/llm/service.py @@ -67,7 +67,12 @@ class SpiritService: settings.ollama_fast_model, prompts.fragment_prompt(source, anomaly, language), system=prompts.fragment_system( - "a spirit box" if source == "radio" else "an EVP recorder", language + { + "radio": "a spirit box", + "evp": "an EVP recorder", + "emf": "an EMF field meter", + }.get(source, "the veil"), + language, ), options={"num_predict": 16, "temperature": 0.95}, ) diff --git a/backend/app/main.py b/backend/app/main.py index 192dab5..2714fb8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from app.config import settings from app.db import Base, engine from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router +from app.routes.shop import router as shop_router from app.ws import AUDIO_DIR from app.ws import router as ws_router @@ -27,6 +28,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Quantumancy", lifespan=lifespan) app.include_router(auth_router) app.include_router(codex_router) +app.include_router(shop_router) app.include_router(ws_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 425f322..02b6c67 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.entity import Entity from app.models.entity_sighting import EntitySighting from app.models.event import Event from app.models.user import User +from app.models.waitlist_entry import WaitlistEntry __all__ = [ "User", @@ -12,4 +13,5 @@ __all__ = [ "Entity", "EntitySighting", "Event", + "WaitlistEntry", ] diff --git a/backend/app/models/waitlist_entry.py b/backend/app/models/waitlist_entry.py new file mode 100644 index 0000000..c3e5325 --- /dev/null +++ b/backend/app/models/waitlist_entry.py @@ -0,0 +1,20 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class WaitlistEntry(Base): + """A seeker who wants the Ultimate Quantum Box hardware when it ships.""" + + __tablename__ = "waitlist_entries" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + interest: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/routes/shop.py b/backend/app/routes/shop.py new file mode 100644 index 0000000..c2e6ae2 --- /dev/null +++ b/backend/app/routes/shop.py @@ -0,0 +1,50 @@ +"""Shop endpoints: the waitlist for the Ultimate Quantum Box hardware.""" + +import re + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_db +from app.models.waitlist_entry import WaitlistEntry +from app.rate_limit import RateLimiter + +router = APIRouter(prefix="/api/shop", tags=["shop"]) + +waitlist_limiter = RateLimiter(max_requests=5, window_seconds=3600) + +_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$") + + +@router.post("/waitlist", status_code=status.HTTP_201_CREATED) +async def join_waitlist( + payload: dict, request: Request, db: AsyncSession = Depends(get_db) +): + email = str(payload.get("email", "")).strip().lower() + interest = payload.get("interest") + if not _EMAIL_RE.match(email): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us" + ) + + client_ip = request.client.host if request.client else "unknown" + if not waitlist_limiter.allow(client_ip): + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + "the veil is crowded — try again later", + ) + + existing = await db.scalar( + select(WaitlistEntry).where(WaitlistEntry.email == email) + ) + if existing is not None: + # Idempotent: re-registering the same address is a no-op. + return {"status": "already_listed", "email": email} + + entry = WaitlistEntry( + email=email, interest=str(interest)[:64] if interest else None + ) + db.add(entry) + await db.commit() + return {"status": "listed", "email": email} diff --git a/backend/app/ws.py b/backend/app/ws.py index 5935c4f..6c32990 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -46,7 +46,7 @@ router = APIRouter() # Alias so tests can swap in the NullPool test session maker. session_maker = _default_session_maker -MODES = {"wire", "evp", "radio", "ouija"} +MODES = {"wire", "evp", "radio", "ouija", "emf"} # Per-user limiters for every LLM-triggering message type (spec §5). fragment_limiter = RateLimiter(max_requests=30, window_seconds=60) diff --git a/backend/tests/test_shop.py b/backend/tests/test_shop.py new file mode 100644 index 0000000..bdb8871 --- /dev/null +++ b/backend/tests/test_shop.py @@ -0,0 +1,29 @@ +import pytest + + +@pytest.mark.asyncio +async def test_waitlist_join_and_idempotent_rejoin(client): + response = await client.post( + "/api/shop/waitlist", json={"email": "hunter@example.com", "interest": "quantum-box"} + ) + assert response.status_code == 201 + assert response.json()["status"] == "listed" + + again = await client.post( + "/api/shop/waitlist", json={"email": "hunter@example.com"} + ) + assert again.status_code == 201 + assert again.json()["status"] == "already_listed" + + +@pytest.mark.asyncio +async def test_waitlist_rejects_bad_email(client): + response = await client.post("/api/shop/waitlist", json={"email": "not-an-email"}) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_waitlist_is_public(client): + # No auth cookie — the shop is open to every passing seeker. + response = await client.post("/api/shop/waitlist", json={"email": "ghost@example.org"}) + assert response.status_code == 201 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2336cee..a33a6a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,11 +3,13 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import { AuthProvider } from './state/auth' +import { HauntingLayer } from './components/HauntingLayer' import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexPage } from './pages/CodexPage' import { EnterPage } from './pages/EnterPage' import { LandingPage } from './pages/LandingPage' import { SeancePage } from './pages/SeancePage' +import { ShopPage } from './pages/ShopPage' function App() { return ( @@ -17,12 +19,14 @@ function App() {
+ } /> } /> } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index 8551043..82e4e3d 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -43,7 +43,7 @@ export function adaptiveMax(values: number[], floor: number): number { return max * 1.15 } -/** Marker color per anomaly source: wire green, evp violet, radio orange. */ +/** Marker color per anomaly source: wire green, evp violet, radio orange, emf amber. */ export function anomalyColor(source: AnomalyEntry['source']): string { switch (source) { case 'wire': @@ -52,6 +52,8 @@ export function anomalyColor(source: AnomalyEntry['source']): string { return '#b26bff' case 'radio': return '#ff9d4a' + case 'emf': + return '#ffd24a' } } diff --git a/frontend/src/components/HauntingLayer.css b/frontend/src/components/HauntingLayer.css new file mode 100644 index 0000000..9f50bfd --- /dev/null +++ b/frontend/src/components/HauntingLayer.css @@ -0,0 +1,145 @@ +/* HauntingLayer — the possession overlay. Sits just under the global CRT + glass (z-index 9999) so scanlines/vignette texture the apparitions. + Tokens: bg #07070d · phosphor #7cffb2 · violet #b26bff · mono 'IBM Plex Mono'. */ + +.haunting-layer { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 9998; +} + +/* Breathing vignette — quickens as the idle escalator rises. */ +.haunt-vignette { + position: absolute; + inset: -4%; + background: radial-gradient( + ellipse at center, + transparent 46%, + rgba(0, 0, 0, 0.62) 100% + ); + opacity: 0; + transition: opacity 5s ease; + animation: haunt-breathe 9s ease-in-out infinite; +} + +.haunting-layer--awake .haunt-vignette { + opacity: 0.55; +} + +@keyframes haunt-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.045); + } +} + +/* Manifest — the full-screen CRT tear. Duration is set inline per event. */ +.haunt-manifest { + position: absolute; + inset: 0; + opacity: 0; + background: + repeating-linear-gradient( + to bottom, + rgba(124, 255, 178, 0.055) 0 1px, + transparent 1px 3px + ), + radial-gradient( + ellipse at 50% 42%, + rgba(178, 107, 255, 0.12), + rgba(7, 7, 13, 0.6) 72% + ); + mix-blend-mode: screen; + animation: haunt-manifest 1s steps(2, jump-none) both; +} + +@keyframes haunt-manifest { + 0% { + opacity: 0; + transform: translateY(0); + } + 7% { + opacity: 0.85; + transform: translateY(-3px); + } + 13% { + opacity: 0.12; + transform: translateY(1px); + } + 21% { + opacity: 0.65; + transform: translateY(2px) skewX(0.4deg); + } + 29% { + opacity: 0.05; + } + 37% { + opacity: 0.5; + transform: none; + } + 55% { + opacity: 0.1; + transform: translateX(-2px); + } + 72% { + opacity: 0.35; + transform: translateX(1px); + } + 100% { + opacity: 0; + transform: none; + } +} + +/* Whispers — fleeting words that surface and dissolve in a blur. */ +.haunt-whisper { + position: absolute; + transform: translate(-50%, -50%); + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 0.85rem; + letter-spacing: 0.4em; + white-space: nowrap; + color: rgba(214, 228, 220, 0.92); + text-shadow: + 0 0 14px rgba(124, 255, 178, 0.75), + 0 0 34px rgba(178, 107, 255, 0.5); + animation: haunt-whisper-fade 2.6s ease-out both; +} + +@keyframes haunt-whisper-fade { + 0% { + opacity: 0; + filter: blur(7px); + } + 24% { + opacity: 0.9; + filter: blur(1.5px); + } + 68% { + opacity: 0.55; + filter: blur(2.5px); + } + 100% { + opacity: 0; + filter: blur(9px); + } +} + +/* Reduced motion: no flicker at all — the layer stays a static vignette. */ +@media (prefers-reduced-motion: reduce) { + .haunt-vignette, + .haunt-manifest, + .haunt-whisper { + animation: none !important; + } + + .haunt-manifest, + .haunt-whisper { + display: none; + } +} diff --git a/frontend/src/components/HauntingLayer.tsx b/frontend/src/components/HauntingLayer.tsx new file mode 100644 index 0000000..f396899 --- /dev/null +++ b/frontend/src/components/HauntingLayer.tsx @@ -0,0 +1,197 @@ +// HauntingLayer — the possession overlay, mounted once at the app root. +// It makes the browser itself feel haunted: full-screen CRT manifest +// flashes, fleeting whisper-words drifting across the glass, a vignette +// that breathes faster the longer the seeker sits idle, an ambient audio +// dread-bed (gesture-armed, autoplay-safe) and occasional document.title +// glitches. Nothing stirs during the first ten seconds after mount — the +// haunting creeps in, it never jumpscares on arrival. Everything is +// jittered, everything unsubscribes/cancels on unmount, and under +// prefers-reduced-motion there is no flicker and no title meddling. + +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + HauntBed, + IdleEscalator, + flickerTitle, + hauntBus, + pickWhisperWord, +} from '../lib/haunting' +import './HauntingLayer.css' + +/** The grace hush: nothing visible or audible before this elapses. */ +const GRACE_MS = 10_000 +/** How long a whisper lingers before dissolving (matches the CSS fade). */ +const WHISPER_LIFE_MS = 2_600 +/** Cap on simultaneous whispers so the glass never gets crowded. */ +const WHISPER_CAP = 6 + +const MANIFEST_RANGE: readonly [number, number] = [120_000, 240_000] // every 2–4 min +const WHISPER_RANGE: readonly [number, number] = [20_000, 50_000] +const FLARE_RANGE: readonly [number, number] = [45_000, 90_000] + +type Whisper = { id: number; word: string; x: number; y: number } +type Manifest = { id: number; durationMs: number } + +function jitter([min, max]: readonly [number, number]): number { + return min + Math.random() * (max - min) +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} + +/** Vignette breathing period per idle level — quicker as dread escalates. */ +function breatheMs(level: number): number { + return level >= 2 ? 3_200 : level === 1 ? 5_500 : 9_000 +} + +export function HauntingLayer() { + const { t } = useTranslation() + const [awake, setAwake] = useState(false) + const [idleLevel, setIdleLevel] = useState(0) + const [whispers, setWhispers] = useState([]) + const [manifest, setManifest] = useState(null) + const idRef = useRef(0) + + // Whisper words + glitch titles, re-read each render so a language + // switch mid-session is honored without re-arming the haunting. + const words = [ + t('haunting.whisper.behindYou', { defaultValue: 'behind you' }), + t('haunting.whisper.cold', { defaultValue: 'cold' }), + t('haunting.whisper.stay', { defaultValue: 'stay' }), + t('haunting.whisper.seesYou', { defaultValue: 'it sees you' }), + t('haunting.whisper.closer', { defaultValue: 'closer' }), + t('haunting.whisper.stillHere', { defaultValue: 'still here' }), + t('haunting.whisper.lineListening', { defaultValue: 'the line is listening' }), + t('haunting.whisper.dontLook', { defaultValue: "don't look" }), + t('haunting.whisper.beneathStatic', { defaultValue: 'beneath the static' }), + t('haunting.whisper.knockTwice', { defaultValue: 'knock twice' }), + ] + const titles = [ + t('haunting.title.seesYou', { defaultValue: 'QUANTUMANCY — it sees you' }), + t('haunting.title.behindYou', { defaultValue: 'QUANTUM▓NCY — behind you' }), + t('haunting.title.listening', { defaultValue: '…listening…' }), + ] + const wordsRef = useRef(words) + wordsRef.current = words + const titlesRef = useRef(titles) + titlesRef.current = titles + + useEffect(() => { + const reduced = prefersReducedMotion() + const escalator = new IdleEscalator() + const bed = new HauntBed() + const timers = new Set>() + let disposed = false + + // Tracked timeout: auto-cleared on unmount, guarded against late fires. + const later = (fn: () => void, ms: number) => { + const id = setTimeout(() => { + timers.delete(id) + if (!disposed) fn() + }, ms) + timers.add(id) + } + + // A self-rescheduling jittered loop: fires, then re-arms in [min, max]. + const loop = (range: readonly [number, number], fn: () => void) => { + const tick = () => { + fn() + later(tick, jitter(range)) + } + later(tick, jitter(range)) + } + + // The seeker's restlessness resets the idle clock. + const onActivity = () => escalator.notifyActivity() + const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const + for (const ev of ACTIVITY_EVENTS) window.addEventListener(ev, onActivity, { passive: true }) + + // Render reactions to the shared bus (our loops below, or anyone else). + const offManifest = hauntBus.on('manifest', ({ durationMs }) => { + idRef.current += 1 + setManifest({ id: idRef.current, durationMs }) + }) + const offWhisper = hauntBus.on('whisper', ({ word, x, y }) => { + if (!word) return + idRef.current += 1 + const id = idRef.current + setWhispers((ws) => [...ws.slice(-(WHISPER_CAP - 1)), { id, word, x, y }]) + later(() => setWhispers((ws) => ws.filter((w) => w.id !== id)), WHISPER_LIFE_MS + 250) + }) + + // The dread-bed waits for a gesture; it starts only after the hush. + bed.armOnFirstGesture() + + let stopTitle: (() => void) | null = null + const awaken = () => { + setAwake(true) + bed.start() + if (reduced) return // no flicker, no title changes under reduced motion + stopTitle = flickerTitle({ titles: () => titlesRef.current }) + loop(MANIFEST_RANGE, () => + hauntBus.emit('manifest', { durationMs: 700 + Math.random() * 900 }), + ) + loop(WHISPER_RANGE, () => { + const word = pickWhisperWord(wordsRef.current, Math.random) + if (!word) return + hauntBus.emit('whisper', { + word, + x: 0.15 + Math.random() * 0.7, + y: 0.2 + Math.random() * 0.55, + }) + }) + loop(FLARE_RANGE, () => + hauntBus.emit('flare', { strength: 0.3 + Math.random() * 0.7 }), + ) + } + later(awaken, GRACE_MS) + + // Idle polling: pace the vignette, feed the bed's intensity (level 0..2). + const idlePoll = window.setInterval(() => { + const level = escalator.level() + setIdleLevel(level) + bed.setIntensity(level / 2) + }, 4_000) + + return () => { + disposed = true + for (const id of timers) clearTimeout(id) + timers.clear() + window.clearInterval(idlePoll) + offManifest() + offWhisper() + for (const ev of ACTIVITY_EVENTS) window.removeEventListener(ev, onActivity) + stopTitle?.() + bed.stop() + } + }, []) + + return ( + @@ -338,6 +343,23 @@ export function LandingPage() { )} +
+
+

+ {t('landing.armory.title', { defaultValue: 'HARDWARE FROM THE ARMORY' })} +

+

+ {t('landing.armory.line', { + defaultValue: + 'the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.', + })} +

+ + {t('landing.armory.link', { defaultValue: 'enter the armory →' })} + +
+
+
+ + {t('nav.shop', { defaultValue: 'shop' })} + + {t('nav.codex')} @@ -417,6 +425,7 @@ function SeanceSession({ username }: { username: string }) { {state.mode === 'evp' && } {state.mode === 'radio' && } {state.mode === 'ouija' && } + {state.mode === 'emf' && } @@ -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(null) + const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null) const [tunedMhz, setTunedMhz] = useState(null) const sdrRef = useRef(null) const detectorRef = useRef(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 ( +
+

{t('seance.radio.unsupportedTitle')}

+

+ {t('seance.radio.insecure', { + defaultValue: + 'the radio dead only speak through consecrated ground — open this site via its https address.', + })} +

+

+ {t('seance.radio.insecureHint', { + defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.', + })} +

+ +
+ ) + } + if (!supported) { return (
@@ -816,9 +852,29 @@ function RadioPanel() { )}
- {error && ( + {error?.kind === 'cancelled' && ( +
+

{t('seance.radio.noDevice')}

+
+ )} + {error?.kind === 'claim' && (
-

{error}

+

{t('seance.radio.claimFailed')}

+

+ {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.', + })} +

+ +
+ )} + {error?.kind === 'other' && ( +
+

{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}

+

{error.message}

)} + + ) + } + + return ( +
+ +
+ {field !== null ? field.toFixed(2) : '—'} + + {t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '} + {baseline !== null ? baseline.toFixed(2) : '—'} + +
+ {listening && ( +

+ + {t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })} +

+ )} + {denied && ( +
+

{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}

+

+ {t('seance.emf.denied', { + defaultValue: + 'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.', + })} +

+
+ )} +
+ + +
+
+ ) +} diff --git a/frontend/src/pages/ShopPage.css b/frontend/src/pages/ShopPage.css new file mode 100644 index 0000000..c059d37 --- /dev/null +++ b/frontend/src/pages/ShopPage.css @@ -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; + } +} diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx new file mode 100644 index 0000000..d25af2f --- /dev/null +++ b/frontend/src/pages/ShopPage.tsx @@ -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 = { + '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 = { + '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 ( + + + + + + + + + ) + case 'emf-array': + return ( + + + + + + + + + ) + case 'geophone': + return ( + + + + + + + + + ) + case 'spirit-mic': + return ( + + + + + + + + ) + case 'uv-illuminator': + return ( + + + + + + + + + + ) + } +} + +export function ShopPage() { + const { t } = useTranslation() + const [email, setEmail] = useState('') + const [interest, setInterest] = useState('quantum-box') + const [state, setState] = useState('idle') + + const submit = (e: FormEvent) => { + 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 ( +
+ + ) +} diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index c2885b0..a383375 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -36,7 +36,7 @@ export type Utterance = { export type AnomalyEntry = { id: number - source: 'radio' | 'evp' | 'wire' + source: 'radio' | 'evp' | 'wire' | 'emf' frequency: number magnitude: number at: number @@ -101,7 +101,7 @@ const TOAST_CAP = 4 export type SeanceAction = | { type: 'connection'; state: VeilConnectionState } | { type: 'frame'; frame: ServerFrame; at?: number } - | { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number; at?: number } + | { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number; at?: number } | { type: 'local_question'; text: string; at?: number } | { type: 'set_mode'; mode: Mode } | { type: 'set_language'; language: Language } @@ -377,7 +377,7 @@ export type SeanceApi = { setLanguage: (language: Language) => void summon: () => void ask: (text: string) => void - sendAnomaly: (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => void + sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void playUtterance: (utteranceId: string) => void dismissToast: (id: number) => void } @@ -450,7 +450,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) { }, []) const sendAnomaly = useCallback( - (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => { + (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => { dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() }) socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude }) }, diff --git a/frontend/src/three/GhostCanvas.tsx b/frontend/src/three/GhostCanvas.tsx index 7b9afda..f754bbe 100644 --- a/frontend/src/three/GhostCanvas.tsx +++ b/frontend/src/three/GhostCanvas.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { GhostScene } from './GhostScene' import type { GhostMood, GhostVisualState } from './GhostScene' +import { hauntBus } from '../lib/haunting' export type GhostCanvasProps = { visual: GhostVisualState | null @@ -26,7 +27,10 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv const ampTimer = window.setInterval(() => { scene.setSpeakAmplitude(ampRef.current?.() ?? 0) }, 50) + // Apparition flares from the haunting layer surge the ghost. + const offFlare = hauntBus.on('flare', ({ strength }) => scene.flare(strength)) return () => { + offFlare() window.clearInterval(ampTimer) scene.dispose() sceneRef.current = null diff --git a/frontend/src/three/GhostScene.ts b/frontend/src/three/GhostScene.ts index c39121c..e8a9c6c 100644 --- a/frontend/src/three/GhostScene.ts +++ b/frontend/src/three/GhostScene.ts @@ -234,6 +234,10 @@ export class GhostScene { private smoothedAmp = 0 private energy = 0.15 private disposed = false + private flareAmp = 0 + private lastT = 0 + private baseScale = 1 + private baseOpacity = 1 constructor(canvas: HTMLCanvasElement) { this.canvas = canvas @@ -373,6 +377,14 @@ export class GhostScene { this.speakAmp = Math.max(0, Math.min(1, amp)) } + /** + * Brief apparition surge (strength 0..1): brightness, scale and agitation + * spike, decaying back to normal over ~1.2s. Stacking flares take the max. + */ + flare(strength: number): void { + this.flareAmp = Math.max(this.flareAmp, Math.max(0, Math.min(1, strength))) + } + setVisual(visual: GhostVisualState | null): void { this.visual = visual if (!visual) { @@ -391,6 +403,8 @@ export class GhostScene { this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7) this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4) + this.baseScale = params.scale + this.baseOpacity = params.opacity this.ghost.scale.setScalar(params.scale) this.ghost.visible = true this.wisps.visible = true @@ -417,24 +431,33 @@ export class GhostScene { private renderFrame(): void { const t = this.clock.getElapsedTime() + const dt = Math.min(0.1, Math.max(0, t - this.lastT)) + this.lastT = t - // Energy target from mood; smooth toward it. + // Apparition surge decays linearly over ~1.2s. + this.flareAmp = Math.max(0, this.flareAmp - dt / 1.2) + + // Energy target from mood; smooth toward it. A flare agitates on top. const target = this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15 this.energy += (target - this.energy) * 0.04 this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25 + const flaredEnergy = Math.min(1, this.energy + this.flareAmp * 0.55) const ghostU = this.ghostMat.uniforms ghostU.uTime.value = t - ghostU.uEnergy.value = this.energy + ghostU.uEnergy.value = flaredEnergy ghostU.uSpeak.value = this.smoothedAmp + ghostU.uOpacity.value = Math.min(1.8, this.baseOpacity * (1 + this.flareAmp * 0.9)) this.wispMat.uniforms.uTime.value = t - this.wispMat.uniforms.uEnergy.value = this.energy + this.wispMat.uniforms.uEnergy.value = flaredEnergy this.wispMat.uniforms.uSpeak.value = this.smoothedAmp + this.wispMat.uniforms.uOpacity.value = 0.55 * (1 + this.flareAmp * 0.9) this.mistMat.uniforms.uTime.value = t this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1 - // Idle drift / slow camera wander. + // Idle drift / slow camera wander; the flare swells the apparition. + this.ghost.scale.setScalar(this.baseScale * (1 + this.flareAmp * 0.14)) this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12 this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3 this.veil.position.y = this.ghost.position.y - 0.3 diff --git a/frontend/src/types/webusb.d.ts b/frontend/src/types/webusb.d.ts index 2896bb7..3069605 100644 --- a/frontend/src/types/webusb.d.ts +++ b/frontend/src/types/webusb.d.ts @@ -34,6 +34,7 @@ interface USBDevice { readonly configuration: USBConfiguration | null open(): Promise close(): Promise + selectConfiguration(configurationValue: number): Promise claimInterface(interfaceNumber: number): Promise releaseInterface(interfaceNumber: number): Promise selectAlternateInterface(