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:
@@ -507,7 +507,16 @@
|
|||||||
"utterances": "known utterances",
|
"utterances": "known utterances",
|
||||||
"noUtterances": "no utterances recorded yet",
|
"noUtterances": "no utterances recorded yet",
|
||||||
"voiceSignature": "voice signature",
|
"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",
|
"empty": "no spirits match this filter — the registry keeps its secrets",
|
||||||
"back": "← back to the codex",
|
"back": "← back to the codex",
|
||||||
|
|||||||
@@ -507,7 +507,16 @@
|
|||||||
"utterances": "enunciados conocidos",
|
"utterances": "enunciados conocidos",
|
||||||
"noUtterances": "aún no hay enunciados registrados",
|
"noUtterances": "aún no hay enunciados registrados",
|
||||||
"voiceSignature": "firma de voz",
|
"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",
|
"empty": "ningún espíritu coincide con este filtro — el registro guarda sus secretos",
|
||||||
"back": "← volver al códice",
|
"back": "← volver al códice",
|
||||||
|
|||||||
@@ -23,10 +23,28 @@ export type CodexEntity = {
|
|||||||
discovered_by: string | null
|
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 & {
|
export type CodexEntityDetail = CodexEntity & {
|
||||||
persona: string
|
persona: string
|
||||||
voice: Record<string, unknown>
|
voice: Record<string, unknown>
|
||||||
sightings: number
|
sightings: number
|
||||||
|
/** Bounded window (backend ROSTER_LIMIT) over `total_encounters`. */
|
||||||
|
encounters: CodexEncounter[]
|
||||||
|
total_encounters: number
|
||||||
|
discoverer: CodexEncounter | null
|
||||||
|
discoverer_public: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpiritVoice = {
|
export type SpiritVoice = {
|
||||||
|
|||||||
@@ -327,6 +327,99 @@
|
|||||||
overflow-wrap: anywhere;
|
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 ---- */
|
/* ---- voice signature table ---- */
|
||||||
|
|
||||||
/* Own scroll container: the table pans inside this box, the page does not. */
|
/* Own scroll container: the table pans inside this box, the page does not. */
|
||||||
|
|||||||
150
frontend/src/pages/CodexEntityPage.test.tsx
Normal file
150
frontend/src/pages/CodexEntityPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,9 @@ import { useEffect, useState, type CSSProperties } from 'react'
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PUBLIC_ORIGIN, usePageMeta } from '../lib/pageMeta'
|
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'
|
import './CodexEntityPage.css'
|
||||||
|
|
||||||
type DetailState =
|
type DetailState =
|
||||||
@@ -34,6 +36,101 @@ function formatVoiceValue(value: unknown): string {
|
|||||||
return String(value)
|
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() {
|
export function CodexEntityPage() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -208,6 +305,8 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<EncounterRoster entity={entity} />
|
||||||
|
|
||||||
<section className="codex-dossier-section">
|
<section className="codex-dossier-section">
|
||||||
<h2 className="codex-dossier-heading">
|
<h2 className="codex-dossier-heading">
|
||||||
{t('codex.detail.voiceSignature', 'voice signature')}
|
{t('codex.detail.voiceSignature', 'voice signature')}
|
||||||
|
|||||||
Reference in New Issue
Block a user