feat: the codex names who has met each spirit

`GET /api/codex/{id}` has been returning `encounters`, `total_encounters`,
`discoverer` and `discoverer_public` since Workstream T, and the page
rendered none of it — the backend work shipped and sat unused.

The roster now names the hunters who reached a spirit, discoverer first and
distinctly (being first to find it is the notable thing). A hunter with a
public profile is a link to their page; a hunter who veiled their profile is
named as plain text, counted but not linkable — deliberate, per the spec:
they were there, and hiding them entirely would falsify the record.

That last rule is the one worth protecting, so it is pinned by tests that
assert `queryByRole('link')` is null for a veiled hunter. Efficacy proven by
making the branch unreachable, which fails exactly those two tests and no
others.

Also shows "and N more" only when the backend's ROSTER_LIMIT actually
truncated, so a capped list can't read as the complete history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 02:05:44 +00:00
parent 967c10b709
commit 5549722633
6 changed files with 381 additions and 3 deletions

View File

@@ -507,7 +507,16 @@
"utterances": "known utterances",
"noUtterances": "no utterances recorded yet",
"voiceSignature": "voice signature",
"attemptContact": "attempt contact"
"attemptContact": "attempt contact",
"roster": {
"heading": "who has met this spirit",
"firstContact": "first contact",
"times_one": "reached once",
"times_other": "reached {{count}} times",
"empty": "no other hunter has reached this spirit — only the one who found it",
"more_one": "and 1 more hunter not named here",
"more_other": "and {{count}} more hunters not named here"
}
},
"empty": "no spirits match this filter — the registry keeps its secrets",
"back": "← back to the codex",

View File

@@ -507,7 +507,16 @@
"utterances": "enunciados conocidos",
"noUtterances": "aún no hay enunciados registrados",
"voiceSignature": "firma de voz",
"attemptContact": "intentar contacto"
"attemptContact": "intentar contacto",
"roster": {
"heading": "quién ha encontrado a este espíritu",
"firstContact": "primer contacto",
"times_one": "alcanzado una vez",
"times_other": "alcanzado {{count}} veces",
"empty": "ningún otro médium ha alcanzado a este espíritu — solo quien lo encontró",
"more_one": "y 1 médium más sin nombrar aquí",
"more_other": "y {{count}} médiums más sin nombrar aquí"
}
},
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
"back": "← volver al códice",

View File

@@ -23,10 +23,28 @@ export type CodexEntity = {
discovered_by: string | null
}
/** One hunter on a spirit's encounter roster (GET /api/codex/{id}).
`public` is false for a hidden profile: still named, still counted,
but never linked — deliberate, per the hunter-profiles design. */
export type CodexEncounter = {
username: string
display_name: string | null
avatar_form: string | null
avatar_hue: number | null
public: boolean
times_contacted: number
last_seen: string | null
}
export type CodexEntityDetail = CodexEntity & {
persona: string
voice: Record<string, unknown>
sightings: number
/** Bounded window (backend ROSTER_LIMIT) over `total_encounters`. */
encounters: CodexEncounter[]
total_encounters: number
discoverer: CodexEncounter | null
discoverer_public: boolean
}
export type SpiritVoice = {

View File

@@ -327,6 +327,99 @@
overflow-wrap: anywhere;
}
/* ---- encounter roster ---- */
.codex-roster {
list-style: none;
margin: 0 0 0.9rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 1px;
background: rgba(124, 255, 178, 0.12);
border: 1px solid rgba(124, 255, 178, 0.12);
border-radius: 8px;
overflow: hidden;
}
.codex-roster-row {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
background: rgba(10, 10, 18, 0.85);
padding: 0.6rem 0.9rem;
}
/* The first to drag this thing through the veil — set apart, violet-lit. */
.codex-roster-row-first {
background: rgba(178, 107, 255, 0.09);
box-shadow: inset 2px 0 0 rgba(178, 107, 255, 0.7);
}
.codex-roster-who {
display: flex;
align-items: baseline;
gap: 0.55rem;
flex-wrap: wrap;
min-width: 0;
flex: 1 1 auto;
}
.codex-roster-name {
font-size: 0.9rem;
letter-spacing: 0.06em;
overflow-wrap: anywhere;
}
.codex-roster-link {
color: #7cffb2;
text-decoration: none;
border-bottom: 1px dotted rgba(124, 255, 178, 0.45);
transition: color 160ms ease, text-shadow 160ms ease;
}
.codex-roster-link:hover,
.codex-roster-link:focus-visible {
color: #a5ffcb;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.6);
}
/* Hidden profile: present, named, unlinkable. Dimmed, never removed. */
.codex-roster-name-hidden {
color: rgba(216, 230, 220, 0.62);
font-style: italic;
}
.codex-roster-badge {
font-size: 0.54rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: #b26bff;
border: 1px solid rgba(178, 107, 255, 0.5);
border-radius: 999px;
padding: 0.16rem 0.6rem;
white-space: nowrap;
text-shadow: 0 0 10px rgba(178, 107, 255, 0.5);
}
.codex-roster-times {
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.45);
margin-left: auto;
white-space: nowrap;
}
.codex-roster-more {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.14em;
font-style: italic;
color: rgba(178, 107, 255, 0.72);
}
/* ---- voice signature table ---- */
/* Own scroll container: the table pans inside this box, the page does not. */

View File

@@ -0,0 +1,150 @@
// The "who has met this spirit" roster on the codex dossier page.
import { render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { CodexEntityPage } from './CodexEntityPage'
type Hunter = Record<string, unknown>
function hunter(username: string, over: Hunter = {}): Hunter {
return {
username,
display_name: null,
avatar_form: 'wisp',
avatar_hue: 150,
public: true,
times_contacted: 2,
last_seen: '2026-01-01T00:00:00+00:00',
...over,
}
}
function detail(over: Record<string, unknown> = {}) {
return {
id: 'e-1',
name: 'Mirela',
epithet: 'the Drowned',
rarity: 'rare',
visual: { hue: 210, form: 'wisp' },
quotes: [],
contact_count: 4,
discovered_at: '2026-01-01T00:00:00+00:00',
discovered_by: 'ravenna',
persona: 'a cold voice under still water',
voice: { pitch: 0.8 },
sightings: 6,
encounters: [],
total_encounters: 0,
discoverer: null,
discoverer_public: false,
...over,
}
}
function mount(body: Record<string, unknown>) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })),
)
render(
<MemoryRouter initialEntries={['/codex/e-1']}>
<Routes>
<Route path="/codex/:id" element={<CodexEntityPage />} />
</Routes>
</MemoryRouter>,
)
return waitFor(() => expect(screen.getByText('Mirela')).toBeInTheDocument())
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('CodexEntityPage encounter roster', () => {
it('links a public hunter to their hunter page', async () => {
await mount(
detail({
encounters: [hunter('seeker', { display_name: 'Seeker of Salt' })],
total_encounters: 1,
}),
)
const link = screen.getByRole('link', { name: 'Seeker of Salt' })
expect(link).toHaveAttribute('href', '/hunters/seeker')
})
it('renders a non-public hunter as plain text, never a link', async () => {
await mount(
detail({
encounters: [hunter('ghosted', { display_name: 'The Quiet One', public: false })],
total_encounters: 1,
}),
)
// Still named, still counted — just not a door.
expect(screen.getByText('The Quiet One')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'The Quiet One' })).toBeNull()
expect(screen.queryByRole('link', { name: /quiet/i })).toBeNull()
})
it('sets the discoverer apart from ordinary encounterers', async () => {
await mount(
detail({
discoverer: hunter('ravenna', { display_name: 'Ravenna' }),
discoverer_public: true,
encounters: [
hunter('ravenna', { display_name: 'Ravenna' }),
hunter('seeker', { display_name: 'Seeker' }),
],
total_encounters: 2,
}),
)
const first = document.querySelector('[data-discoverer="true"]') as HTMLElement
expect(first).not.toBeNull()
expect(within(first).getByText('Ravenna')).toBeInTheDocument()
expect(within(first).getByText('first contact')).toBeInTheDocument()
// and not duplicated down in the ordinary list
expect(screen.getAllByText('Ravenna')).toHaveLength(1)
expect(screen.getByRole('link', { name: 'Seeker' })).toBeInTheDocument()
})
it('marks a non-public discoverer as unlinked but still first', async () => {
await mount(
detail({
discoverer: hunter('ravenna', { display_name: 'Ravenna', public: false }),
discoverer_public: false,
encounters: [hunter('ravenna', { display_name: 'Ravenna', public: false })],
total_encounters: 1,
}),
)
expect(screen.getByText('first contact')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Ravenna' })).toBeNull()
})
it('says how many hunters are missing when the roster is truncated', async () => {
await mount(
detail({
encounters: [hunter('a'), hunter('b')],
total_encounters: 9,
}),
)
expect(screen.getByText(/7 more hunters/)).toBeInTheDocument()
})
it('shows no truncation notice when the roster is complete', async () => {
await mount(detail({ encounters: [hunter('a')], total_encounters: 1 }))
expect(screen.queryByText(/more hunter/)).toBeNull()
})
it('gives the empty case real copy', async () => {
await mount(
detail({
discoverer: hunter('ravenna', { display_name: 'Ravenna' }),
discoverer_public: true,
encounters: [hunter('ravenna', { display_name: 'Ravenna' })],
total_encounters: 1,
}),
)
expect(screen.getByText(/only the one who found it/)).toBeInTheDocument()
})
})

View File

@@ -5,7 +5,9 @@ import { useEffect, useState, type CSSProperties } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { PUBLIC_ORIGIN, usePageMeta } from '../lib/pageMeta'
import type { CodexEntityDetail } from '../lib/types'
import { GhostGlyph } from '../components/GhostGlyph'
import { hunterName } from '../api'
import type { CodexEncounter, CodexEntityDetail, GhostForm } from '../lib/types'
import './CodexEntityPage.css'
type DetailState =
@@ -34,6 +36,101 @@ function formatVoiceValue(value: unknown): string {
return String(value)
}
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
function asForm(form: string | null | undefined): GhostForm {
return GHOST_FORMS.includes(form ?? '') ? (form as GhostForm) : 'wisp'
}
/** A hunter's name. Public profiles are doors; hidden ones are plain text —
they were there, they are named, but they are not linkable. */
function HunterLabel({ hunter }: { hunter: CodexEncounter }) {
const label = hunterName(hunter)
if (!hunter.public) {
return <span className="codex-roster-name codex-roster-name-hidden">{label}</span>
}
return (
<Link to={`/hunters/${hunter.username}`} className="codex-roster-name codex-roster-link">
{label}
</Link>
)
}
function RosterRow({
hunter,
discoverer,
}: {
hunter: CodexEncounter
discoverer?: boolean
}) {
const { t } = useTranslation()
return (
<li
className={`codex-roster-row${discoverer ? ' codex-roster-row-first' : ''}`}
data-discoverer={discoverer ? 'true' : undefined}
>
<GhostGlyph hue={hunter.avatar_hue ?? 150} form={asForm(hunter.avatar_form)} size={34} />
<span className="codex-roster-who">
<HunterLabel hunter={hunter} />
{discoverer && (
<span className="codex-roster-badge">
{t('codex.detail.roster.firstContact', 'first contact')}
</span>
)}
</span>
{hunter.times_contacted > 0 && (
<span className="codex-roster-times">
{t('codex.detail.roster.times', { count: hunter.times_contacted })}
</span>
)}
</li>
)
}
/** "Who has met this spirit" — the discoverer set apart, then everyone else. */
function EncounterRoster({ entity }: { entity: CodexEntityDetail }) {
const { t } = useTranslation()
const encounters = entity.encounters ?? []
const discoverer = entity.discoverer ?? null
const others = discoverer
? encounters.filter((h) => h.username !== discoverer.username)
: encounters
// total_encounters counts distinct hunters; the roster is a bounded window.
const hidden = Math.max(0, (entity.total_encounters ?? encounters.length) - encounters.length)
return (
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.roster.heading', 'who has met this spirit')}
</h2>
{discoverer && (
<ul className="codex-roster">
<RosterRow hunter={discoverer} discoverer />
</ul>
)}
{others.length > 0 ? (
<ul className="codex-roster">
{others.map((hunter) => (
<RosterRow key={hunter.username} hunter={hunter} />
))}
</ul>
) : (
<p className="codex-dossier-none">
{t('codex.detail.roster.empty', 'no other hunter has reached this spirit')}
</p>
)}
{hidden > 0 && (
<p className="codex-roster-more">
{t('codex.detail.roster.more', { count: hidden })}
</p>
)}
</section>
)
}
export function CodexEntityPage() {
const { t, i18n } = useTranslation()
const { id } = useParams<{ id: string }>()
@@ -208,6 +305,8 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
</dl>
</section>
<EncounterRoster entity={entity} />
<section className="codex-dossier-section">
<h2 className="codex-dossier-heading">
{t('codex.detail.voiceSignature', 'voice signature')}