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,87 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { TranscriptEntry } from '../state/seance'
export type TranscriptProps = {
entries: TranscriptEntry[]
streaming: { active: boolean; text: string }
speakingId: string | null
onReplay: (utteranceId: string) => void
}
function fmtFreq(source: string, f: number): string {
// EVP reports Hz (voice band); radio reports MHz (FM band); wire is ambient.
if (source === 'evp') return `${f.toFixed(0)}Hz`
if (source === 'radio') return `${f.toFixed(1)}MHz`
return f >= 1000 ? `${(f / 1000).toFixed(2)}kHz` : `${f.toFixed(1)}Hz`
}
/** Terminal-style scrolling transmission log. */
export function Transcript({ entries, streaming, speakingId, onReplay }: TranscriptProps) {
const { t } = useTranslation()
const boxRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const box = boxRef.current
if (box) box.scrollTop = box.scrollHeight
}, [entries.length, streaming.text])
return (
<div className="transcript" ref={boxRef} aria-live="polite">
{entries.length === 0 && !streaming.active && (
<div className="tx-row tx-system dim">{t('seance.transcriptEmpty')}</div>
)}
{entries.map((e) => {
switch (e.kind) {
case 'anomaly':
return (
<div key={e.id} className="tx-row tx-anomaly">
<span className="tx-glyph">⚡</span>
<span className="tx-src">{e.source}</span>{' '}
<span className="tx-freq">{fmtFreq(e.source, e.frequency)}</span>{' '}
<span className="tx-mag">+{e.magnitude.toFixed(1)}dB</span>
</div>
)
case 'question':
return (
<div key={e.id} className="tx-row tx-question">
<span className="tx-speaker">{t('seance.you')}</span>
<span className="tx-text"> {e.text}</span>
</div>
)
case 'system':
return (
<div key={e.id} className="tx-row tx-system">
{e.text}
</div>
)
case 'utterance': {
const speaking = speakingId === e.utteranceId
return (
<div key={e.id} className={`tx-row tx-utterance kind-${e.utKind} ${speaking ? 'speaking' : ''}`}>
<span className="tx-speaker">{e.speaker ?? '???'}</span>
<span className="tx-text"> “{e.text}”</span>
{e.audioUrl && (
<button
className="tx-replay"
title={t('common.replay')}
onClick={() => onReplay(e.utteranceId)}
>
{speaking ? '🔊' : '▶'}
</button>
)}
</div>
)
}
}
})}
{streaming.active && (
<div className="tx-row tx-streaming">
<span className="tx-stream-label">{t('seance.streaming')}</span>
<span className="tx-text smoke-text">{streaming.text}</span>
<span className="tx-cursor">▌</span>
</div>
)}
</div>
)
}