feat: make the Ghost Log actually do something

It was read-only: one link on the whole page ("back to séance"), no way
to get from "I talked to this spirit" to anything about them. Fixed with
three additions, all client-side against data already fetched in one
call — no backend change:

- Every entity glyph and name links straight to its Codex page.
- Echoes past the first are collapsed behind a "+N more" toggle instead
  of always showing up to 3 — a card reads cleanly at a glance, with
  detail one tap away.
- A channel filter (only shown once >1 mode is actually present in the
  log) so a seeker with a long history can find "just the radio nights."

Deliberately did NOT add a "revisit this spirit" button. Contact is a
probabilistic channel draw (RETURN_CHANCE), not "resume this entity" —
a button that just navigated to /seance would be indistinguishable from
the existing back link and would imply a guarantee the app doesn't make.
Cosmetic interactivity isn't worth shipping.

6 new tests. 372 frontend tests pass total; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-29 19:16:49 +00:00
parent b68e5d7fe5
commit 1688907971
5 changed files with 283 additions and 8 deletions

View File

@@ -28,6 +28,11 @@
"error": "the log will not open — the veil resists tonight", "error": "the log will not open — the veil resists tonight",
"empty": "no séances yet — the log waits", "empty": "no séances yet — the log waits",
"noEntity": "no one answered", "noEntity": "no one answered",
"filterLabel": "filter by channel",
"filterAll": "all channels",
"showMore_one": "+{{count}} more echo",
"showMore_other": "+{{count}} more echoes",
"showLess": "show less",
"counts": { "counts": {
"question_one": "{{count}} question", "question_one": "{{count}} question",
"question_other": "{{count}} questions", "question_other": "{{count}} questions",

View File

@@ -28,6 +28,11 @@
"error": "el registro no se abre — el velo se resiste esta noche", "error": "el registro no se abre — el velo se resiste esta noche",
"empty": "aún no hay sesiones — el registro espera", "empty": "aún no hay sesiones — el registro espera",
"noEntity": "nadie respondió", "noEntity": "nadie respondió",
"filterLabel": "filtrar por canal",
"filterAll": "todos los canales",
"showMore_one": "+{{count}} eco más",
"showMore_other": "+{{count}} ecos más",
"showLess": "mostrar menos",
"counts": { "counts": {
"question_one": "{{count}} pregunta", "question_one": "{{count}} pregunta",
"question_other": "{{count}} preguntas", "question_other": "{{count}} preguntas",

View File

@@ -79,6 +79,16 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
a.ghostlog-glyph {
border-radius: 50%;
transition: filter 140ms ease;
}
a.ghostlog-glyph:hover,
a.ghostlog-glyph:focus-visible {
filter: drop-shadow(0 0 8px rgba(124, 255, 178, 0.6));
}
.ghostlog-body { .ghostlog-body {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
@@ -91,6 +101,26 @@
gap: 0.5rem; gap: 0.5rem;
} }
.ghostlog-who-link {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
text-decoration: none;
border-radius: 3px;
transition: text-shadow 140ms ease;
}
.ghostlog-who-link:hover .ghostlog-name,
.ghostlog-who-link:focus-visible .ghostlog-name {
text-shadow: 0 0 10px rgba(124, 255, 178, 0.6);
}
.ghostlog-who-link:focus-visible {
outline: 1px solid #7cffb2;
outline-offset: 3px;
}
.ghostlog-name { .ghostlog-name {
font-family: Cinzel, serif; font-family: Cinzel, serif;
font-size: 1rem; font-size: 1rem;
@@ -130,6 +160,56 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.ghostlog-expand {
background: transparent;
border: none;
color: #b26bff;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.3rem 0 0.1rem;
cursor: pointer;
min-height: 32px;
}
.ghostlog-expand:hover,
.ghostlog-expand:focus-visible {
text-shadow: 0 0 8px rgba(178, 107, 255, 0.6);
}
.ghostlog-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0 0 1.1rem;
}
.ghostlog-filter {
background: transparent;
border: 1px solid rgba(124, 255, 178, 0.25);
border-radius: 3px;
color: rgba(211, 233, 219, 0.6);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.4rem 0.7rem;
min-height: 36px;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.ghostlog-filter:hover {
color: #7cffb2;
}
.ghostlog-filter.active {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.6);
background: rgba(124, 255, 178, 0.08);
}
/* phone widths */ /* phone widths */
@media (max-width: 420px) { @media (max-width: 420px) {
.ghostlog-page { .ghostlog-page {

View File

@@ -0,0 +1,126 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { AuthProvider } from '../state/auth'
import * as api from '../api'
import { GhostLogPage } from './GhostLogPage'
vi.mock('../api')
function mockFetch(handler: (input: RequestInfo | URL) => Response | Promise<Response>) {
vi.stubGlobal('fetch', vi.fn(handler))
}
function seance(overrides: Record<string, unknown> = {}) {
return {
id: 's-1',
started_at: new Date(Date.now() - 5 * 60_000).toISOString(),
ended_at: null,
mode: 'evp',
entity: {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
rarity: 'rare',
visual: { hue: 210, form: 'wisp' },
},
counts: { question: 3, anomaly: 2, utterance: 5 },
echoes: ['the water remembers', 'do not follow the light', 'I was here first'],
...overrides,
}
}
beforeEach(() => {
vi.mocked(api.me).mockResolvedValue({ id: 'u-1', username: 'seeker', unlocks: [], essence: 0 })
})
afterEach(() => {
vi.unstubAllGlobals()
})
async function renderPage() {
render(
<AuthProvider>
<MemoryRouter initialEntries={['/log']}>
<GhostLogPage />
</MemoryRouter>
</AuthProvider>,
)
await waitFor(() => expect(api.me).toHaveBeenCalled())
}
describe('GhostLogPage', () => {
it('links a session with an entity straight to its Codex page', async () => {
mockFetch(async () => Response.json({ seances: [seance()] }))
await renderPage()
const link = await screen.findByRole('link', { name: /Mirela/ })
expect(link).toHaveAttribute('href', '/codex/e-1')
})
it('shows only the first echo and reveals the rest on demand', async () => {
mockFetch(async () => Response.json({ seances: [seance()] }))
await renderPage()
await screen.findByText('the water remembers')
expect(screen.queryByText('do not follow the light')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /more echo/i }))
expect(screen.getByText('do not follow the light')).toBeInTheDocument()
expect(screen.getByText('I was here first')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /show less/i }))
expect(screen.queryByText('do not follow the light')).toBeNull()
})
it('does not show an expand control when there is only one echo', async () => {
mockFetch(async () =>
Response.json({ seances: [seance({ echoes: ['only one line'] })] }),
)
await renderPage()
await screen.findByText('only one line')
expect(screen.queryByRole('button', { name: /more echo/i })).toBeNull()
})
it('filters the log by channel when more than one mode is present', async () => {
mockFetch(async () =>
Response.json({
seances: [
seance({ id: 's-evp', mode: 'evp', entity: { ...seance().entity, name: 'Mirela' } }),
seance({ id: 's-radio', mode: 'radio', entity: { ...seance().entity, id: 'e-2', name: 'Ash' } }),
],
}),
)
await renderPage()
await screen.findByRole('link', { name: /Mirela/ })
expect(screen.getByRole('link', { name: /Ash/ })).toBeInTheDocument()
const filters = screen.getByRole('group', { name: /filter/i })
await userEvent.click(within(filters).getByRole('button', { name: /radio/i }))
expect(screen.queryByRole('link', { name: /Mirela/ })).toBeNull()
expect(screen.getByRole('link', { name: /Ash/ })).toBeInTheDocument()
})
it('hides the filter row entirely when every session shares one channel', async () => {
mockFetch(async () =>
Response.json({ seances: [seance({ mode: 'evp' }), seance({ id: 's-2', mode: 'evp' })] }),
)
await renderPage()
await screen.findAllByText('Mirela')
expect(screen.queryByRole('group', { name: /filter/i })).toBeNull()
})
it('renders a session with no entity without a dangling link', async () => {
mockFetch(async () => Response.json({ seances: [seance({ entity: null })] }))
await renderPage()
await screen.findByText(/no one answered/i)
expect(screen.queryByRole('link', { name: /codex/i })).toBeNull()
})
})

View File

@@ -1,7 +1,7 @@
// The Ghost Log — a seeker's own recent séances, recalled from // The Ghost Log — a seeker's own recent séances, recalled from
// GET /api/seances/recent. Usability wave Workstream C (#7). // GET /api/seances/recent. Usability wave Workstream C (#7).
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom' import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
@@ -32,6 +32,7 @@ type LoadState = 'loading' | 'error' | 'ready'
const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade'] const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
const ALL_MODES = 'all'
function relativeTime(iso: string, t: TFunction): string { function relativeTime(iso: string, t: TFunction): string {
const then = new Date(iso).getTime() const then = new Date(iso).getTime()
@@ -49,6 +50,8 @@ export function GhostLogPage() {
const { user, checking } = useAuth() const { user, checking } = useAuth()
const [seances, setSeances] = useState<LogSeance[]>([]) const [seances, setSeances] = useState<LogSeance[]>([])
const [state, setState] = useState<LoadState>('loading') const [state, setState] = useState<LoadState>('loading')
const [modeFilter, setModeFilter] = useState<string>(ALL_MODES)
const [expandedId, setExpandedId] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!user) return if (!user) return
@@ -68,6 +71,17 @@ export function GhostLogPage() {
return () => controller.abort() return () => controller.abort()
}, [user]) }, [user])
// Client-side only — the whole log is already fetched in one call, and
// a handful of sessions never justifies a round trip just to filter them.
const modesPresent = useMemo(
() => Array.from(new Set(seances.map((s) => s.mode))).sort(),
[seances],
)
const visible = useMemo(
() => (modeFilter === ALL_MODES ? seances : seances.filter((s) => s.mode === modeFilter)),
[seances, modeFilter],
)
if (checking) return null if (checking) return null
if (!user) return <Navigate to="/enter" replace /> if (!user) return <Navigate to="/enter" replace />
@@ -91,9 +105,31 @@ export function GhostLogPage() {
<p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p> <p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p>
)} )}
{state === 'ready' && seances.length > 0 && modesPresent.length > 1 && (
<div className="ghostlog-filters" role="group" aria-label={t('log.filterLabel')}>
<button
type="button"
className={`ghostlog-filter ${modeFilter === ALL_MODES ? 'active' : ''}`}
onClick={() => setModeFilter(ALL_MODES)}
>
{t('log.filterAll')}
</button>
{modesPresent.map((mode) => (
<button
key={mode}
type="button"
className={`ghostlog-filter ${modeFilter === mode ? 'active' : ''}`}
onClick={() => setModeFilter(mode)}
>
{t(`seance.modes.${mode}`, mode)}
</button>
))}
</div>
)}
{state === 'ready' && seances.length > 0 && ( {state === 'ready' && seances.length > 0 && (
<ul className="ghostlog-list"> <ul className="ghostlog-list">
{seances.map((seance) => { {visible.map((seance) => {
const visual = seance.entity?.visual ?? {} const visual = seance.entity?.visual ?? {}
const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '') const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '')
? (visual.form as GhostForm) ? (visual.form as GhostForm)
@@ -101,20 +137,31 @@ export function GhostLogPage() {
const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0) const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0)
.map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] })) .map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] }))
.join(' · ') .join(' · ')
const expanded = expandedId === seance.id
const hasMoreToShow = seance.echoes.length > 1
return ( return (
<li key={seance.id} className="ghostlog-card"> <li key={seance.id} className="ghostlog-card">
{seance.entity ? (
// The whole point of a log is that it leads somewhere —
// straight through to everything the Codex knows about
// whoever answered.
<Link to={`/codex/${seance.entity.id}`} className="ghostlog-glyph">
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
</Link>
) : (
<div className="ghostlog-glyph"> <div className="ghostlog-glyph">
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} /> <GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
</div> </div>
)}
<div className="ghostlog-body"> <div className="ghostlog-body">
<div className="ghostlog-who"> <div className="ghostlog-who">
{seance.entity ? ( {seance.entity ? (
<> <Link to={`/codex/${seance.entity.id}`} className="ghostlog-who-link">
<span className="ghostlog-name">{seance.entity.name}</span> <span className="ghostlog-name">{seance.entity.name}</span>
{seance.entity.epithet && ( {seance.entity.epithet && (
<span className="ghostlog-epithet">{seance.entity.epithet}</span> <span className="ghostlog-epithet">{seance.entity.epithet}</span>
)} )}
</> </Link>
) : ( ) : (
<span className="ghostlog-name ghostlog-noone"> <span className="ghostlog-name ghostlog-noone">
{t('log.noEntity')} {t('log.noEntity')}
@@ -134,12 +181,24 @@ export function GhostLogPage() {
</div> </div>
{seance.echoes.length > 0 && ( {seance.echoes.length > 0 && (
<div className="ghostlog-echoes"> <div className="ghostlog-echoes">
{seance.echoes.map((echo, i) => ( {(expanded ? seance.echoes : seance.echoes.slice(0, 1)).map((echo, i) => (
<p key={i} className="ghostlog-echo"> <p key={i} className="ghostlog-echo">
<span aria-hidden>» </span> <span aria-hidden>» </span>
{echo} {echo}
</p> </p>
))} ))}
{hasMoreToShow && (
<button
type="button"
className="ghostlog-expand"
onClick={() => setExpandedId(expanded ? null : seance.id)}
aria-expanded={expanded}
>
{expanded
? t('log.showLess')
: t('log.showMore', { count: seance.echoes.length - 1 })}
</button>
)}
</div> </div>
)} )}
</div> </div>