Files
qtalker---/frontend/src/pages/SeancePage.tsx
Indiana 6edbbbbc2a 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
2026-07-20 21:11:49 +00:00

820 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
)
}