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
80 lines
2.6 KiB
TypeScript
80 lines
2.6 KiB
TypeScript
import { useTranslation } from 'react-i18next'
|
|
import type { SpiritEntity } from '../lib/types'
|
|
import { GhostGlyph } from './GhostGlyph'
|
|
|
|
export type EntityCardProps = {
|
|
entity: SpiritEntity | null
|
|
isNew: boolean
|
|
}
|
|
|
|
const RARITY_CLASS: Record<string, string> = {
|
|
common: 'rarity-common',
|
|
uncommon: 'rarity-uncommon',
|
|
rare: 'rarity-rare',
|
|
mythic: 'rarity-mythic',
|
|
}
|
|
|
|
/** Occult dossier of the currently-summoned spirit, wax-seal vibe. */
|
|
export function EntityCard({ entity, isNew }: EntityCardProps) {
|
|
const { t } = useTranslation()
|
|
|
|
if (!entity) {
|
|
return (
|
|
<aside className="entity-card empty">
|
|
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
|
<div className="ec-none">
|
|
<div className="ec-none-sigil">⟁</div>
|
|
<p>{t('seance.entity.none')}</p>
|
|
<p className="dim">{t('seance.entity.noneHint')}</p>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<aside className="entity-card">
|
|
<div className="ec-wax" aria-hidden="true">
|
|
{entity.name.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
<h3 className="ec-title">{t('seance.entity.title')}</h3>
|
|
<div className="ec-head">
|
|
<GhostGlyph hue={entity.visual.hue} form={entity.visual.form} size={64} />
|
|
<div>
|
|
<div className="ec-name glitch-hover">{entity.name}</div>
|
|
<div className="ec-epithet">{entity.epithet}</div>
|
|
<span className={`rarity-badge ${RARITY_CLASS[entity.rarity] ?? ''}`}>
|
|
{t(`seance.entity.rarity.${entity.rarity}`)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="ec-contact">
|
|
{isNew ? t('seance.entity.isNew') : t('seance.entity.known', { count: entity.contact_count })}
|
|
</div>
|
|
<div className="ec-section">
|
|
<h4>{t('seance.entity.persona')}</h4>
|
|
<p className="ec-persona">{entity.persona}</p>
|
|
</div>
|
|
{entity.quotes.length > 0 && (
|
|
<div className="ec-section">
|
|
<h4>{t('seance.entity.quotes')}</h4>
|
|
<ul className="ec-quotes">
|
|
{entity.quotes.slice(0, 4).map((q, i) => (
|
|
<li key={i}>“{q}”</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<div className="ec-section">
|
|
<h4>{t('seance.entity.voice')}</h4>
|
|
<div className="ec-voice">
|
|
<span>id {entity.voice.voice_id}</span>
|
|
<span>pitch {entity.voice.pitch.toFixed(2)}</span>
|
|
<span>rate {entity.voice.rate.toFixed(2)}</span>
|
|
<span>noise {entity.voice.noise.toFixed(2)}</span>
|
|
<span>echo {entity.voice.echo.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|