Three self-contained features, verified complete and cross-wired end-to-end (audited: backend 54/54 tests, frontend 110/110 tests, tsc --noEmit clean, i18n coverage script clean): - EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing, a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own fragment prompt persona and full frontend gauge UI. - Armory (shop/waitlist): pre-order capture page for the future Ultimate Quantum Box hardware line, rate-limited public endpoint, explicitly no payment collection. - Haunting layer: ambient possession effects (dread-bed audio, title glitching, idle-paced whispers/manifests), respects prefers-reduced-motion, mounted once at the app root. Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID, explicit selectConfiguration, isSecureContext gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
90 lines
3.2 KiB
TypeScript
90 lines
3.2 KiB
TypeScript
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); emf reports a
|
|
// field-strength estimate; wire is ambient.
|
|
if (source === 'evp') return `${f.toFixed(0)}Hz`
|
|
if (source === 'radio') return `${f.toFixed(1)}MHz`
|
|
if (source === 'emf') return `field ${f.toFixed(2)}`
|
|
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>
|
|
)
|
|
}
|