feat: complete Quantumancy web app — full frontend + docs

Frontend (React 18 + TS + Vite):
- Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits
- Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive),
  Ouija planchette board spelling utterances, transcript with TTS replay,
  entity dossier, direct contact streaming, passive/active listening
- Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB
  RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact
- Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete
- State: VeilSocket (reconnect/backoff), seance reducer, auth context
- 72 vitest tests green; served by FastAPI at :7777

Docs: README + as-built plans 3-7
This commit is contained in:
Indiana
2026-07-20 21:11:49 +00:00
parent 1ec9ea5863
commit 6edbbbbc2a
48 changed files with 10494 additions and 168 deletions

View File

@@ -0,0 +1,482 @@
/* Codex dossier — single-spirit occult record. Same token palette as the registry:
bg #07070d, panel rgba(16,16,26,0.85), phosphor #7cffb2, violet #b26bff,
blood #ff3b5c (sparingly), borders rgba(124,255,178,0.18). */
.codex-detail-page {
min-height: 100vh;
width: 100%;
background:
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
box-sizing: border-box;
position: relative;
}
/* scanlines + vignette */
.codex-detail-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%);
}
/* ---- nav / states ---- */
.codex-detail-nav {
max-width: 780px;
margin: 0 auto 2.2rem;
}
.codex-detail-back {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
transition: color 160ms ease, text-shadow 160ms ease;
}
.codex-detail-back:hover,
.codex-detail-back:focus-visible {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
}
.codex-detail-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 5rem 0;
animation: codex-detail-breathe 2.4s ease-in-out infinite;
}
.codex-detail-state-error {
color: #ff3b5c;
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
animation: none;
}
/* ---- 404 / not reached ---- */
.codex-detail-missing {
max-width: 560px;
margin: 4rem auto 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6rem;
}
.codex-detail-missing-sigil {
width: 110px;
height: 110px;
border-radius: 50%;
border: 1px dashed rgba(124, 255, 178, 0.35);
box-shadow:
inset 0 0 30px rgba(124, 255, 178, 0.06),
0 0 24px rgba(124, 255, 178, 0.08);
animation: codex-detail-sigil-spin 26s linear infinite;
}
.codex-detail-missing-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.4rem, 4vw, 2rem);
font-weight: 700;
letter-spacing: 0.14em;
margin: 0;
color: rgba(238, 247, 240, 0.85);
text-shadow: 0 0 18px rgba(124, 255, 178, 0.25);
animation: codex-detail-flicker 9s linear infinite;
}
/* ---- dossier sheet ---- */
.codex-dossier {
max-width: 780px;
margin: 0 auto;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 12px;
padding: clamp(1.8rem, 5vw, 3.2rem);
position: relative;
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 40px rgba(124, 255, 178, 0.05);
}
.codex-dossier::before {
content: '';
position: absolute;
inset: 10px;
border: 1px solid rgba(124, 255, 178, 0.08);
border-radius: 8px;
pointer-events: none;
}
.codex-dossier[data-rarity='rare'] {
border-color: rgba(178, 107, 255, 0.35);
}
.codex-dossier[data-rarity='mythic'] {
border-color: rgba(255, 157, 74, 0.4);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.5),
0 0 44px rgba(255, 157, 74, 0.1);
}
/* ---- dossier header ---- */
.codex-dossier-header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.55rem;
padding-bottom: 1.8rem;
border-bottom: 1px solid rgba(124, 255, 178, 0.14);
margin-bottom: 1.8rem;
}
.codex-dossier-glyph {
width: 150px;
height: 150px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 42%,
hsl(var(--glyph-hue), 82%, 74%) 0%,
hsl(var(--glyph-hue), 80%, 65%) 24%,
hsla(var(--glyph-hue), 80%, 55%, 0.3) 52%,
transparent 72%
);
filter: blur(2px);
opacity: 0.92;
animation: codex-detail-glyph-drift 8s ease-in-out infinite;
margin-bottom: 0.4rem;
}
.codex-dossier-form {
font-size: 0.64rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
.codex-dossier-name {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.9rem, 5vw, 2.8rem);
font-weight: 700;
letter-spacing: 0.16em;
margin: 0.2rem 0 0;
color: #eef7f0;
text-shadow:
0 0 18px rgba(124, 255, 178, 0.3),
0 0 46px rgba(178, 107, 255, 0.2);
animation: codex-detail-flicker 11s linear infinite;
}
.codex-dossier-epithet {
margin: 0;
font-size: 0.82rem;
letter-spacing: 0.1em;
font-style: italic;
color: rgba(178, 107, 255, 0.8);
}
.codex-dossier-rarity {
display: inline-block;
font-size: 0.62rem;
letter-spacing: 0.3em;
text-transform: uppercase;
padding: 0.26rem 0.85rem;
border: 1px solid;
border-radius: 999px;
margin-top: 0.5rem;
}
.codex-dossier-rarity[data-rarity='common'] {
color: #8a8f98;
border-color: rgba(138, 143, 152, 0.45);
}
.codex-dossier-rarity[data-rarity='uncommon'] {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-dossier-rarity[data-rarity='rare'] {
color: #b26bff;
border-color: rgba(178, 107, 255, 0.5);
}
.codex-dossier-rarity[data-rarity='mythic'] {
color: #ff9d4a;
border-color: rgba(255, 157, 74, 0.55);
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
box-shadow: 0 0 16px rgba(255, 157, 74, 0.32), inset 0 0 8px rgba(255, 157, 74, 0.12);
}
/* ---- sections ---- */
.codex-dossier-section {
margin-bottom: 2rem;
}
.codex-dossier-heading {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.6);
margin: 0 0 0.9rem;
}
.codex-dossier-heading::after {
content: '';
display: block;
width: 42px;
height: 1px;
margin-top: 0.45rem;
background: linear-gradient(90deg, rgba(124, 255, 178, 0.5), transparent);
}
.codex-dossier-persona {
margin: 0;
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 1.02rem;
line-height: 1.75;
color: rgba(238, 247, 240, 0.85);
}
.codex-dossier-quotes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.codex-dossier-quote {
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 0.94rem;
line-height: 1.6;
color: rgba(216, 230, 220, 0.8);
padding-left: 1rem;
border-left: 2px solid rgba(178, 107, 255, 0.35);
}
.codex-dossier-none {
margin: 0;
font-size: 0.75rem;
letter-spacing: 0.08em;
font-style: italic;
color: rgba(216, 230, 220, 0.4);
}
/* ---- stats row ---- */
.codex-dossier-stats {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1px;
background: rgba(124, 255, 178, 0.12);
border: 1px solid rgba(124, 255, 178, 0.12);
border-radius: 8px;
overflow: hidden;
}
.codex-dossier-stat {
background: rgba(10, 10, 18, 0.85);
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.codex-dossier-stat dt {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.42);
}
.codex-dossier-stat dd {
margin: 0;
font-size: 0.95rem;
color: #7cffb2;
text-shadow: 0 0 10px rgba(124, 255, 178, 0.3);
overflow-wrap: anywhere;
}
/* ---- voice signature table ---- */
.codex-dossier-voice {
width: 100%;
border-collapse: collapse;
border: 1px solid rgba(124, 255, 178, 0.14);
border-radius: 8px;
overflow: hidden;
font-size: 0.78rem;
}
.codex-dossier-voice th,
.codex-dossier-voice td {
padding: 0.6rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(124, 255, 178, 0.08);
}
.codex-dossier-voice tr:last-child th,
.codex-dossier-voice tr:last-child td {
border-bottom: none;
}
.codex-dossier-voice tr:nth-child(even) th,
.codex-dossier-voice tr:nth-child(even) td {
background: rgba(124, 255, 178, 0.03);
}
.codex-dossier-voice th {
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.64rem;
color: rgba(216, 230, 220, 0.5);
width: 40%;
}
.codex-dossier-voice td {
color: #b26bff;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.3);
overflow-wrap: anywhere;
}
/* ---- CTA ---- */
.codex-dossier-footer {
display: flex;
justify-content: center;
padding-top: 0.6rem;
}
.codex-detail-cta {
display: inline-block;
font-size: 0.78rem;
letter-spacing: 0.26em;
text-transform: uppercase;
text-decoration: none;
color: #07070d;
background: #7cffb2;
border: 1px solid #7cffb2;
border-radius: 6px;
padding: 0.85rem 2.2rem;
box-shadow: 0 0 22px rgba(124, 255, 178, 0.35);
transition: box-shadow 180ms ease, transform 180ms ease, background 180ms ease;
}
.codex-detail-cta:hover,
.codex-detail-cta:focus-visible {
background: #a5ffcb;
box-shadow: 0 0 34px rgba(124, 255, 178, 0.6);
transform: translateY(-2px);
outline: none;
}
.codex-detail-cta-ghost {
color: #7cffb2;
background: transparent;
}
.codex-detail-cta-ghost:hover,
.codex-detail-cta-ghost:focus-visible {
color: #07070d;
background: #7cffb2;
}
/* ---- motion ---- */
@keyframes codex-detail-flicker {
0%,
100% {
opacity: 1;
}
91% {
opacity: 1;
}
92% {
opacity: 0.55;
}
93% {
opacity: 1;
}
95% {
opacity: 0.72;
}
96% {
opacity: 1;
}
}
@keyframes codex-detail-glyph-drift {
0%,
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
50% {
transform: translateY(-8px) scale(1.05);
opacity: 1;
}
}
@keyframes codex-detail-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@keyframes codex-detail-sigil-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.codex-dossier-name,
.codex-dossier-glyph,
.codex-detail-state,
.codex-detail-missing-sigil,
.codex-detail-missing-title {
animation: none;
}
.codex-detail-cta,
.codex-detail-back {
transition: none;
}
}

View File

@@ -0,0 +1,209 @@
// Codex dossier — a single spirit's occult record at /codex/:id.
// Fetches GET /api/codex/:id; 404s render a themed "not reached" state.
import { useEffect, useState, type CSSProperties } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { CodexEntityDetail } from '../lib/types'
import './CodexEntityPage.css'
type DetailState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'notfound' }
| { status: 'ready'; entity: CodexEntityDetail }
function formatDate(iso: string, locale: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
/** Voice profiles are loose records — render whatever the veil preserved. */
function formatVoiceValue(value: unknown): string {
if (value === null || value === undefined) return '—'
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
export function CodexEntityPage() {
const { t, i18n } = useTranslation()
const { id } = useParams<{ id: string }>()
const [state, setState] = useState<DetailState>({ status: 'loading' })
useEffect(() => {
if (!id) {
setState({ status: 'notfound' })
return
}
const controller = new AbortController()
setState({ status: 'loading' })
fetch(`/api/codex/${encodeURIComponent(id)}`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 404) {
setState({ status: 'notfound' })
return
}
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
const entity = (await res.json()) as CodexEntityDetail
setState({ status: 'ready', entity })
})
.catch(() => {
if (!controller.signal.aborted) setState({ status: 'error' })
})
return () => controller.abort()
}, [id])
return (
<div className="codex-detail-page">
<div className="codex-detail-overlay" aria-hidden="true" />
<nav className="codex-detail-nav">
<Link to="/codex" className="codex-detail-back">
{t('codex.back')}
</Link>
</nav>
{state.status === 'loading' && (
<p className="codex-detail-state">
{t('codex.detail.loading', 'summoning the dossier…')}
</p>
)}
{state.status === 'error' && (
<p className="codex-detail-state codex-detail-state-error" role="alert">
{t('common.error')}
</p>
)}
{state.status === 'notfound' && (
<div className="codex-detail-missing">
<div className="codex-detail-missing-sigil" aria-hidden="true" />
<h1 className="codex-detail-missing-title">
{t('codex.detail.notReached', 'this spirit has not been reached')}
</h1>
<Link to="/codex" className="codex-detail-cta codex-detail-cta-ghost">
{t('codex.back')}
</Link>
</div>
)}
{state.status === 'ready' && (
<Dossier entity={state.entity} locale={i18n.language} />
)}
</div>
)
}
function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string }) {
const { t } = useTranslation()
const discoverer = entity.discovered_by ?? t('codex.detail.unknownMedium', 'unknown medium')
const stats: Array<{ label: string; value: string }> = [
{
label: t('codex.detail.stats.sightings', 'sightings'),
value: String(entity.sightings),
},
{
label: t('codex.detail.stats.contacts', 'contacts'),
value: String(entity.contact_count),
},
{
label: t('codex.detail.stats.discoveredBy', 'discovered by'),
value: discoverer,
},
{
label: t('codex.detail.stats.discoveredAt', 'entered the registry'),
value: formatDate(entity.discovered_at, locale),
},
]
return (
<article className="codex-dossier" data-rarity={entity.rarity}>
<header className="codex-dossier-header">
<div
className="codex-dossier-glyph"
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
aria-hidden="true"
/>
<span className="codex-dossier-form">
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
</span>
<h1 className="codex-dossier-name">{entity.name}</h1>
<p className="codex-dossier-epithet">{entity.epithet}</p>
<span className="codex-dossier-rarity" data-rarity={entity.rarity}>
{t(`codex.rarity.${entity.rarity}`)}
</span>
</header>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">{t('codex.persona')}</h2>
<p className="codex-dossier-persona">{entity.persona}</p>
</section>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.utterances', 'known utterances')}
</h2>
{entity.quotes.length > 0 ? (
<ul className="codex-dossier-quotes">
{entity.quotes.map((quote, i) => (
<li key={i} className="codex-dossier-quote">
“{quote}”
</li>
))}
</ul>
) : (
<p className="codex-dossier-none">
{t('codex.detail.noUtterances', 'no utterances recorded yet')}
</p>
)}
</section>
<section className="codex-dossier-section">
<dl className="codex-dossier-stats">
{stats.map((stat) => (
<div key={stat.label} className="codex-dossier-stat">
<dt>{stat.label}</dt>
<dd>{stat.value}</dd>
</div>
))}
</dl>
</section>
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.voiceSignature', 'voice signature')}
</h2>
<table className="codex-dossier-voice">
<tbody>
{Object.entries(entity.voice).map(([key, value]) => (
<tr key={key}>
<th scope="row">{key.replace(/_/g, ' ')}</th>
<td>{formatVoiceValue(value)}</td>
</tr>
))}
</tbody>
</table>
</section>
<footer className="codex-dossier-footer">
<Link to="/seance" className="codex-detail-cta">
{t('codex.detail.attemptContact', 'attempt contact')}
</Link>
</footer>
</article>
)
}

View File

@@ -0,0 +1,412 @@
/* The Codex — gothic registry grid. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
.codex-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 */
.codex-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%);
}
/* ---- header ---- */
.codex-header {
text-align: center;
margin: 0 auto 2.5rem;
max-width: 760px;
position: relative;
}
.codex-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;
}
.codex-back:hover,
.codex-back:focus-visible {
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
}
.codex-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: codex-flicker 9s linear infinite;
}
.codex-subtitle {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.08em;
color: rgba(216, 230, 220, 0.55);
font-style: italic;
}
/* ---- control pills ---- */
.codex-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.8rem 2rem;
margin-bottom: 2.6rem;
}
.codex-pill-group {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.45rem;
}
.codex-pill {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.42rem 0.95rem;
color: rgba(216, 230, 220, 0.6);
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 999px;
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease, box-shadow 160ms ease,
background 160ms ease;
}
.codex-pill:hover {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-pill:focus-visible {
outline: none;
border-color: #7cffb2;
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.25), 0 0 14px rgba(124, 255, 178, 0.35);
}
.codex-pill-active,
.codex-pill-active:hover {
color: #07070d;
background: #7cffb2;
border-color: #7cffb2;
box-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
}
.codex-pill-active[data-rarity='rare'] {
background: #b26bff;
border-color: #b26bff;
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
}
.codex-pill-active[data-rarity='mythic'] {
background: #ff9d4a;
border-color: #ff9d4a;
box-shadow: 0 0 18px rgba(255, 157, 74, 0.55);
}
.codex-pill-active[data-rarity='common'] {
background: #8a8f98;
border-color: #8a8f98;
box-shadow: 0 0 14px rgba(138, 143, 152, 0.4);
}
/* ---- states ---- */
.codex-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 4rem 0;
animation: codex-breathe 2.4s ease-in-out infinite;
}
.codex-state-error {
color: #ff3b5c;
text-shadow: 0 0 14px rgba(255, 59, 92, 0.45);
animation: none;
}
/* ---- grid + cards ---- */
.codex-grid {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 1280px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1.4rem;
}
.codex-card-item {
display: flex;
}
.codex-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 10px;
padding: 1.8rem 1.4rem 1.5rem;
position: relative;
overflow: hidden;
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.codex-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(124, 255, 178, 0.05), transparent 35%);
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.codex-card:hover,
.codex-card:focus-visible {
transform: translateY(-3px);
border-color: rgba(124, 255, 178, 0.5);
box-shadow:
0 0 0 1px rgba(124, 255, 178, 0.12),
0 10px 34px rgba(0, 0, 0, 0.55),
0 0 26px rgba(124, 255, 178, 0.14);
}
.codex-card:hover::before,
.codex-card:focus-visible::before {
opacity: 1;
}
.codex-card:focus-visible {
outline: none;
}
.codex-card[data-rarity='rare']:hover,
.codex-card[data-rarity='rare']:focus-visible {
border-color: rgba(178, 107, 255, 0.55);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 26px rgba(178, 107, 255, 0.2);
}
.codex-card[data-rarity='mythic']:hover,
.codex-card[data-rarity='mythic']:focus-visible {
border-color: rgba(255, 157, 74, 0.55);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 30px rgba(255, 157, 74, 0.22);
}
/* ---- ghost glyph ---- */
.codex-glyph-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
margin-bottom: 1rem;
}
.codex-glyph {
width: 88px;
height: 88px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 42%,
hsl(var(--glyph-hue), 82%, 74%) 0%,
hsl(var(--glyph-hue), 80%, 65%) 24%,
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
transparent 72%
);
filter: blur(1.5px);
opacity: 0.9;
animation: codex-glyph-drift 7s ease-in-out infinite;
}
.codex-glyph-form {
font-size: 0.62rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
/* ---- card body ---- */
.codex-card-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.codex-card-name {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.28rem;
font-weight: 700;
letter-spacing: 0.1em;
margin: 0;
color: #eef7f0;
}
.codex-card-epithet {
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.08em;
color: rgba(178, 107, 255, 0.75);
font-style: italic;
}
.codex-rarity {
display: inline-block;
font-size: 0.6rem;
letter-spacing: 0.28em;
text-transform: uppercase;
padding: 0.22rem 0.7rem;
border: 1px solid;
border-radius: 999px;
margin-top: 0.2rem;
}
.codex-rarity[data-rarity='common'] {
color: #8a8f98;
border-color: rgba(138, 143, 152, 0.45);
}
.codex-rarity[data-rarity='uncommon'] {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.codex-rarity[data-rarity='rare'] {
color: #b26bff;
border-color: rgba(178, 107, 255, 0.5);
}
.codex-rarity[data-rarity='mythic'] {
color: #ff9d4a;
border-color: rgba(255, 157, 74, 0.55);
text-shadow: 0 0 10px rgba(255, 157, 74, 0.8);
box-shadow: 0 0 14px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
}
.codex-card-quote {
margin: 0.5rem 0 0;
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 0.86rem;
line-height: 1.55;
color: rgba(216, 230, 220, 0.78);
}
.codex-card-meta {
margin: 0.35rem 0 0;
font-size: 0.66rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.55);
}
.codex-card-discoverer {
color: rgba(216, 230, 220, 0.38);
margin-top: 0;
}
/* ---- motion ---- */
@keyframes codex-flicker {
0%,
100% {
opacity: 1;
}
91% {
opacity: 1;
}
92% {
opacity: 0.55;
}
93% {
opacity: 1;
}
95% {
opacity: 0.72;
}
96% {
opacity: 1;
}
}
@keyframes codex-glyph-drift {
0%,
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
50% {
transform: translateY(-6px) scale(1.06);
opacity: 1;
}
}
@keyframes codex-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.codex-title,
.codex-glyph,
.codex-state {
animation: none;
}
.codex-card,
.codex-pill,
.codex-back {
transition: none;
}
}

View File

@@ -0,0 +1,190 @@
// The Codex — public registry of every spirit ever contacted.
// Filterable / sortable grid of entity dossiers, fetched from GET /api/codex.
import { useEffect, useState, type CSSProperties } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { CodexEntity, CodexListResponse, Rarity, SortOrder } from '../lib/types'
import './CodexPage.css'
type RarityFilter = Rarity | 'all'
type LoadState = 'loading' | 'error' | 'ready'
const RARITIES: readonly Rarity[] = ['common', 'uncommon', 'rare', 'mythic']
const SORTS: readonly SortOrder[] = ['recent', 'contacted']
function formatDate(iso: string, locale: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
export function CodexPage() {
const { t, i18n } = useTranslation()
const [rarity, setRarity] = useState<RarityFilter>('all')
const [sort, setSort] = useState<SortOrder>('recent')
const [entities, setEntities] = useState<CodexEntity[]>([])
const [state, setState] = useState<LoadState>('loading')
useEffect(() => {
const controller = new AbortController()
setState('loading')
const params = new URLSearchParams({ sort, limit: '120' })
if (rarity !== 'all') params.set('rarity', rarity)
fetch(`/api/codex?${params.toString()}`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`the registry answered ${res.status}`)
return (await res.json()) as CodexListResponse
})
.then((data) => {
setEntities(data.entities)
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [rarity, sort])
return (
<div className="codex-page">
<div className="codex-overlay" aria-hidden="true" />
<header className="codex-header">
<Link to="/" className="codex-back">
{t('codex.header.back', '← the threshold')}
</Link>
<h1 className="codex-title">{t('codex.header.title', 'THE CODEX')}</h1>
<p className="codex-subtitle">
{t(
'codex.header.subtitle',
'every spirit ever contacted, catalogued by the seekers who reached them',
)}
</p>
</header>
<div className="codex-controls">
<div
className="codex-pill-group"
role="group"
aria-label={t('codex.controls.rarity', 'filter by rarity')}
>
<button
type="button"
className={`codex-pill${rarity === 'all' ? ' codex-pill-active' : ''}`}
aria-pressed={rarity === 'all'}
onClick={() => setRarity('all')}
>
{t('codex.filterAll')}
</button>
{RARITIES.map((r) => (
<button
key={r}
type="button"
className={`codex-pill${rarity === r ? ' codex-pill-active' : ''}`}
data-rarity={r}
aria-pressed={rarity === r}
onClick={() => setRarity(r)}
>
{t(`codex.rarity.${r}`)}
</button>
))}
</div>
<div
className="codex-pill-group"
role="group"
aria-label={t('codex.controls.sort', 'sort the registry')}
>
{SORTS.map((s) => (
<button
key={s}
type="button"
className={`codex-pill${sort === s ? ' codex-pill-active' : ''}`}
aria-pressed={sort === s}
onClick={() => setSort(s)}
>
{t(`codex.sort.${s}`)}
</button>
))}
</div>
</div>
{state === 'loading' && (
<p className="codex-state">{t('codex.state.loading', 'consulting the registry…')}</p>
)}
{state === 'error' && (
<p className="codex-state codex-state-error" role="alert">
{t('common.error')}
</p>
)}
{state === 'ready' && entities.length === 0 && (
<p className="codex-state">
{t('codex.state.empty', 'no spirits of this tier have been reached yet')}
</p>
)}
{state === 'ready' && entities.length > 0 && (
<ul className="codex-grid">
{entities.map((entity) => (
<CodexCard key={entity.id} entity={entity} locale={i18n.language} />
))}
</ul>
)}
</div>
)
}
function CodexCard({ entity, locale }: { entity: CodexEntity; locale: string }) {
const { t } = useTranslation()
const discoverer = entity.discovered_by ?? t('codex.card.unknownMedium', 'unknown medium')
return (
<li className="codex-card-item">
<Link to={`/codex/${entity.id}`} className="codex-card" data-rarity={entity.rarity}>
<div className="codex-glyph-wrap">
<div
className="codex-glyph"
style={{ '--glyph-hue': entity.visual.hue } as CSSProperties}
aria-hidden="true"
/>
<span className="codex-glyph-form">
{t(`codex.form.${entity.visual.form}`, entity.visual.form)}
</span>
</div>
<div className="codex-card-body">
<h2 className="codex-card-name">{entity.name}</h2>
<p className="codex-card-epithet">{entity.epithet}</p>
<span className="codex-rarity" data-rarity={entity.rarity}>
{t(`codex.rarity.${entity.rarity}`)}
</span>
{entity.quotes.length > 0 && (
<blockquote className="codex-card-quote">“{entity.quotes[0]}”</blockquote>
)}
<p className="codex-card-meta">
{t('codex.card.contacted', {
count: entity.contact_count,
defaultValue: 'contacted {{count}} times',
defaultValue_one: 'contacted {{count}} time',
defaultValue_other: 'contacted {{count}} times',
})}
</p>
<p className="codex-card-meta codex-card-discoverer">
{t('codex.card.discoveredBy', {
name: discoverer,
date: formatDate(entity.discovered_at, locale),
defaultValue: 'discovered by {{name}} · {{date}}',
})}
</p>
</div>
</Link>
</li>
)
}

View File

@@ -0,0 +1,275 @@
/* EnterPage: the threshold. Centered gothic auth panel over drifting CSS
fog. Scoped under .enter-page. */
.enter-page {
position: relative;
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #07070d;
color: #d8e6dd;
font-family: 'IBM Plex Mono', monospace;
overflow: hidden;
padding: 2rem 1rem;
box-sizing: border-box;
}
/* ---------- smoky backdrop (pure CSS fog) ---------- */
.enter-fog {
position: absolute;
inset: 0;
pointer-events: none;
}
.enter-fog::before,
.enter-fog::after {
content: '';
position: absolute;
width: 65vmax;
height: 65vmax;
border-radius: 50%;
filter: blur(70px);
opacity: 0.14;
}
.enter-fog::before {
background: radial-gradient(circle, #7cffb2 0%, transparent 62%);
top: -18%;
left: -12%;
animation: fog-drift-a 27s ease-in-out infinite alternate;
}
.enter-fog::after {
background: radial-gradient(circle, #b26bff 0%, transparent 62%);
bottom: -22%;
right: -14%;
animation: fog-drift-b 34s ease-in-out infinite alternate;
}
@keyframes fog-drift-a {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(9vw, 6vh) scale(1.15); }
}
@keyframes fog-drift-b {
from { transform: translate(0, 0) scale(1.1); }
to { transform: translate(-8vw, -7vh) scale(0.95); }
}
/* dimmed vignette + scanlines */
.enter-scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(ellipse at center, transparent 40%, rgba(3, 3, 7, 0.75) 100%),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.022) 0,
rgba(255, 255, 255, 0.022) 1px,
transparent 1px,
transparent 3px
);
}
/* ---------- the panel ---------- */
.enter-panel {
position: relative;
z-index: 2;
width: min(92vw, 420px);
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 2.6rem 2.2rem 2rem;
backdrop-filter: blur(4px);
box-shadow:
0 0 70px rgba(0, 0, 0, 0.65),
0 0 32px rgba(124, 255, 178, 0.07);
}
.enter-title {
margin: 0 0 0.4rem;
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: 1.7rem;
letter-spacing: 0.3em;
text-align: center;
text-transform: uppercase;
color: #eafff3;
text-shadow:
0 0 16px rgba(124, 255, 178, 0.45),
0 0 50px rgba(124, 255, 178, 0.15);
animation: enter-flicker 6s infinite;
}
@keyframes enter-flicker {
0%, 100% { opacity: 1; }
93% { opacity: 1; }
94% { opacity: 0.7; }
95% { opacity: 1; }
97% { opacity: 0.85; }
98% { opacity: 1; }
}
.enter-sub {
margin: 0 0 1.9rem;
text-align: center;
font-size: 0.78rem;
font-style: italic;
color: rgba(216, 230, 221, 0.5);
min-height: 1.2em;
}
/* ---------- mode toggle ---------- */
.enter-tabs {
display: flex;
border: 1px solid rgba(124, 255, 178, 0.18);
margin-bottom: 1.6rem;
}
.enter-tab {
flex: 1;
background: transparent;
border: none;
border-radius: 0;
padding: 0.7rem 0;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(216, 230, 221, 0.55);
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.enter-tab:hover {
color: #7cffb2;
background: rgba(124, 255, 178, 0.06);
}
.enter-tab.active,
.enter-tab.active:hover {
color: #07120c;
background: #7cffb2;
box-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
}
.enter-tab:focus-visible {
outline: 2px solid #b26bff;
outline-offset: -2px;
}
/* ---------- form ---------- */
.enter-form {
display: block;
}
.enter-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1.15rem;
}
.enter-field span {
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.65);
}
.enter-field input {
background: rgba(7, 7, 13, 0.8);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 0;
color: #eafff3;
padding: 0.72rem 0.8rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.enter-field input:focus {
border-color: #7cffb2;
box-shadow:
0 0 0 1px rgba(124, 255, 178, 0.35),
0 0 18px rgba(124, 255, 178, 0.18);
}
.enter-error {
margin: 0 0 1.15rem;
padding: 0.6rem 0.75rem;
font-size: 0.78rem;
line-height: 1.5;
color: #ff3b5c;
border: 1px solid rgba(255, 59, 92, 0.35);
background: rgba(255, 59, 92, 0.07);
text-shadow: 0 0 8px rgba(255, 59, 92, 0.35);
animation: error-flicker 2.6s infinite;
}
@keyframes error-flicker {
0%, 100% { opacity: 1; }
88% { opacity: 1; }
89% { opacity: 0.65; }
90% { opacity: 1; }
}
.enter-submit {
width: 100%;
font-family: 'Cinzel', serif;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #07120c;
background: linear-gradient(180deg, #8dffc0, #4fd392);
border: 1px solid #7cffb2;
border-radius: 0;
padding: 0.85rem 1rem;
cursor: pointer;
box-shadow:
0 0 22px 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.2s ease;
}
.enter-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow:
0 0 40px rgba(124, 255, 178, 0.55),
inset 0 0 12px rgba(7, 7, 13, 0.35);
}
.enter-submit:focus-visible {
outline: 2px solid #b26bff;
outline-offset: 3px;
}
.enter-submit:disabled {
opacity: 0.55;
cursor: wait;
}
.enter-smallprint {
margin: 1.6rem 0 0;
text-align: center;
font-size: 0.66rem;
letter-spacing: 0.06em;
color: rgba(216, 230, 221, 0.38);
}
@media (prefers-reduced-motion: reduce) {
.enter-fog::before,
.enter-fog::after,
.enter-title,
.enter-error {
animation: none;
}
}

View File

@@ -0,0 +1,129 @@
// Enter page: the threshold. Gothic auth panel floating in CSS fog —
// toggles between Reconnect (login) and First contact (register), and
// steps the medium through to the séance on success.
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import './EnterPage.css'
type EnterMode = 'login' | 'register'
export function EnterPage() {
const { t } = useTranslation()
const { user, login, register } = useAuth()
const navigate = useNavigate()
const [mode, setMode] = useState<EnterMode>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// Already seated at the table — no need to knock again.
if (user) {
return <Navigate to="/seance" replace />
}
const switchMode = (next: EnterMode) => {
setMode(next)
setError(null)
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (busy) return
setError(null)
setBusy(true)
try {
if (mode === 'register') {
await register(username, password)
} else {
await login(username, password)
}
navigate('/seance')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setBusy(false)
}
}
return (
<div className="enter-page">
<div className="enter-fog" aria-hidden="true" />
<div className="enter-scanlines" aria-hidden="true" />
<main className="enter-panel">
<h1 className="enter-title">{t('nav.home')}</h1>
<p className="enter-sub">
{mode === 'login' ? t('enter.reconnectSub') : t('enter.firstContactSub')}
</p>
<div className="enter-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={mode === 'login'}
className={`enter-tab${mode === 'login' ? ' active' : ''}`}
onClick={() => switchMode('login')}
>
{t('enter.reconnect')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'register'}
className={`enter-tab${mode === 'register' ? ' active' : ''}`}
onClick={() => switchMode('register')}
>
{t('enter.firstContact')}
</button>
</div>
<form className="enter-form" onSubmit={handleSubmit}>
<label className="enter-field">
<span>{t('enter.username')}</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
autoComplete="username"
autoFocus
/>
</label>
<label className="enter-field">
<span>{t('enter.password')}</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
</label>
{error && (
<p className="enter-error" role="alert">
{t('enter.failed', { message: error })}
</p>
)}
<button type="submit" className="enter-submit" disabled={busy}>
{mode === 'login' ? t('enter.submitLogin') : t('enter.submitRegister')}
</button>
</form>
<p className="enter-smallprint">
{t('enter.smallPrint', {
defaultValue: 'your medium handle is only ever stored on this server',
})}
</p>
</main>
</div>
)
}

View File

@@ -0,0 +1,466 @@
/* LandingPage: gothic x hacker front door. Scoped under .landing to avoid
colliding with other pages' styles. */
.landing {
position: relative;
width: 100%;
min-height: 100vh;
background: #07070d;
color: #d8e6dd;
font-family: 'IBM Plex Mono', monospace;
overflow-x: hidden;
text-align: left;
}
/* scanline + vignette film over everything */
.landing-scanlines {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.55) 100%),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 3px
);
}
/* ---------- hero ---------- */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.smoke-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero-content {
position: relative;
z-index: 2;
text-align: center;
padding: 2rem 1.5rem;
max-width: 60rem;
}
.hero-kicker {
font-size: 0.78rem;
letter-spacing: 0.34em;
text-transform: lowercase;
color: rgba(216, 230, 221, 0.55);
margin: 0 0 1.25rem;
}
.hero-kicker::before,
.hero-kicker::after {
content: '//';
color: rgba(124, 255, 178, 0.4);
margin: 0 0.75rem;
}
.glitch-title {
position: relative;
margin: 0;
font-family: 'Cinzel', serif;
font-weight: 700;
font-size: clamp(2.6rem, 11vw, 8.5rem);
letter-spacing: 0.08em;
line-height: 1.05;
color: #eafff3;
text-shadow:
0 0 18px rgba(124, 255, 178, 0.45),
0 0 60px rgba(124, 255, 178, 0.18),
0 0 120px rgba(178, 107, 255, 0.12);
animation: title-flicker 5.2s infinite;
}
.glitch-title::before,
.glitch-title::after {
content: attr(data-text);
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.glitch-title::before {
color: #7cffb2;
animation: glitch-slice-a 2.9s infinite steps(1, end);
}
.glitch-title::after {
color: #b26bff;
animation: glitch-slice-b 3.4s infinite steps(1, end);
}
/* short jump-cut bursts, long eerie calm in between */
@keyframes glitch-slice-a {
0%, 84%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
85% { opacity: 0.85; transform: translate(-7px, -3px); clip-path: inset(8% 0 64% 0); }
88% { opacity: 0.85; transform: translate(5px, 2px); clip-path: inset(56% 0 20% 0); }
91% { opacity: 0.85; transform: translate(-4px, 3px); clip-path: inset(32% 0 44% 0); }
93% { opacity: 0; }
}
@keyframes glitch-slice-b {
0%, 78%, 100% { opacity: 0; transform: translate(0, 0); clip-path: inset(50% 0 50% 0); }
79% { opacity: 0.8; transform: translate(6px, 2px); clip-path: inset(62% 0 12% 0); }
82% { opacity: 0.8; transform: translate(-5px, -2px); clip-path: inset(14% 0 70% 0); }
85% { opacity: 0.8; transform: translate(3px, -3px); clip-path: inset(40% 0 36% 0); }
87% { opacity: 0; }
}
@keyframes title-flicker {
0%, 100% { opacity: 1; }
91% { opacity: 1; }
92% { opacity: 0.72; }
93% { opacity: 1; }
96% { opacity: 0.86; }
97% { opacity: 1; }
}
.hero-tagline-slot {
min-height: 1.6em;
margin: 1.5rem 0 2.5rem;
}
.hero-tagline {
display: inline-block;
font-size: clamp(0.9rem, 2vw, 1.15rem);
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.35);
animation: tagline-in 1.1s ease both;
}
@keyframes tagline-in {
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.hero-ctas {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.1rem;
}
.cta-primary {
font-family: 'Cinzel', serif;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.22em;
color: #07120c;
background: linear-gradient(180deg, #8dffc0, #4fd392);
border: 1px solid #7cffb2;
padding: 0.95rem 2.4rem;
cursor: pointer;
box-shadow:
0 0 24px rgba(124, 255, 178, 0.35),
inset 0 0 12px rgba(7, 7, 13, 0.35);
transition: box-shadow 0.25s ease, transform 0.25s ease;
}
.cta-primary:hover {
transform: translateY(-2px);
box-shadow:
0 0 44px rgba(124, 255, 178, 0.6),
inset 0 0 12px rgba(7, 7, 13, 0.35);
}
.cta-primary:focus-visible {
outline: 2px solid #b26bff;
outline-offset: 3px;
}
.cta-secondary {
color: #b26bff;
font-size: 0.85rem;
letter-spacing: 0.12em;
text-decoration: none;
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
padding-bottom: 2px;
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
}
.cta-secondary:hover {
color: #d3a8ff;
border-color: #d3a8ff;
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
}
/* ---------- shared section chrome ---------- */
.landing-section {
position: relative;
z-index: 2;
max-width: 1100px;
margin: 0 auto;
padding: 5rem 1.5rem;
}
.section-title {
display: flex;
align-items: center;
justify-content: center;
gap: 0.7rem;
font-family: 'Cinzel', serif;
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 700;
letter-spacing: 0.28em;
color: #eafff3;
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
margin: 0 0 2.5rem;
text-align: center;
}
.section-title::before,
.section-title::after {
content: '';
flex: 0 0 3rem;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
}
.section-title::after {
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
}
/* ---------- live veil activity ---------- */
.live-dot {
flex: none;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #7cffb2;
box-shadow: 0 0 10px rgba(124, 255, 178, 0.9);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.8); }
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: rgba(124, 255, 178, 0.18);
border: 1px solid rgba(124, 255, 178, 0.18);
box-shadow: 0 0 40px rgba(124, 255, 178, 0.06);
}
.stat-cell {
background: rgba(16, 16, 26, 0.85);
padding: 1.9rem 1rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-value {
font-size: clamp(1.6rem, 4vw, 2.6rem);
color: #7cffb2;
text-shadow: 0 0 16px rgba(124, 255, 178, 0.45);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(216, 230, 221, 0.5);
}
/* ---------- how the veil answers ---------- */
.modes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.mode-card {
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 1.75rem 1.35rem;
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
}
.mode-card:hover {
border-color: rgba(124, 255, 178, 0.55);
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
transform: translateY(-4px);
}
.mode-glyph {
color: #7cffb2;
margin-bottom: 1rem;
transition: color 0.25s ease, filter 0.25s ease;
}
.mode-card:hover .mode-glyph {
color: #b26bff;
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
}
.mode-glyph-svg {
width: 44px;
height: 44px;
display: block;
}
.mode-name {
font-family: 'Cinzel', serif;
font-size: 1.05rem;
letter-spacing: 0.14em;
color: #eafff3;
margin: 0 0 0.7rem;
}
.mode-desc {
font-size: 0.82rem;
line-height: 1.65;
color: rgba(216, 230, 221, 0.62);
margin: 0;
}
/* ---------- featured spirits ---------- */
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.spirit-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
padding: 1.9rem 1.4rem;
text-align: center;
text-decoration: none;
color: inherit;
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
}
.spirit-card:hover {
border-color: rgba(178, 107, 255, 0.55);
box-shadow: 0 0 32px rgba(178, 107, 255, 0.14);
transform: translateY(-4px);
}
.spirit-name {
font-family: 'Cinzel', serif;
font-size: 1.15rem;
letter-spacing: 0.1em;
color: #eafff3;
margin-top: 0.6rem;
}
.spirit-epithet {
font-size: 0.8rem;
font-style: italic;
color: rgba(216, 230, 221, 0.55);
}
.rarity-badge {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border: 1px solid currentColor;
margin-top: 0.25rem;
}
.spirit-card .rarity-common { color: rgba(124, 255, 178, 0.7); }
.spirit-card .rarity-uncommon { color: #b26bff; }
.spirit-card .rarity-rare { color: #b26bff; text-shadow: 0 0 10px rgba(178, 107, 255, 0.7); }
.spirit-card .rarity-mythic { color: #ff3b5c; text-shadow: 0 0 10px rgba(255, 59, 92, 0.7); }
.spirit-quote {
margin-top: 0.8rem;
font-family: Georgia, serif;
font-style: italic;
font-size: 0.82rem;
line-height: 1.6;
color: rgba(216, 230, 221, 0.6);
quotes: '\201C' '\201D';
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.featured-empty {
text-align: center;
color: rgba(216, 230, 221, 0.5);
font-style: italic;
}
/* ---------- footer ---------- */
.landing-footer {
position: relative;
z-index: 2;
border-top: 1px solid rgba(124, 255, 178, 0.18);
padding: 2.5rem 1.5rem 3rem;
text-align: center;
font-size: 0.78rem;
color: rgba(216, 230, 221, 0.5);
}
.landing-footer p {
margin: 0.25rem 0;
}
.footer-colophon {
color: rgba(124, 255, 178, 0.45);
letter-spacing: 0.14em;
font-size: 0.7rem;
}
/* ---------- responsive + reduced motion ---------- */
@media (max-width: 720px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.section-title::before,
.section-title::after {
flex-basis: 1.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.glitch-title,
.glitch-title::before,
.glitch-title::after,
.hero-tagline,
.live-dot {
animation: none;
}
.glitch-title::before,
.glitch-title::after {
opacity: 0;
}
}

View File

@@ -0,0 +1,347 @@
// Landing page: the hype front door. Full-viewport glitch hero over a
// self-contained drifting-smoke canvas, live veil-activity counters,
// the four contact modes, featured spirits from the codex, and the CTA.
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { GhostGlyph } from '../components/GhostGlyph'
import type { CodexEntity, CodexListResponse, Mode, Stats } from '../lib/types'
import './LandingPage.css'
const STATS_REFRESH_MS = 15_000
const TAGLINE_MS = 4_000
const MODE_ORDER: Mode[] = ['radio', 'evp', 'wire', 'ouija']
const STAT_KEYS = ['entities', 'sessions', 'utterances', 'anomalies'] as const
/** Hacker-occult stroke glyphs for the four contact modes (no emoji, no fonts). */
function ModeGlyph({ mode }: { mode: Mode }) {
const common = {
viewBox: '0 0 48 48',
className: 'mode-glyph-svg',
'aria-hidden': true as const,
}
switch (mode) {
case 'radio':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="24" cy="18" r="3" />
<path d="M24 21 L15 42 M24 21 L33 42 M18.6 33 h10.8" />
<path d="M16.5 12.5 a10.5 10.5 0 0 1 15 0" />
<path d="M11.5 7.5 a18 18 0 0 1 25 0" />
</g>
</svg>
)
case 'evp':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M7 24 h4 M37 24 h4" />
<path d="M14 20 v8 M19 14 v20 M24 18 v12 M29 10 v28 M34 17 v14" />
</g>
</svg>
)
case 'wire':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M14.6 31.6 L21.7 16.6 M26.3 16.6 L33.4 29.6 M15.2 34 h17.6" />
<circle cx="12" cy="34" r="3.2" />
<circle cx="24" cy="13" r="3.2" />
<circle cx="36" cy="32" r="3.2" />
</g>
</svg>
)
case 'ouija':
return (
<svg {...common}>
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M24 6 C29 14 39 20 39 30 A15 15 0 1 1 9 30 C9 20 19 14 24 6 Z" />
<circle cx="24" cy="29" r="5.5" />
</g>
</svg>
)
}
}
type Wisp = {
x: number
y: number
r: number
hue: number
alpha: number
vy: number
swayAmp: number
swaySpeed: number
phase: number
}
/**
* Lightweight 2D-canvas fog: slow phosphor-green/violet wisps drifting
* upward on an additive blend. Self-contained; tears itself down on unmount.
*/
function SmokeCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let raf = 0
let wisps: Wisp[] = []
const HUES = [150, 275] // phosphor green / violet
const spawn = (anywhere: boolean): Wisp => {
const r = 50 + Math.random() * 120
return {
x: Math.random() * canvas.width,
y: anywhere ? Math.random() * canvas.height : canvas.height + r,
r,
hue: HUES[Math.floor(Math.random() * HUES.length)],
alpha: 0.025 + Math.random() * 0.05,
vy: 0.06 + Math.random() * 0.22,
swayAmp: 0.1 + Math.random() * 0.35,
swaySpeed: 0.0004 + Math.random() * 0.0009,
phase: Math.random() * Math.PI * 2,
}
}
const seed = () => {
const count = Math.max(14, Math.min(34, Math.floor((canvas.width * canvas.height) / 42000)))
wisps = Array.from({ length: count }, () => spawn(true))
}
const resize = () => {
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
seed()
}
const frame = (now: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < wisps.length; i++) {
const w = wisps[i]
w.y -= w.vy
const x = w.x + Math.sin(now * w.swaySpeed + w.phase) * w.swayAmp * 40
if (w.y + w.r < 0) {
wisps[i] = spawn(false)
continue
}
const g = ctx.createRadialGradient(x, w.y, 0, x, w.y, w.r)
g.addColorStop(0, `hsla(${w.hue}, 90%, 65%, ${w.alpha})`)
g.addColorStop(1, `hsla(${w.hue}, 90%, 65%, 0)`)
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(x, w.y, w.r, 0, Math.PI * 2)
ctx.fill()
}
raf = requestAnimationFrame(frame)
}
resize()
window.addEventListener('resize', resize)
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
}
}, [])
return <canvas ref={canvasRef} className="smoke-canvas" aria-hidden="true" />
}
/** Count-up number that eases toward `value`; renders '—' when unknown. */
function AnimatedNumber({ value }: { value: number | null }) {
const [display, setDisplay] = useState<number | null>(value)
const shownRef = useRef<number | null>(value)
useEffect(() => {
if (value === null) {
shownRef.current = null
setDisplay(null)
return
}
const from = shownRef.current ?? 0
if (from === value) {
setDisplay(value)
return
}
const start = performance.now()
const duration = 900
let raf = requestAnimationFrame(function tick(now: number) {
const p = Math.min(1, (now - start) / duration)
const eased = 1 - Math.pow(1 - p, 3)
const v = Math.round(from + (value - from) * eased)
shownRef.current = v
setDisplay(v)
if (p < 1) raf = requestAnimationFrame(tick)
})
return () => cancelAnimationFrame(raf)
}, [value])
return <span className="stat-value">{display === null ? '—' : display.toLocaleString()}</span>
}
export function LandingPage() {
const { t } = useTranslation()
const { user } = useAuth()
const navigate = useNavigate()
const [stats, setStats] = useState<Stats | null>(null)
const [featured, setFeatured] = useState<CodexEntity[] | null>(null)
const [taglineIdx, setTaglineIdx] = useState(0)
const taglineObjects: unknown = t('landing.taglines', { returnObjects: true })
const taglines = Array.isArray(taglineObjects)
? taglineObjects.filter((s): s is string => typeof s === 'string')
: []
const title = t('common.appName')
// Rotate the hero tagline; the key remount re-triggers the CSS fade.
useEffect(() => {
if (taglines.length < 2) return
const id = window.setInterval(() => setTaglineIdx((i) => (i + 1) % taglines.length), TAGLINE_MS)
return () => window.clearInterval(id)
}, [taglines.length])
// Live veil activity: poll the public stats endpoint, fail silently.
useEffect(() => {
let cancelled = false
const load = () => {
fetch('/api/stats', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`stats ${r.status}`)
return r.json() as Promise<Stats>
})
.then((s) => {
if (!cancelled) setStats(s)
})
.catch(() => {
/* the veil keeps its secrets — keep old numbers or '—' */
})
}
load()
const id = window.setInterval(load, STATS_REFRESH_MS)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
// Featured spirits: most-contacted three from the codex. Hidden on failure.
useEffect(() => {
let cancelled = false
fetch('/api/codex?sort=contacted&limit=3', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`codex ${r.status}`)
return r.json() as Promise<CodexListResponse>
})
.then((d) => {
if (!cancelled) setFeatured(d.entities.slice(0, 3))
})
.catch(() => {
/* registry unreachable — section simply stays shut */
})
return () => {
cancelled = true
}
}, [])
return (
<div className="landing">
<div className="landing-scanlines" aria-hidden="true" />
<section className="hero">
<SmokeCanvas />
<div className="hero-content">
<p className="hero-kicker">{t('landing.heroKicker')}</p>
<h1 className="glitch-title" data-text={title}>
{title}
</h1>
<p className="hero-tagline-slot">
{taglines.length > 0 && (
<span key={taglineIdx} className="hero-tagline">
{taglines[taglineIdx % taglines.length]}
</span>
)}
</p>
<div className="hero-ctas">
<button
type="button"
className="cta-primary"
onClick={() => navigate(user ? '/seance' : '/enter')}
>
{t('landing.cta')}
</button>
<Link className="cta-secondary" to="/codex">
{t('landing.ctaCodex')}
</Link>
</div>
</div>
</section>
<section className="landing-section veil-stats" aria-label={t('landing.tickerPrefix')}>
<h2 className="section-title">
<span className="live-dot" aria-hidden="true" />
{t('landing.tickerPrefix')}
</h2>
<div className="stats-grid">
{STAT_KEYS.map((key) => (
<div className="stat-cell" key={key}>
<AnimatedNumber value={stats ? stats[key] : null} />
<span className="stat-label">{t(`landing.stats.${key}`)}</span>
</div>
))}
</div>
</section>
<section className="landing-section modes">
<h2 className="section-title">{t('landing.modesTitle')}</h2>
<div className="modes-grid">
{MODE_ORDER.map((mode) => (
<article className="mode-card" key={mode}>
<div className="mode-glyph">
<ModeGlyph mode={mode} />
</div>
<h3 className="mode-name">{t(`landing.modes.${mode}.name`)}</h3>
<p className="mode-desc">{t(`landing.modes.${mode}.desc`)}</p>
</article>
))}
</div>
</section>
{featured !== null && (
<section className="landing-section featured">
<h2 className="section-title">{t('landing.featured')}</h2>
{featured.length === 0 ? (
<p className="featured-empty">{t('landing.featuredEmpty')}</p>
) : (
<div className="featured-grid">
{featured.map((e) => (
<Link to={`/codex/${e.id}`} className="spirit-card" key={e.id}>
<GhostGlyph hue={e.visual.hue} form={e.visual.form} size={56} />
<span className="spirit-name">{e.name}</span>
<span className="spirit-epithet">{e.epithet}</span>
<span className={`rarity-badge rarity-${e.rarity}`}>
{t(`codex.rarity.${e.rarity}`)}
</span>
{e.quotes.length > 0 && <q className="spirit-quote">{e.quotes[0]}</q>}
</Link>
))}
</div>
)}
</section>
)}
<footer className="landing-footer">
<p>{t('landing.footer')}</p>
<p className="footer-colophon">{t('landing.footerColophon')}</p>
</footer>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,819 @@
// The main séance screen: ghost backdrop, planchette board, mode panels
// (wire / evp / radio / ouija), transmission log and direct-contact row.
//
// Wiring notes:
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
// itself; this page only enqueues utterance text and polls snapshot()
// (non-mutating) for the ouija panel readout.
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
// in the panel's unmount cleanup — switching modes or leaving the page
// always releases the hardware.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { SeanceProvider, useSeance } from '../state/seance'
import type { Toast } from '../state/seance'
import { GhostCanvas } from '../three/GhostCanvas'
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
import { PlanchetteBoard } from '../components/PlanchetteBoard'
import { Transcript } from '../components/Transcript'
import { PlanchetteMachine } from '../lib/planchette'
import type { PlanchetteSnapshot } from '../lib/planchette'
import { EvpListener } from '../lib/evp'
import {
isSupported,
RtlSdr,
SpectrumAnomalyDetector,
SWEEP_END_MHZ,
SWEEP_START_MHZ,
} from '../lib/sdr'
import { persistLanguage, storedLanguage } from '../i18n'
import type { Language, Mode } from '../lib/types'
import './SeancePage.css'
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
const TOAST_TTL_MS = 6000
/** SDR sample rate must match RtlSdr.open()'s default. */
const SDR_SAMPLE_RATE_HZ = 2_048_000
export function SeancePage() {
const { user, checking } = useAuth()
const { t } = useTranslation()
if (checking) {
return (
<div className="seance-loading" role="status">
<div className="loading-sigil" aria-hidden>
⟁
</div>
<p className="loading-text">{t('common.loading')}</p>
</div>
)
}
if (!user) return <Navigate to="/enter" replace />
return (
<SeanceProvider>
<SeanceSession username={user.username} />
</SeanceProvider>
)
}
export default SeancePage
// ---- the séance itself (rendered inside SeanceProvider) ----
function SeanceSession({ username }: { username: string }) {
const {
state,
audioPlayer,
setMode,
setPassive,
setLanguage,
summon,
ask,
playUtterance,
dismissToast,
} = useSeance()
const { logout } = useAuth()
const { t, i18n } = useTranslation()
const [departed, setDeparted] = useState(false)
const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true)
// The planchette machine lives for the whole session; the board ticks it.
const machineRef = useRef(new PlanchetteMachine())
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
// Tell the backend which tongue we whisper in (queued while connecting).
useEffect(() => {
setLanguage(storedLanguage())
}, [setLanguage])
// Feed every fresh utterance (greetings, fragments, replies) to the board.
const lastEnqueuedRef = useRef<string | null>(null)
useEffect(() => {
const uts = state.utterances
if (uts.length === 0) return
const lastSeen = lastEnqueuedRef.current
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
const fresh = uts.slice(idx + 1)
for (const u of fresh) machineRef.current.enqueue(u.text)
lastEnqueuedRef.current = uts[uts.length - 1].id
}, [state.utterances])
// Poll the machine's snapshot for the ouija readout (board owns ticking).
useEffect(() => {
let lastSig = ''
const id = window.setInterval(() => {
const s = machineRef.current.snapshot()
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
if (sig !== lastSig) {
lastSig = sig
setBoardSnap(s)
}
}, 150)
return () => window.clearInterval(id)
}, [])
// Open the dossier whenever a new presence steps through.
const entityId = state.entity?.id ?? null
useEffect(() => {
if (entityId) setDossierOpen(true)
}, [entityId])
const ghostVisual = useMemo<GhostVisualState | null>(
() =>
state.entity
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
: null,
[state.entity],
)
const ghostMood: GhostMood = state.speakingId
? 'speaking'
: state.status === 'gathering' || state.status === 'summoning'
? 'gathering'
: state.status === 'attuning'
? 'attuning'
: 'idle'
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
const switchLanguage = (lang: Language) => {
setLanguage(lang)
void i18n.changeLanguage(lang)
persistLanguage(lang)
}
const handleLogout = () => {
void logout()
.catch(() => undefined)
.finally(() => setDeparted(true))
}
const streaming = state.replyStreaming.active
const submitQuestion = (e: FormEvent) => {
e.preventDefault()
if (streaming) return
ask(question)
setQuestion('')
}
if (departed) return <Navigate to="/" replace />
const entityHue = state.entity?.visual.hue ?? 150
const entity = state.entity
return (
<div className="seance-page">
<GhostCanvas
className="seance-ghost"
visual={ghostVisual}
mood={ghostMood}
getAmplitude={getAmplitude}
/>
{/* ---- top bar ---- */}
<header className="seance-topbar">
<div className="seance-brand">
<span className="brand-sigil" aria-hidden>
⟁
</span>
{t('common.appName')}
</div>
<nav className="seance-modes" aria-label="modes">
{MODES.map((m) => (
<button
key={m}
type="button"
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{t(`seance.modes.${m}`)}
</button>
))}
</nav>
<div className="seance-top-right">
<label className="passive-toggle" title={t('seance.passive')}>
<input
type="checkbox"
checked={state.passive}
onChange={(e) => setPassive(e.target.checked)}
/>
<span className="passive-track" aria-hidden />
<span className="passive-label">{t('seance.passive')}</span>
</label>
<div className="lang-toggle" role="group" aria-label="language">
<button
type="button"
className={state.language === 'en' ? 'active' : ''}
onClick={() => switchLanguage('en')}
>
EN
</button>
<button
type="button"
className={state.language === 'es' ? 'active' : ''}
onClick={() => switchLanguage('es')}
>
ES
</button>
</div>
<Link to="/codex" className="seance-codex-link">
{t('nav.codex')}
</Link>
<span className="seance-user" title={username}>
{username}
</span>
<button type="button" className="seance-logout" onClick={handleLogout}>
{t('nav.logout')}
</button>
<span
className={`conn-dot conn-${state.connection}`}
title={t(`seance.connection.${state.connection}`)}
aria-label={t(`seance.connection.${state.connection}`)}
/>
</div>
</header>
{/* ---- unstable-veil banner ---- */}
{state.connection !== 'open' && (
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
<span className="conn-glyph" aria-hidden>
⟁
</span>
{t(`seance.connection.${state.connection}`)}
</div>
)}
{/* ---- main: board + side column ---- */}
<main className="seance-main">
<section className="seance-board-wrap">
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
{state.status && (
<div className="seance-status-overlay" aria-live="polite">
<span className="status-sigil" aria-hidden />
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
</div>
)}
</section>
<aside className="seance-side">
{entity ? (
<section className={`spirit-dossier ${dossierOpen ? 'open' : 'closed'}`}>
<button
type="button"
className="dossier-header"
onClick={() => setDossierOpen((o) => !o)}
aria-expanded={dossierOpen}
>
<span className="dossier-title">{t('seance.entity.title')}</span>
<span className="dossier-chevron" aria-hidden>
{dossierOpen ? '▾' : '◂'}
</span>
</button>
{dossierOpen && (
<div className="dossier-body">
<h3 className="dossier-name" style={{ color: `hsl(${entity.visual.hue} 85% 78%)` }}>
{entity.name}
</h3>
<p className="dossier-epithet">{entity.epithet}</p>
<div className="dossier-badges">
<span className={`rarity-badge rarity-${entity.rarity}`}>
{t(`seance.entity.rarity.${entity.rarity}`)}
</span>
{state.entityIsNew ? (
<span className="dossier-badge badge-new">{t('seance.entity.isNew')}</span>
) : (
<span className="dossier-badge badge-known">
{t('seance.entity.known', { count: entity.contact_count })}
</span>
)}
</div>
<div className="dossier-section">
<h4>{t('seance.entity.persona')}</h4>
<p className="dossier-persona">{entity.persona}</p>
</div>
<div className="dossier-section">
<h4>{t('seance.entity.voice')}</h4>
<dl className="voice-params">
<div>
<dt>id</dt>
<dd>{entity.voice.voice_id}</dd>
</div>
<div>
<dt>pitch</dt>
<dd>{entity.voice.pitch.toFixed(2)}</dd>
</div>
<div>
<dt>rate</dt>
<dd>{entity.voice.rate.toFixed(2)}</dd>
</div>
<div>
<dt>noise</dt>
<dd>{entity.voice.noise.toFixed(2)}</dd>
</div>
<div>
<dt>echo</dt>
<dd>{entity.voice.echo.toFixed(2)}</dd>
</div>
</dl>
</div>
{entity.quotes.length > 0 && (
<div className="dossier-section">
<h4>{t('seance.entity.quotes')}</h4>
<ul className="dossier-quotes">
{entity.quotes.slice(0, 4).map((q, i) => (
<li key={i}>“{q}”</li>
))}
</ul>
</div>
)}
</div>
)}
</section>
) : (
<section className="spirit-dossier empty">
<div className="dossier-body">
<p className="dossier-none">{t('seance.entity.none')}</p>
<p className="dossier-nonehint dim">{t('seance.entity.noneHint')}</p>
</div>
</section>
)}
<section className="mode-panel">
{state.mode === 'wire' && <WirePanel />}
{state.mode === 'evp' && <EvpPanel />}
{state.mode === 'radio' && <RadioPanel />}
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
</section>
</aside>
</main>
{/* ---- bottom: transmission log + direct contact ---- */}
<footer className="seance-bottom">
<div className="seance-transcript-wrap">
<div className="seance-transcript-title">
<span>{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
{t('seance.anomaly.count', { count: state.anomalyCount })}
</span>
</div>
<Transcript
entries={state.transcript}
streaming={state.replyStreaming}
speakingId={state.speakingId}
onReplay={playUtterance}
/>
</div>
<form className="seance-contact" onSubmit={submitQuestion}>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder={t('seance.askPlaceholder')}
disabled={streaming}
maxLength={280}
aria-label={t('seance.ask')}
/>
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
{t('seance.ask')}
</button>
<button
type="button"
className="summon-btn"
onClick={summon}
disabled={state.status === 'summoning'}
>
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
</button>
</form>
</footer>
{/* ---- toasts ---- */}
<div className="seance-toasts" aria-live="polite">
{state.toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</div>
{/* ---- CRT overlays (above everything, never interactive) ---- */}
<div className="seance-scanlines" aria-hidden />
<div className="seance-vignette" aria-hidden />
</div>
)
}
// ---- toasts ----
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
const { t } = useTranslation()
useEffect(() => {
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
return () => window.clearTimeout(id)
}, [toast.id, onDismiss])
return (
<div className="seance-toast" role="alert">
<span className="toast-glyph" aria-hidden>
⚠
</span>
<span className="toast-msg">
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
</span>
<button
type="button"
className="toast-dismiss"
onClick={() => onDismiss(toast.id)}
aria-label={t('common.dismiss')}
>
×
</button>
</div>
)
}
// ---- wire panel: raw line telemetry ----
function WirePanel() {
const { state } = useSeance()
const { t } = useTranslation()
const tel = state.telemetry
return (
<div className="wire-panel">
<h4 className="panel-title">{t('seance.telemetry.title')}</h4>
{tel ? (
<div className="telemetry-grid">
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.jitter')}</span>
<span className="tel-val flicker-soft">
{tel.jitter_bytes_per_s.toFixed(1)} {t('seance.telemetry.units.bytes')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.latencyVar')}</span>
<span className="tel-val flicker-soft">
{tel.latency_variance_ms.toFixed(2)} {t('seance.telemetry.units.ms')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.latencyMean')}</span>
<span className="tel-val flicker-soft">
{tel.latency_mean_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
</span>
</div>
<div className="tel-row">
<span className="tel-key">{t('seance.telemetry.dns')}</span>
<span className="tel-val flicker-soft">
{tel.dns_ms.toFixed(1)} {t('seance.telemetry.units.ms')}
</span>
</div>
</div>
) : (
<p className="dim flicker">{t('common.loading')}</p>
)}
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
{!state.passive && (
<p className="wire-hint dim">
{t('seance.wire.hint', {
defaultValue:
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
})}
</p>
)}
</div>
)
}
// ---- EVP panel: microphone voice-band listening ----
function EvpPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const [listening, setListening] = useState(false)
const [pending, setPending] = useState(false)
const [denied, setDenied] = useState(false)
const [bandDb, setBandDb] = useState<number | null>(null)
const listenerRef = useRef<EvpListener | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastMeterRef = useRef(0)
// Release the microphone when the panel unmounts (mode switch / leaving).
useEffect(
() => () => {
const l = listenerRef.current
listenerRef.current = null
if (l) void l.stop()
},
[],
)
const drawScope = (db: Float32Array) => {
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 n = db.length
const step = Math.max(1, Math.floor(n / 160))
const bw = w / (n / step)
for (let i = 0, x = 0; i < n; i += step, x++) {
const v = db[i] // dB, roughly -160..0
const norm = Math.max(0, Math.min(1, (v + 110) / 80))
const bh = Math.max(1, norm * h)
ctx.fillStyle = `hsla(150 90% 65% / ${0.25 + norm * 0.6})`
ctx.fillRect(x * bw, h - bh, Math.max(1, bw - 0.5), bh)
}
}
const start = async () => {
if (pending || listening) return
setDenied(false)
setPending(true)
const listener = listenerRef.current ?? new EvpListener()
listenerRef.current = listener
try {
await listener.start({
onAnomaly: (a) => sendAnomaly('evp', a.frequency, a.magnitude),
onFrame: (db) => {
drawScope(db)
const now = performance.now()
if (now - lastMeterRef.current > 200) {
lastMeterRef.current = now
// Voice band ≈ 300–3400 Hz (assuming the usual 48 kHz / 2048 FFT).
const n = db.length
const lo = Math.floor((300 / 24000) * n)
const hi = Math.min(n - 1, Math.ceil((3400 / 24000) * n))
let peak = -Infinity
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
setBandDb(Number.isFinite(peak) ? peak : null)
}
},
})
if (listenerRef.current === listener) setListening(true)
} catch {
if (listenerRef.current === listener) listenerRef.current = null
setDenied(true)
setBandDb(null)
} finally {
setPending(false)
}
}
const stop = async () => {
const l = listenerRef.current
listenerRef.current = null
if (l) await l.stop()
setListening(false)
setBandDb(null)
}
return (
<div className="evp-panel">
<canvas ref={canvasRef} width={520} height={90} className="evp-scope" aria-hidden />
<div className="evp-readout">
<span className="dim">{t('seance.evp.band')}</span>
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
</div>
{listening && (
<p className="evp-listening flicker">
<span className="rec-dot" aria-hidden />
{t('seance.evp.listening')}
</p>
)}
{denied && (
<div className="panel-notice error">
<h4>{t('seance.evp.deniedTitle')}</h4>
<p>{t('seance.evp.denied')}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (listening ? stop() : start())}
disabled={pending}
>
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
</button>
</div>
)
}
// ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() {
const { sendAnomaly } = useSeance()
const { t } = useTranslation()
const supported = useMemo(() => isSupported(), [])
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
const [error, setError] = useState<string | null>(null)
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
const sdrRef = useRef<RtlSdr | null>(null)
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const lastTuneUiRef = useRef(0)
// Power down the dongle on unmount (mode switch / leaving the page).
useEffect(
() => () => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
},
[],
)
const drawWaterfallRow = (db: Float64Array) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Scroll the existing waterfall down one row, paint the new one on top.
ctx.drawImage(canvas, 0, 1)
const n = db.length
const bw = w / n
for (let i = 0; i < n; i++) {
const norm = Math.max(0, Math.min(1, (db[i] + 90) / 70))
ctx.fillStyle = `hsl(${272 - norm * 122} ${45 + norm * 50}% ${5 + norm * 52}%)`
ctx.fillRect(i * bw, 0, Math.ceil(bw), 1)
}
}
const stop = useCallback(() => {
const sdr = sdrRef.current
sdrRef.current = null
detectorRef.current = null
if (sdr) {
sdr.stopSweep()
void sdr.close()
}
setPhase('idle')
setTunedMhz(null)
}, [])
const start = async () => {
if (!supported || sdrRef.current || phase === 'busy') return
setError(null)
setPhase('busy')
const sdr = new RtlSdr()
try {
await sdr.requestDevice()
await sdr.open()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const name = err instanceof DOMException ? err.name : ''
setError(
name === 'NotFoundError' || /no device selected/i.test(msg)
? t('seance.radio.noDevice')
: /claim/i.test(msg)
? t('seance.radio.claimFailed')
: msg,
)
setPhase('idle')
void sdr.close()
return
}
sdrRef.current = sdr
detectorRef.current = new SpectrumAnomalyDetector()
setPhase('sweeping')
void sdr
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
onSpectrum: (centerHz, db) => {
if (sdrRef.current !== sdr) return
drawWaterfallRow(db)
const now = performance.now()
if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now
setTunedMhz(centerHz / 1e6)
}
const det = detectorRef.current
if (det) {
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
if (a) sendAnomaly('radio', a.frequency, a.magnitude)
}
},
onError: (err) => {
if (sdrRef.current === sdr) setError(err.message)
},
})
.then(() => {
if (sdrRef.current === sdr) setPhase('idle')
})
.catch((err: unknown) => {
if (sdrRef.current === sdr) {
setError(err instanceof Error ? err.message : String(err))
setPhase('idle')
}
})
}
if (!supported) {
return (
<div className="panel-notice">
<h4>{t('seance.radio.unsupportedTitle')}</h4>
<p>{t('seance.radio.unsupported')}</p>
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
<button type="button" className="panel-action" disabled>
{t('seance.radio.start')}
</button>
</div>
)
}
return (
<div className="radio-panel">
<canvas ref={canvasRef} width={256} height={110} className="radio-waterfall" aria-hidden />
<div className="radio-readout">
{tunedMhz !== null ? (
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
) : (
<span className="dim">—</span>
)}
{phase === 'sweeping' && (
<span className="dim flicker">
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
</span>
)}
</div>
{error && (
<div className="panel-notice error">
<p>{error}</p>
</div>
)}
<button
type="button"
className="panel-action"
onClick={() => void (phase === 'sweeping' ? stop() : start())}
disabled={phase === 'busy'}
>
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
</button>
</div>
)
}
// ---- ouija panel: what the planchette is doing ----
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
const { t } = useTranslation()
const word = snapshot?.word ?? null
return (
<div className="ouija-panel">
{word ? (
<div className="ouija-spelling">
<span className="dim ouija-spelling-label">
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
</span>
<div className="ouija-word" aria-live="polite">
{word.split('').map((ch, i) => (
<span
key={i}
className={
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
}
>
{ch}
</span>
))}
</div>
</div>
) : (
<p className="dim ouija-drift">
{t('seance.ouija.drift', {
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
})}
</p>
)}
{snapshot && snapshot.spelled.length > 0 && (
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
)}
</div>
)
}