Files
qtalker---/frontend/src/components/Transcript.tsx
Indiana 0756e677b9 Add EMF field mode, Armory shop/waitlist, and ambient haunting layer
Three self-contained features, verified complete and cross-wired
end-to-end (audited: backend 54/54 tests, frontend 110/110 tests,
tsc --noEmit clean, i18n coverage script clean):

- EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing,
  a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own
  fragment prompt persona and full frontend gauge UI.
- Armory (shop/waitlist): pre-order capture page for the future
  Ultimate Quantum Box hardware line, rate-limited public endpoint,
  explicitly no payment collection.
- Haunting layer: ambient possession effects (dread-bed audio, title
  glitching, idle-paced whispers/manifests), respects
  prefers-reduced-motion, mounted once at the app root.

Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID,
explicit selectConfiguration, isSecureContext gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
2026-07-21 02:28:14 +00:00

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