feat: presence beyond the tab — PWA manifest and the Ghost Log recap

Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 17:19:55 +00:00
parent 2e2c64d36b
commit 3b33b5d6f6
12 changed files with 656 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { DevicesPage } from './pages/DevicesPage'
import { EnterPage } from './pages/EnterPage'
import { GhostLogPage } from './pages/GhostLogPage'
import { InventoryPage } from './pages/InventoryPage'
import { LandingPage } from './pages/LandingPage'
import { SeancePage } from './pages/SeancePage'
@@ -28,6 +29,7 @@ function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/enter" element={<EnterPage />} />
<Route path="/seance" element={<SeancePage />} />
<Route path="/log" element={<GhostLogPage />} />
<Route path="/codex" element={<CodexPage />} />
<Route path="/codex/:id" element={<CodexEntityPage />} />
<Route path="/shop" element={<ShopPage />} />

View File

@@ -39,6 +39,7 @@ const RULES = [
['codex.rarity.', RARITY],
['codex.sort.', ['recent', 'contacted']],
['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']],
['log.counts.', ['question', 'anomaly', 'utterance']],
['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']],
['landing.modes.', ['radio', 'evp', 'wire', 'ouija']],
['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']],

View File

@@ -17,7 +17,34 @@
"devices": "devices",
"seance": "séance",
"enter": "enter",
"logout": "depart"
"logout": "depart",
"log": "log"
},
"log": {
"title": "GHOST LOG",
"subtitle": "every séance leaves a page behind",
"back": "return to the séance",
"loading": "leafing back through the log…",
"error": "the log will not open — the veil resists tonight",
"empty": "no séances yet — the log waits",
"noEntity": "no one answered",
"counts": {
"question_one": "{{count}} question",
"question_other": "{{count}} questions",
"anomaly_one": "{{count}} anomaly",
"anomaly_other": "{{count}} anomalies",
"utterance_one": "{{count}} utterance",
"utterance_other": "{{count}} utterances"
},
"time": {
"moments": "moments past",
"minutes_one": "a minute past",
"minutes_other": "{{count}} minutes past",
"hours_one": "an hour past",
"hours_other": "{{count}} hours past",
"days_one": "a day past",
"days_other": "{{count}} days past"
}
},
"landing": {
"taglines": [

View File

@@ -17,7 +17,34 @@
"devices": "dispositivos",
"seance": "sesión",
"enter": "entrar",
"logout": "partir"
"logout": "partir",
"log": "registro"
},
"log": {
"title": "REGISTRO ESPECTRAL",
"subtitle": "cada sesión deja una página tras de sí",
"back": "volver a la sesión",
"loading": "hojeando el registro hacia atrás…",
"error": "el registro no se abre — el velo se resiste esta noche",
"empty": "aún no hay sesiones — el registro espera",
"noEntity": "nadie respondió",
"counts": {
"question_one": "{{count}} pregunta",
"question_other": "{{count}} preguntas",
"anomaly_one": "{{count}} anomalía",
"anomaly_other": "{{count}} anomalías",
"utterance_one": "{{count}} voz",
"utterance_other": "{{count}} voces"
},
"time": {
"moments": "hace instantes",
"minutes_one": "hace un minuto",
"minutes_other": "hace {{count}} minutos",
"hours_one": "hace una hora",
"hours_other": "hace {{count}} horas",
"days_one": "hace un día",
"days_other": "hace {{count}} días"
}
},
"landing": {
"taglines": [

View File

@@ -0,0 +1,147 @@
/* Ghost Log page — same tokens as SeancePage.css:
bg #07070d · phosphor #7cffb2 · violet #b26bff. */
.ghostlog-page {
min-height: 100vh;
padding: 2rem 1rem 4rem;
background:
radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%),
#07070d;
color: #d3e9db;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
}
.ghostlog-header {
max-width: 680px;
margin: 0 auto 1.6rem;
}
.ghostlog-back {
color: #b26bff;
text-decoration: none;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
}
.ghostlog-back:hover {
text-shadow: 0 0 16px rgba(178, 107, 255, 0.9);
}
.ghostlog-title {
margin: 0.9rem 0 0.2rem;
font-family: Cinzel, serif;
font-size: 1.6rem;
letter-spacing: 0.18em;
color: #7cffb2;
text-shadow: 0 0 18px rgba(124, 255, 178, 0.35);
}
.ghostlog-subtitle {
margin: 0;
font-size: 0.78rem;
color: rgba(211, 233, 219, 0.6);
}
.ghostlog-note {
max-width: 680px;
margin: 2.4rem auto;
font-size: 0.85rem;
color: rgba(211, 233, 219, 0.65);
}
.ghostlog-empty {
color: #b26bff;
text-shadow: 0 0 12px rgba(178, 107, 255, 0.4);
}
.ghostlog-list {
max-width: 680px;
margin: 0 auto;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.ghostlog-card {
display: flex;
gap: 0.9rem;
padding: 0.9rem 1rem;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 6px;
}
.ghostlog-glyph {
flex: 0 0 auto;
}
.ghostlog-body {
min-width: 0;
flex: 1;
}
.ghostlog-who {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.ghostlog-name {
font-family: Cinzel, serif;
font-size: 1rem;
color: #7cffb2;
}
.ghostlog-noone {
color: rgba(211, 233, 219, 0.5);
font-style: italic;
}
.ghostlog-epithet {
font-size: 0.72rem;
color: #b26bff;
}
.ghostlog-meta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.3rem;
font-size: 0.72rem;
letter-spacing: 0.06em;
color: rgba(211, 233, 219, 0.6);
}
.ghostlog-echoes {
margin-top: 0.55rem;
border-left: 2px solid rgba(178, 107, 255, 0.35);
padding-left: 0.7rem;
}
.ghostlog-echo {
margin: 0.2rem 0;
font-size: 0.78rem;
color: rgba(124, 255, 178, 0.85);
overflow-wrap: break-word;
}
/* phone widths */
@media (max-width: 420px) {
.ghostlog-page {
padding: 1.4rem 0.7rem 3rem;
}
.ghostlog-card {
padding: 0.7rem 0.7rem;
gap: 0.6rem;
}
.ghostlog-title {
font-size: 1.3rem;
}
}

View File

@@ -0,0 +1,153 @@
// The Ghost Log — a seeker's own recent séances, recalled from
// GET /api/seances/recent. Usability wave Workstream C (#7).
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { GhostGlyph } from '../components/GhostGlyph'
import { useAuth } from '../state/auth'
import type { GhostForm, Mode } from '../lib/types'
import './GhostLogPage.css'
type LogEntity = {
id: string
name: string
epithet: string
rarity: string
visual: { hue?: number; form?: string }
}
type LogSeance = {
id: string
started_at: string
ended_at: string | null
mode: Mode | string
entity: LogEntity | null
counts: Record<string, number>
echoes: string[]
}
type LoadState = 'loading' | 'error' | 'ready'
const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
function relativeTime(iso: string, t: TFunction): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return iso
const minutes = Math.floor((Date.now() - then) / 60_000)
if (minutes < 2) return t('log.time.moments')
if (minutes < 60) return t('log.time.minutes', { count: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('log.time.hours', { count: hours })
return t('log.time.days', { count: Math.floor(hours / 24) })
}
export function GhostLogPage() {
const { t } = useTranslation()
const { user, checking } = useAuth()
const [seances, setSeances] = useState<LogSeance[]>([])
const [state, setState] = useState<LoadState>('loading')
useEffect(() => {
if (!user) return
const controller = new AbortController()
fetch('/api/seances/recent', { credentials: 'include', signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`the log answered ${res.status}`)
return (await res.json()) as { seances: LogSeance[] }
})
.then((data) => {
setSeances(data.seances)
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [user])
if (checking) return null
if (!user) return <Navigate to="/enter" replace />
return (
<div className="ghostlog-page">
<header className="ghostlog-header">
<Link to="/seance" className="ghostlog-back">
{t('log.back')}
</Link>
<h1 className="ghostlog-title">{t('log.title')}</h1>
<p className="ghostlog-subtitle">{t('log.subtitle')}</p>
</header>
{state === 'loading' && (
<p className="ghostlog-note" role="status">
{t('log.loading')}
</p>
)}
{state === 'error' && <p className="ghostlog-note">{t('log.error')}</p>}
{state === 'ready' && seances.length === 0 && (
<p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p>
)}
{state === 'ready' && seances.length > 0 && (
<ul className="ghostlog-list">
{seances.map((seance) => {
const visual = seance.entity?.visual ?? {}
const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '')
? (visual.form as GhostForm)
: 'wisp'
const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0)
.map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] }))
.join(' · ')
return (
<li key={seance.id} className="ghostlog-card">
<div className="ghostlog-glyph">
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
</div>
<div className="ghostlog-body">
<div className="ghostlog-who">
{seance.entity ? (
<>
<span className="ghostlog-name">{seance.entity.name}</span>
{seance.entity.epithet && (
<span className="ghostlog-epithet">{seance.entity.epithet}</span>
)}
</>
) : (
<span className="ghostlog-name ghostlog-noone">
{t('log.noEntity')}
</span>
)}
</div>
<div className="ghostlog-meta">
<span>{relativeTime(seance.started_at, t)}</span>
<span aria-hidden>·</span>
<span>{t(`seance.modes.${seance.mode}`, seance.mode)}</span>
{countLine && (
<>
<span aria-hidden>·</span>
<span>{countLine}</span>
</>
)}
</div>
{seance.echoes.length > 0 && (
<div className="ghostlog-echoes">
{seance.echoes.map((echo, i) => (
<p key={i} className="ghostlog-echo">
<span aria-hidden>» </span>
{echo}
</p>
))}
</div>
)}
</div>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -264,6 +264,10 @@ function SeanceSession({ username }: { username: string }) {
{t('nav.codex')}
</Link>
<Link to="/log" className="seance-codex-link">
{t('nav.log', { defaultValue: 'log' })}
</Link>
<Link to="/inventory" className="seance-codex-link">
{t('nav.inventory', { defaultValue: 'inventory' })}
</Link>