From 55497226338dba8b64292462814ab544c7f4f4e8 Mon Sep 17 00:00:00 2001 From: Indiana Date: Sat, 1 Aug 2026 02:05:44 +0000 Subject: [PATCH] feat: the codex names who has met each spirit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- frontend/src/i18n/en.json | 11 +- frontend/src/i18n/es.json | 11 +- frontend/src/lib/types.ts | 18 +++ frontend/src/pages/CodexEntityPage.css | 93 ++++++++++++ frontend/src/pages/CodexEntityPage.test.tsx | 150 ++++++++++++++++++++ frontend/src/pages/CodexEntityPage.tsx | 101 ++++++++++++- 6 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/CodexEntityPage.test.tsx diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 4ceb670..7914cb0 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 0c73634..d9aecef 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a43109e..16bff98 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 sightings: number + /** Bounded window (backend ROSTER_LIMIT) over `total_encounters`. */ + encounters: CodexEncounter[] + total_encounters: number + discoverer: CodexEncounter | null + discoverer_public: boolean } export type SpiritVoice = { diff --git a/frontend/src/pages/CodexEntityPage.css b/frontend/src/pages/CodexEntityPage.css index c6f3787..ca40111 100644 --- a/frontend/src/pages/CodexEntityPage.css +++ b/frontend/src/pages/CodexEntityPage.css @@ -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. */ diff --git a/frontend/src/pages/CodexEntityPage.test.tsx b/frontend/src/pages/CodexEntityPage.test.tsx new file mode 100644 index 0000000..1d32ede --- /dev/null +++ b/frontend/src/pages/CodexEntityPage.test.tsx @@ -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 + +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 = {}) { + 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) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })), + ) + render( + + + } /> + + , + ) + 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() + }) +}) diff --git a/frontend/src/pages/CodexEntityPage.tsx b/frontend/src/pages/CodexEntityPage.tsx index 1ea8004..29f33f4 100644 --- a/frontend/src/pages/CodexEntityPage.tsx +++ b/frontend/src/pages/CodexEntityPage.tsx @@ -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 {label} + } + return ( + + {label} + + ) +} + +function RosterRow({ + hunter, + discoverer, +}: { + hunter: CodexEncounter + discoverer?: boolean +}) { + const { t } = useTranslation() + return ( +
  • + + + + {discoverer && ( + + {t('codex.detail.roster.firstContact', 'first contact')} + + )} + + {hunter.times_contacted > 0 && ( + + {t('codex.detail.roster.times', { count: hunter.times_contacted })} + + )} +
  • + ) +} + +/** "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 ( +
    +

    + {t('codex.detail.roster.heading', 'who has met this spirit')} +

    + + {discoverer && ( +
      + +
    + )} + + {others.length > 0 ? ( +
      + {others.map((hunter) => ( + + ))} +
    + ) : ( +

    + {t('codex.detail.roster.empty', 'no other hunter has reached this spirit')} +

    + )} + + {hidden > 0 && ( +

    + {t('codex.detail.roster.more', { count: hidden })} +

    + )} +
    + ) +} + export function CodexEntityPage() { const { t, i18n } = useTranslation() const { id } = useParams<{ id: string }>() @@ -208,6 +305,8 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string + +

    {t('codex.detail.voiceSignature', 'voice signature')}