feat: hunter profiles, ranks, whispers, and the encounter record

All five agents died mid-flight (three on session limits, two on 529s), but
their worktrees held real work — 17 files. Salvaged everything, wrote the
missing pieces, and finished the integration by hand.

PROFILES + RANK
User gains display_name, bio, gender, avatar_form, avatar_hue and
profile_public — all nullable, so every existing row including the guest
`wanderer-` accounts stays valid with no backfill. The avatar is procedural
(a GhostForm plus a hue, drawn by the same GhostGlyph that renders
entities): no uploads means no moderation surface, no EXIF and no blob
storage, and an `avatar_url` still slots in later without changing anything.

rank.py converts encounters, essence and favor into one "standing" currency
and maps it onto six one-word titles. An encounter is worth ten points to
ten essence's one, because contact is what the app is about — a seeker who
only buys unlocks climbs very slowly. Negative essence and favor floor at
zero rather than subtracting, so a bad judgment can never demote you: rank
is a record of what you have done. Level 1 costs exactly one encounter, so a
new hunter sees the bar move after their first séance.

Privacy invariants, verified live rather than assumed:
- `email` is returned by GET /api/profile/me and by nothing else. Confirmed
  against the running server: zero occurrences in both public payloads.
- A hidden profile 404s rather than 403s — confirming the account exists
  would leak exactly what hiding it was meant to prevent.

WHISPERS BETWEEN HUNTERS
Plain text, no attachments, no editing. Guests can RECEIVE but not send:
that gives registering a felt purpose beyond keeping a codex, and closes the
obvious spam vector since guest accounts are free and automatic. Verified
live: alice→bob delivers, a guest send returns 403, and a third party's
conversation list comes back empty — no cross-user leak.

Message bodies are rendered as text nodes, never as HTML, and wrap with
overflow-wrap:anywhere so a long unbroken string can't blow out the layout.

THE ENCOUNTER RECORD
The Codex already knew all of this — Entity.discovered_by has always been
recorded and every contact was already an entity_sightings row. Nobody ever
showed it. Now an entity page names its summoner and lists every hunter who
has met it. Hunters who opted out of a public profile are still COUNTED but
not linkable: an anonymous contact is still a contact, so a spirit's history
stays honest without exposing anyone.

Live on production data: Mabel Crump, discovered by Charly, 1 encounter;
Charly ranks channeler (level 2) from 5 real sightings — all computed from
data that was already sitting there.

385 frontend tests pass; i18n parity holds across both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-31 02:50:00 +00:00
parent 3656b6b0c4
commit bacfb852b8
23 changed files with 3472 additions and 4 deletions

View File

@@ -11,7 +11,11 @@ import { DevicesPage } from './pages/DevicesPage'
import { EnterPage } from './pages/EnterPage'
import { GhostLogPage } from './pages/GhostLogPage'
import { InventoryPage } from './pages/InventoryPage'
import { HunterPage } from './pages/HunterPage'
import { HuntersPage } from './pages/HuntersPage'
import { LandingPage } from './pages/LandingPage'
import { MessagesPage } from './pages/MessagesPage'
import { ProfilePage } from './pages/ProfilePage'
import { SeancePage } from './pages/SeancePage'
import { ShopPage } from './pages/ShopPage'
@@ -35,6 +39,11 @@ function App() {
<Route path="/shop" element={<ShopPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/hunters" element={<HuntersPage />} />
<Route path="/hunters/:username" element={<HunterPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/messages/:username" element={<MessagesPage />} />
<Route path="*" element={<LandingPage />} />
</Routes>
</BrowserRouter>

View File

@@ -1,3 +1,5 @@
import type { GhostForm, Rarity } from './lib/types'
export type User = {
id: string
username: string
@@ -51,3 +53,119 @@ export function logout(): Promise<void> {
export function me(): Promise<User> {
return request<User>('/auth/me')
}
// ---------------------------------------------------------------------------
// Hunter profiles — the single place the app talks to Workstream P's
// /api/profile and /api/hunters endpoints, so a backend shape change is a
// one-file fix rather than a hunt through three pages.
// ---------------------------------------------------------------------------
export const GENDERS = ['male', 'female', 'unspecified'] as const
export type Gender = (typeof GENDERS)[number]
export const AVATAR_FORMS: readonly GhostForm[] = ['wisp', 'banshee', 'fairy', 'shade']
/** Bio cap enforced client-side; the backend enforces the same 280 limit. */
export const BIO_MAX = 280
export const HUE_MAX = 359
/** Rank block carried inside every profile payload. `progress` is a 0-1
* fraction toward `next_at`; `next_at` is null at the top of the curve. */
export type Rank = {
level: number
title: string
encounters: number
next_at: number | null
progress: number
}
/** GET /api/profile/me — the seeker's own profile, email included. */
export type OwnProfile = {
username: string
display_name: string | null
bio: string | null
gender: Gender | null
avatar_form: GhostForm | null
avatar_hue: number | null
profile_public: boolean
email: string | null
rank: Rank
}
/** Body of PATCH /api/profile — every field optional, only sent fields change. */
export type ProfileUpdate = {
display_name?: string | null
bio?: string | null
gender?: Gender
avatar_form?: GhostForm
avatar_hue?: number
profile_public?: boolean
email?: string | null
}
export type HunterEntity = {
id: string
name: string
epithet: string
rarity: Rarity | string
visual: { hue?: number; form?: string }
}
/** GET /api/hunters/{username} — public profile. Never carries an email. */
export type PublicHunter = {
username: string
display_name: string | null
bio: string | null
gender: Gender | null
avatar_form: GhostForm | null
avatar_hue: number | null
joined_at: string | null
rank: Rank
recent_entities: HunterEntity[]
}
/** GET /api/hunters — one roster row. */
export type RosterHunter = {
username: string
display_name: string | null
avatar_form: GhostForm | null
avatar_hue: number | null
rank: Rank
}
export function fetchOwnProfile(signal?: AbortSignal): Promise<OwnProfile> {
return request<OwnProfile>('/api/profile/me', { signal })
}
export function saveProfile(patch: ProfileUpdate): Promise<OwnProfile> {
return request<OwnProfile>('/api/profile', {
method: 'PATCH',
body: JSON.stringify(patch),
})
}
/** Resolves to null when the hunter is absent *or* has veiled their profile —
* the backend 404s for both on purpose, and the page says so in-fiction
* instead of surfacing a status code. */
export async function fetchHunter(
username: string,
signal?: AbortSignal,
): Promise<PublicHunter | null> {
const res = await fetch(`/api/hunters/${encodeURIComponent(username)}`, {
credentials: 'include',
signal,
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`the roll answered ${res.status}`)
return (await res.json()) as PublicHunter
}
export async function fetchHunters(signal?: AbortSignal): Promise<RosterHunter[]> {
const data = await request<{ hunters: RosterHunter[] }>('/api/hunters', { signal })
return data.hunters ?? []
}
/** Public name for a hunter, falling back to their username. */
export function hunterName(h: { display_name: string | null; username: string }): string {
return h.display_name?.trim() || h.username
}

View File

@@ -18,7 +18,8 @@
"seance": "séance",
"enter": "enter",
"logout": "depart",
"log": "log"
"log": "log",
"hunters": "hunters"
},
"log": {
"title": "GHOST LOG",
@@ -620,5 +621,115 @@
"idle": "the veil is quiet — no presence detected",
"waiting": "listening for tells…",
"meterLabel": "evil meter"
},
"profile": {
"back": "← the table",
"title": "YOUR SIGIL",
"subtitle": "how the veil will remember you",
"loading": "reading your sigil…",
"loadError": "your sigil will not resolve — the glass is clouded",
"save": "seal it",
"saving": "sealing…",
"saved": "sealed — the veil has taken it",
"saveError": "the veil refused the change",
"viewPublic": "see what others see",
"rankLine": "{{title}} · rank {{level}}",
"titles": {
"curious": "curious",
"sensitive": "sensitive",
"channeler": "channeler",
"medium": "medium",
"adept": "adept",
"oracle": "oracle"
},
"wanderer": {
"body": "you drift here without a name. wanderers may look, but the veil forgets them at dawn — claim a name and your sigil will hold.",
"claim": "claim a name"
},
"avatar": {
"heading": "your shape",
"note": "no likeness is taken. your sigil is drawn from a form and a colour, the same way the dead are drawn.",
"formLabel": "choose a form",
"hueLabel": "colour · {{hue}}°"
},
"identity": {
"heading": "your name",
"nameLabel": "name others will read",
"nameHint": "leave it blank and the veil will use your true name",
"nameTooLong": "no more than {{max}} characters",
"bioLabel": "a few words on yourself",
"bioCount": "{{remaining}} of {{max}} left",
"bioTooLong": "no more than {{max}} characters"
},
"gender": {
"label": "how you are addressed",
"male": "he",
"female": "she",
"unspecified": "unspoken"
},
"veil": {
"heading": "the veil over you",
"public": "visible to other seekers",
"private": "veiled",
"publicNote": "your sigil, rank and recent contacts can be read by anyone who knows your name.",
"privateNote": "no one will find you on the roll. your séances continue unchanged."
},
"email": {
"label": "a way back (optional)",
"note": "for recovering your account only. never shown on your page, never given to another seeker, never used to reach you otherwise.",
"invalid": "that address will not carry a message"
}
},
"hunters": {
"back": "← the table",
"backToRoster": "← the roll",
"title": "THE ROLL",
"subtitle": "every seeker who left their name on the veil",
"loading": "calling the roll…",
"empty": "no one has left a name yet",
"card": {
"encounters_one": "{{count}} spirit reached",
"encounters_other": "{{count}} spirits reached"
},
"profile": {
"loading": "tracing their sigil…",
"veiledTitle": "no trace of them",
"veiledBody": "the veil holds no record of “{{username}}”. either the name was never claimed, or they have drawn the veil over themselves.",
"progressLabel": "progress toward the next rank",
"progressNext_one": "{{count}} more spirit to rise",
"progressNext_other": "{{count}} more spirits to rise",
"progressMax": "as far as the veil goes",
"encounters": "spirits reached",
"joined": "first crossed",
"recentTitle": "recently reached",
"recentEmpty": "they have reached no one yet",
"sendMessage": "send word"
}
},
"messages": {
"title": "whispers between hunters",
"findHunters": "find hunters",
"emptyInbox": "no one has whispered to you yet. find a hunter whose codex you admire, and say something.",
"emptyThread": "nothing has passed between you yet.",
"needAccount": "whispers need a name. a wanderer's voice does not carry.",
"claimName": "claim a name",
"backToInbox": "back to the whispers",
"noSuchHunter": "no such hunter walks here.",
"placeholder": "speak, and it will carry…",
"send": "send",
"sending": "sending…",
"sendFailed": "the whisper did not carry. try again.",
"guestBlocked": "a wanderer cannot be heard. claim a name and your whispers will carry.",
"youPrefix": "you: ",
"unread": "unread whispers",
"time": {
"now": "just now",
"minutes_one": "{{count}} minute past",
"minutes_other": "{{count}} minutes past",
"hours_one": "{{count}} hour past",
"hours_other": "{{count}} hours past",
"days_one": "{{count}} day past",
"days_other": "{{count}} days past"
}
}
}

View File

@@ -18,7 +18,8 @@
"seance": "sesión",
"enter": "entrar",
"logout": "partir",
"log": "registro"
"log": "registro",
"hunters": "lista"
},
"log": {
"title": "REGISTRO ESPECTRAL",
@@ -620,5 +621,115 @@
"idle": "el velo está en silencio — no se detecta presencia",
"waiting": "escuchando indicios…",
"meterLabel": "medidor de maldad"
},
"profile": {
"back": "← la mesa",
"title": "TU SIGILO",
"subtitle": "cómo el velo te recordará",
"loading": "leyendo tu sigilo…",
"loadError": "tu sigilo no se aclara — el cristal está nublado",
"save": "séllalo",
"saving": "sellando…",
"saved": "sellado — el velo lo ha tomado",
"saveError": "el velo rechazó el cambio",
"viewPublic": "mira lo que otros ven",
"rankLine": "{{title}} · rango {{level}}",
"titles": {
"curious": "curioso",
"sensitive": "sensitivo",
"channeler": "canalizador",
"medium": "médium",
"adept": "adepto",
"oracle": "oráculo"
},
"wanderer": {
"body": "vagas aquí sin nombre. a los errantes se les permite mirar, pero el velo los olvida al amanecer — reclama un nombre y tu sigilo perdurará.",
"claim": "reclamar un nombre"
},
"avatar": {
"heading": "tu forma",
"note": "no se toma ninguna imagen. tu sigilo se traza con una forma y un color, igual que se trazan los muertos.",
"formLabel": "elige una forma",
"hueLabel": "color · {{hue}}°"
},
"identity": {
"heading": "tu nombre",
"nameLabel": "nombre que otros leerán",
"nameHint": "déjalo vacío y el velo usará tu nombre verdadero",
"nameTooLong": "no más de {{max}} caracteres",
"bioLabel": "unas palabras sobre ti",
"bioCount": "quedan {{remaining}} de {{max}}",
"bioTooLong": "no más de {{max}} caracteres"
},
"gender": {
"label": "cómo te nombran",
"male": "él",
"female": "ella",
"unspecified": "sin decir"
},
"veil": {
"heading": "el velo sobre ti",
"public": "visible a otros buscadores",
"private": "velado",
"publicNote": "tu sigilo, tu rango y tus contactos recientes podrán leerse por quien sepa tu nombre.",
"privateNote": "nadie te hallará en la lista. tus sesiones siguen igual."
},
"email": {
"label": "un camino de vuelta (opcional)",
"note": "sólo para recuperar tu cuenta. nunca se muestra en tu página, nunca se da a otro buscador, nunca se usa para nada más.",
"invalid": "esa dirección no llevará ningún mensaje"
}
},
"hunters": {
"back": "← la mesa",
"backToRoster": "← la lista",
"title": "LA LISTA",
"subtitle": "todo buscador que dejó su nombre en el velo",
"loading": "pasando lista…",
"empty": "nadie ha dejado su nombre aún",
"card": {
"encounters_one": "{{count}} espíritu alcanzado",
"encounters_other": "{{count}} espíritus alcanzados"
},
"profile": {
"loading": "trazando su sigilo…",
"veiledTitle": "ningún rastro de ellos",
"veiledBody": "el velo no guarda registro de «{{username}}». o el nombre nunca fue reclamado, o han corrido el velo sobre sí mismos.",
"progressLabel": "avance hacia el siguiente rango",
"progressNext_one": "{{count}} espíritu más para ascender",
"progressNext_other": "{{count}} espíritus más para ascender",
"progressMax": "hasta donde llega el velo",
"encounters": "espíritus alcanzados",
"joined": "primer cruce",
"recentTitle": "alcanzados hace poco",
"recentEmpty": "aún no han alcanzado a nadie",
"sendMessage": "enviar palabra"
}
},
"messages": {
"title": "susurros entre cazadores",
"findHunters": "buscar cazadores",
"emptyInbox": "nadie te ha susurrado aún. busca a un cazador cuyo códice admires, y di algo.",
"emptyThread": "nada ha pasado entre ustedes todavía.",
"needAccount": "los susurros necesitan un nombre. la voz de un errante no llega lejos.",
"claimName": "reclama un nombre",
"backToInbox": "volver a los susurros",
"noSuchHunter": "ningún cazador camina aquí.",
"placeholder": "habla, y llegará…",
"send": "enviar",
"sending": "enviando…",
"sendFailed": "el susurro no llegó. inténtalo de nuevo.",
"guestBlocked": "un errante no puede ser oído. reclama un nombre y tus susurros llegarán.",
"youPrefix": "tú: ",
"unread": "susurros sin leer",
"time": {
"now": "ahora mismo",
"minutes_one": "hace {{count}} minuto",
"minutes_other": "hace {{count}} minutos",
"hours_one": "hace {{count}} hora",
"hours_other": "hace {{count}} horas",
"days_one": "hace {{count}} día",
"days_other": "hace {{count}} días"
}
}
}

View File

@@ -0,0 +1,56 @@
// Label helpers shared by the three hunter pages.
//
// These deliberately use *literal* t() keys inside a switch rather than a
// computed `t(\`profile.titles.${x}\`)` template: the i18n coverage checker
// only tracks literal keys without a hand-written domain rule, and rank
// titles are decided by the backend, so a literal switch keeps the gate
// honest and falls back to the raw word if P ever adds a band.
import type { TFunction } from 'i18next'
import type { Gender } from '../api'
/** Rank bands from backend rank.py, lowest to highest. */
export const RANK_TITLES = [
'curious',
'sensitive',
'channeler',
'medium',
'adept',
'oracle',
] as const
export function rankTitle(t: TFunction, title: string): string {
switch (title) {
case 'curious':
return t('profile.titles.curious')
case 'sensitive':
return t('profile.titles.sensitive')
case 'channeler':
return t('profile.titles.channeler')
case 'medium':
return t('profile.titles.medium')
case 'adept':
return t('profile.titles.adept')
case 'oracle':
return t('profile.titles.oracle')
default:
return title
}
}
export function genderLabel(t: TFunction, gender: Gender): string {
switch (gender) {
case 'male':
return t('profile.gender.male')
case 'female':
return t('profile.gender.female')
default:
return t('profile.gender.unspecified')
}
}
/** Clamps a rank progress fraction into 0-100 for a bar width. */
export function progressPercent(progress: number): number {
if (!Number.isFinite(progress)) return 0
return Math.round(Math.min(1, Math.max(0, progress)) * 100)
}

View File

@@ -0,0 +1,387 @@
/* A hunter's public page. Same tokens as the Codex, and the entity cards
deliberately reuse the Codex card look so the grid reads as one registry. */
.hunter-page {
min-height: 100vh;
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
}
.hunter-topbar {
max-width: 1100px;
margin: 0 auto 1.2rem;
}
.hunter-back,
.hunter-missing-link {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
transition: color 160ms ease;
}
.hunter-back:hover,
.hunter-back:focus-visible,
.hunter-missing-link:hover,
.hunter-missing-link:focus-visible {
color: #7cffb2;
}
/* ---- states ---- */
.hunter-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 3rem 0;
animation: hunter-breathe 2.4s ease-in-out infinite;
}
.hunter-state-error {
color: #ff3b5c;
animation: none;
}
.hunter-missing {
max-width: 560px;
margin: 3rem auto;
text-align: center;
padding: 2rem 1.4rem;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(178, 107, 255, 0.3);
border-radius: 10px;
}
.hunter-missing-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.3rem, 5vw, 1.9rem);
letter-spacing: 0.16em;
margin: 0 0 0.8rem;
color: #eef7f0;
}
.hunter-missing-body {
margin: 0 0 1.2rem;
font-size: 0.82rem;
line-height: 1.7;
font-style: italic;
color: rgba(216, 230, 220, 0.6);
}
/* ---- hero ---- */
.hunter-hero {
max-width: 620px;
margin: 0 auto 3rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 12px;
padding: 2rem clamp(0.9rem, 4vw, 2rem);
}
.hunter-glyph .ghost-glyph {
animation: hunter-drift 7s ease-in-out infinite;
}
.hunter-name {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.6rem, 6vw, 2.4rem);
letter-spacing: 0.14em;
margin: 0.5rem 0 0;
color: #eef7f0;
text-shadow: 0 0 18px rgba(124, 255, 178, 0.28);
}
.hunter-handle {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.16em;
color: rgba(216, 230, 220, 0.4);
}
.hunter-rank {
margin: 0.6rem 0 0;
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(178, 107, 255, 0.9);
}
.hunter-progress {
width: 100%;
max-width: 340px;
height: 8px;
border-radius: 999px;
background: rgba(124, 255, 178, 0.12);
border: 1px solid rgba(124, 255, 178, 0.18);
overflow: hidden;
}
.hunter-progress-fill {
height: 100%;
background: linear-gradient(90deg, #7cffb2, #b26bff);
box-shadow: 0 0 12px rgba(124, 255, 178, 0.45);
transition: width 400ms ease;
}
.hunter-progress-note {
margin: 0;
font-size: 0.66rem;
letter-spacing: 0.1em;
color: rgba(216, 230, 220, 0.45);
}
.hunter-stats {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.4rem;
margin: 1rem 0 0;
}
.hunter-stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.hunter-stat dt {
font-size: 0.6rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
.hunter-stat dd {
margin: 0;
font-family: 'Cinzel', Georgia, serif;
font-size: 1.1rem;
color: #7cffb2;
}
.hunter-bio {
margin: 1.2rem 0 0;
font-family: 'Cinzel', Georgia, serif;
font-style: italic;
font-size: 0.92rem;
line-height: 1.65;
color: rgba(216, 230, 220, 0.8);
border-left: 2px solid rgba(178, 107, 255, 0.4);
padding-left: 0.9rem;
text-align: left;
}
.hunter-message-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: 1.4rem;
padding: 0 1.6rem;
font-size: 0.74rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #07070d;
background: #7cffb2;
border-radius: 999px;
text-decoration: none;
transition: box-shadow 160ms ease;
}
.hunter-message-link:hover,
.hunter-message-link:focus-visible {
box-shadow: 0 0 20px rgba(124, 255, 178, 0.5);
}
/* ---- recent entities (Codex card styling) ---- */
.hunter-recent {
max-width: 1100px;
margin: 0 auto;
}
.hunter-section-title {
font-family: 'Cinzel', Georgia, serif;
font-size: 0.92rem;
letter-spacing: 0.24em;
text-transform: uppercase;
text-align: center;
color: rgba(124, 255, 178, 0.8);
margin: 0 0 1.4rem;
}
.hunter-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.1rem;
}
.hunter-card-item {
display: flex;
}
.hunter-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 10px;
padding: 1.2rem 0.9rem;
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.hunter-card:hover,
.hunter-card:focus-visible {
outline: none;
transform: translateY(-3px);
border-color: rgba(124, 255, 178, 0.5);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(124, 255, 178, 0.14);
}
.hunter-card[data-rarity='rare']:hover,
.hunter-card[data-rarity='rare']:focus-visible {
border-color: rgba(178, 107, 255, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(178, 107, 255, 0.2);
}
.hunter-card[data-rarity='mythic']:hover,
.hunter-card[data-rarity='mythic']:focus-visible {
border-color: rgba(255, 157, 74, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 28px rgba(255, 157, 74, 0.22);
}
.hunter-card-glyph-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.7rem;
}
.hunter-card-glyph {
width: 68px;
height: 68px;
border-radius: 50%;
background: radial-gradient(
circle at 50% 42%,
hsl(var(--glyph-hue), 82%, 74%) 0%,
hsl(var(--glyph-hue), 80%, 65%) 24%,
hsla(var(--glyph-hue), 80%, 55%, 0.32) 50%,
transparent 72%
);
filter: blur(1.5px);
opacity: 0.9;
animation: hunter-drift 7s ease-in-out infinite;
}
.hunter-card-form {
font-size: 0.58rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: rgba(216, 230, 220, 0.4);
}
.hunter-card-name {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.05rem;
letter-spacing: 0.08em;
margin: 0;
color: #eef7f0;
}
.hunter-card-epithet {
margin: 0.25rem 0 0.5rem;
font-size: 0.68rem;
font-style: italic;
color: rgba(178, 107, 255, 0.75);
}
.hunter-rarity {
font-size: 0.56rem;
letter-spacing: 0.26em;
text-transform: uppercase;
padding: 0.2rem 0.65rem;
border: 1px solid;
border-radius: 999px;
}
.hunter-rarity[data-rarity='common'] {
color: #8a8f98;
border-color: rgba(138, 143, 152, 0.45);
}
.hunter-rarity[data-rarity='uncommon'] {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.hunter-rarity[data-rarity='rare'] {
color: #b26bff;
border-color: rgba(178, 107, 255, 0.5);
}
.hunter-rarity[data-rarity='mythic'] {
color: #ff9d4a;
border-color: rgba(255, 157, 74, 0.55);
}
/* ---- motion ---- */
@keyframes hunter-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@keyframes hunter-drift {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-6px) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.hunter-state,
.hunter-glyph .ghost-glyph,
.hunter-card-glyph {
animation: none;
}
.hunter-card,
.hunter-progress-fill,
.hunter-message-link,
.hunter-back {
transition: none;
}
}

View File

@@ -0,0 +1,210 @@
// A hunter's public page — /hunters/:username. Reads GET /api/hunters/{u},
// which 404s both for names that were never claimed and for seekers who have
// veiled themselves; both arrive here as one in-fiction "no trace" state.
import { useEffect, useState, type CSSProperties } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { GhostGlyph } from '../components/GhostGlyph'
import { usePageMeta } from '../lib/pageMeta'
import { genderLabel, progressPercent, rankTitle } from '../lib/hunterLabels'
import * as api from '../api'
import { hunterName } from '../api'
import type { PublicHunter } from '../api'
import type { GhostForm } from '../lib/types'
import './HunterPage.css'
type LoadState = 'loading' | 'error' | 'missing' | 'ready'
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'
}
function formatDate(iso: string | null, locale: string): string | null {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
export function HunterPage() {
const { t, i18n } = useTranslation()
const { username = '' } = useParams()
const [hunter, setHunter] = useState<PublicHunter | null>(null)
const [state, setState] = useState<LoadState>('loading')
usePageMeta({
title: `${username} — a hunter of the veil`,
description: `The public record of ${username}: rank, encounters, and the spirits they have reached through Quantumancy.`,
path: `/hunters/${username}`,
})
useEffect(() => {
if (!username) {
setState('missing')
return
}
const controller = new AbortController()
setState('loading')
api
.fetchHunter(username, controller.signal)
.then((data) => {
if (!data) {
setState('missing')
return
}
setHunter(data)
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [username])
const joined = formatDate(hunter?.joined_at ?? null, i18n.language)
return (
<div className="hunter-page">
<header className="hunter-topbar">
<Link to="/hunters" className="hunter-back">
{t('hunters.backToRoster')}
</Link>
</header>
{state === 'loading' && (
<p className="hunter-state" role="status">
{t('hunters.profile.loading')}
</p>
)}
{state === 'error' && (
<p className="hunter-state hunter-state-error" role="alert">
{t('common.error')}
</p>
)}
{state === 'missing' && (
<div className="hunter-missing" role="status">
<h1 className="hunter-missing-title">{t('hunters.profile.veiledTitle')}</h1>
<p className="hunter-missing-body">
{t('hunters.profile.veiledBody', { username })}
</p>
<Link to="/hunters" className="hunter-missing-link">
{t('hunters.backToRoster')}
</Link>
</div>
)}
{state === 'ready' && hunter && (
<>
<section className="hunter-hero">
<div className="hunter-glyph">
<GhostGlyph
hue={hunter.avatar_hue ?? 150}
form={asForm(hunter.avatar_form)}
size={148}
/>
</div>
<h1 className="hunter-name">{hunterName(hunter)}</h1>
<p className="hunter-handle">@{hunter.username}</p>
<p className="hunter-rank">
{t('profile.rankLine', {
title: rankTitle(t, hunter.rank.title),
level: hunter.rank.level,
})}
</p>
<div
className="hunter-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progressPercent(hunter.rank.progress)}
aria-label={t('hunters.profile.progressLabel')}
>
<div
className="hunter-progress-fill"
style={{ width: `${progressPercent(hunter.rank.progress)}%` }}
/>
</div>
<p className="hunter-progress-note">
{hunter.rank.next_at == null
? t('hunters.profile.progressMax')
: t('hunters.profile.progressNext', {
remaining: Math.max(0, hunter.rank.next_at - hunter.rank.encounters),
})}
</p>
<dl className="hunter-stats">
<div className="hunter-stat">
<dt>{t('hunters.profile.encounters')}</dt>
<dd>{hunter.rank.encounters}</dd>
</div>
{hunter.gender && (
<div className="hunter-stat">
<dt>{t('profile.gender.label')}</dt>
<dd>{genderLabel(t, hunter.gender)}</dd>
</div>
)}
{joined && (
<div className="hunter-stat">
<dt>{t('hunters.profile.joined')}</dt>
<dd>{joined}</dd>
</div>
)}
</dl>
{hunter.bio && <blockquote className="hunter-bio">{hunter.bio}</blockquote>}
{/* Workstream S owns /messages/:username — this is only the door. */}
<Link to={`/messages/${hunter.username}`} className="hunter-message-link">
{t('hunters.profile.sendMessage')}
</Link>
</section>
<section className="hunter-recent">
<h2 className="hunter-section-title">{t('hunters.profile.recentTitle')}</h2>
{hunter.recent_entities.length === 0 ? (
<p className="hunter-state">{t('hunters.profile.recentEmpty')}</p>
) : (
<ul className="hunter-grid">
{hunter.recent_entities.map((entity) => (
<li key={entity.id} className="hunter-card-item">
<Link
to={`/codex/${entity.id}`}
className="hunter-card"
data-rarity={entity.rarity}
>
<div className="hunter-card-glyph-wrap">
<div
className="hunter-card-glyph"
style={
{ '--glyph-hue': entity.visual.hue ?? 150 } as CSSProperties
}
aria-hidden="true"
/>
<span className="hunter-card-form">
{t(`codex.form.${asForm(entity.visual.form)}`)}
</span>
</div>
<h3 className="hunter-card-name">{entity.name}</h3>
<p className="hunter-card-epithet">{entity.epithet}</p>
<span className="hunter-rarity" data-rarity={entity.rarity}>
{t(`codex.rarity.${entity.rarity}`, entity.rarity)}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,179 @@
/* The roll of hunters — roster grid. Codex tokens throughout. */
.hunters-page {
min-height: 100vh;
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
}
.hunters-header {
max-width: 760px;
margin: 0 auto 2.4rem;
text-align: center;
}
.hunters-back {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
transition: color 160ms ease;
}
.hunters-back:hover,
.hunters-back:focus-visible {
color: #7cffb2;
}
.hunters-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.9rem, 6vw, 3.2rem);
letter-spacing: 0.2em;
margin: 0.4rem 0 0.5rem;
color: #eef7f0;
text-shadow: 0 0 18px rgba(124, 255, 178, 0.32), 0 0 46px rgba(178, 107, 255, 0.24);
}
.hunters-subtitle {
margin: 0;
font-size: 0.78rem;
font-style: italic;
color: rgba(216, 230, 220, 0.55);
}
.hunters-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 3.5rem 0;
animation: hunters-breathe 2.4s ease-in-out infinite;
}
.hunters-state-error {
color: #ff3b5c;
animation: none;
}
.hunters-grid {
list-style: none;
margin: 0 auto;
padding: 0;
max-width: 1180px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.2rem;
}
.hunters-card-item {
display: flex;
}
.hunters-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.3rem;
min-height: 44px;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 10px;
padding: 1.4rem 0.9rem 1.2rem;
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.hunters-card:hover,
.hunters-card:focus-visible {
outline: none;
transform: translateY(-3px);
border-color: rgba(124, 255, 178, 0.5);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(124, 255, 178, 0.14);
}
.hunters-card .ghost-glyph {
animation: hunters-drift 7s ease-in-out infinite;
}
.hunters-card-name {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.05rem;
letter-spacing: 0.08em;
margin: 0.5rem 0 0;
color: #eef7f0;
}
.hunters-card-rank {
margin: 0;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(178, 107, 255, 0.85);
}
.hunters-card-bar {
width: 100%;
height: 5px;
margin: 0.5rem 0 0.3rem;
border-radius: 999px;
background: rgba(124, 255, 178, 0.12);
overflow: hidden;
}
.hunters-card-bar-fill {
height: 100%;
background: linear-gradient(90deg, #7cffb2, #b26bff);
transition: width 400ms ease;
}
.hunters-card-meta {
margin: 0;
font-size: 0.64rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.55);
}
@keyframes hunters-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@keyframes hunters-drift {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@media (prefers-reduced-motion: reduce) {
.hunters-state,
.hunters-card .ghost-glyph {
animation: none;
}
.hunters-card,
.hunters-card-bar-fill,
.hunters-back {
transition: none;
}
}

View File

@@ -0,0 +1,107 @@
// The roll of hunters — /hunters. A roster of public profiles from
// GET /api/hunters, each card a door to /hunters/:username.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { GhostGlyph } from '../components/GhostGlyph'
import { usePageMeta } from '../lib/pageMeta'
import { progressPercent, rankTitle } from '../lib/hunterLabels'
import * as api from '../api'
import { hunterName } from '../api'
import type { RosterHunter } from '../api'
import type { GhostForm } from '../lib/types'
import './HuntersPage.css'
type LoadState = 'loading' | 'error' | 'ready'
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'
}
export function HuntersPage() {
const { t } = useTranslation()
const [hunters, setHunters] = useState<RosterHunter[]>([])
const [state, setState] = useState<LoadState>('loading')
usePageMeta({
title: 'The Roll of Hunters',
description:
'Every seeker who has left their name on the veil — their rank, their title, and the spirits they have reached.',
path: '/hunters',
})
useEffect(() => {
const controller = new AbortController()
api
.fetchHunters(controller.signal)
.then((rows) => {
setHunters(rows)
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [])
return (
<div className="hunters-page">
<header className="hunters-header">
<Link to="/seance" className="hunters-back">
{t('hunters.back')}
</Link>
<h1 className="hunters-title">{t('hunters.title')}</h1>
<p className="hunters-subtitle">{t('hunters.subtitle')}</p>
</header>
{state === 'loading' && (
<p className="hunters-state" role="status">
{t('hunters.loading')}
</p>
)}
{state === 'error' && (
<p className="hunters-state hunters-state-error" role="alert">
{t('common.error')}
</p>
)}
{state === 'ready' && hunters.length === 0 && (
<p className="hunters-state">{t('hunters.empty')}</p>
)}
{state === 'ready' && hunters.length > 0 && (
<ul className="hunters-grid">
{hunters.map((hunter) => (
<li key={hunter.username} className="hunters-card-item">
<Link to={`/hunters/${hunter.username}`} className="hunters-card">
<GhostGlyph
hue={hunter.avatar_hue ?? 150}
form={asForm(hunter.avatar_form)}
size={72}
/>
<h2 className="hunters-card-name">{hunterName(hunter)}</h2>
<p className="hunters-card-rank">
{t('profile.rankLine', {
title: rankTitle(t, hunter.rank.title),
level: hunter.rank.level,
})}
</p>
<div className="hunters-card-bar" aria-hidden="true">
<div
className="hunters-card-bar-fill"
style={{ width: `${progressPercent(hunter.rank.progress)}%` }}
/>
</div>
<p className="hunters-card-meta">
{t('hunters.card.encounters', { count: hunter.rank.encounters })}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,154 @@
/* Hunter messages — SeancePage token palette. */
.msg-page {
min-height: 100vh;
padding: 1.4rem 1rem 2rem;
max-width: 720px;
margin: 0 auto;
color: #d3e9db;
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
}
.msg-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.msg-title {
font-family: 'Cinzel', serif;
font-size: 1.3rem;
letter-spacing: 0.14em;
color: #7cffb2;
text-shadow: 0 0 14px rgba(124, 255, 178, 0.4);
margin: 0;
}
.msg-back {
color: #b26bff;
text-decoration: none;
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.msg-back:hover { text-shadow: 0 0 12px rgba(178, 107, 255, 0.8); }
.msg-empty {
opacity: 0.6;
font-size: 0.85rem;
line-height: 1.6;
}
.msg-list { list-style: none; margin: 0; padding: 0; }
.msg-row {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.7rem 0.6rem;
/* 44px floor: these are the primary tap targets on this screen. */
min-height: 44px;
border-bottom: 1px solid rgba(124, 255, 178, 0.12);
text-decoration: none;
color: inherit;
}
.msg-row:hover { background: rgba(124, 255, 178, 0.05); }
.msg-row-glyph { flex-shrink: 0; line-height: 0; }
.msg-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.msg-row-top { display: flex; justify-content: space-between; gap: 0.6rem; }
.msg-row-name { color: #7cffb2; font-size: 0.85rem; }
.msg-row-when { font-size: 0.7rem; white-space: nowrap; }
.msg-row-last {
font-size: 0.76rem;
/* One line only — a long message must not push the row tall. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-unread {
flex-shrink: 0;
min-width: 20px;
padding: 0.1rem 0.35rem;
border-radius: 999px;
background: rgba(178, 107, 255, 0.25);
color: #ecdcff;
font-size: 0.7rem;
text-align: center;
}
/* ---- thread ---- */
.msg-thread-page { display: flex; flex-direction: column; }
.msg-thread-who { display: flex; align-items: center; gap: 0.5rem; }
.msg-thread-name { color: #7cffb2; text-decoration: none; font-size: 0.9rem; }
.msg-thread {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0 1rem;
overflow-y: auto;
}
.msg-bubble {
max-width: 82%;
padding: 0.5rem 0.7rem;
border-radius: 4px;
border: 1px solid rgba(124, 255, 178, 0.18);
background: rgba(16, 16, 26, 0.85);
}
.msg-bubble.is-mine {
align-self: flex-end;
border-color: rgba(178, 107, 255, 0.4);
}
.msg-bubble.is-theirs { align-self: flex-start; }
.msg-bubble-body {
margin: 0 0 0.2rem;
font-size: 0.84rem;
line-height: 1.5;
/* Arbitrary text from another hunter: wrap it rather than let a long
unbroken string blow out the layout. */
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.msg-bubble-when { font-size: 0.66rem; }
.msg-compose { display: flex; flex-direction: column; gap: 0.4rem; }
.msg-input {
width: 100%;
resize: vertical;
background: rgba(16, 16, 26, 0.9);
border: 1px solid rgba(124, 255, 178, 0.25);
border-radius: 3px;
color: #d3e9db;
font-family: inherit;
/* 16px exactly: iOS Safari zooms the page for anything smaller and
leaves it zoomed after blur. */
font-size: 16px;
padding: 0.5rem;
}
.msg-input:focus { outline: 1px solid rgba(124, 255, 178, 0.6); }
.msg-compose-row { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.msg-count { font-size: 0.7rem; }
.msg-error { color: #ff8fa3; font-size: 0.78rem; margin: 0; }
@media (max-width: 560px) {
.msg-compose-row button { min-height: 44px; flex: 1 1 auto; }
.msg-bubble { max-width: 92%; }
}

View File

@@ -0,0 +1,323 @@
// Hunter messages: a conversation list at /messages, one thread at
// /messages/:username. Deliberately plain — text only, no attachments, no
// editing. The point is that hunters can reach each other, not that this
// becomes a chat product.
//
// Message bodies are rendered as text nodes, never as HTML. They are
// arbitrary strings written by other people, so the only safe treatment is
// no interpretation at all.
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { GhostGlyph } from '../components/GhostGlyph'
import { usePageMeta } from '../lib/pageMeta'
import type { GhostForm } from '../lib/types'
import './MessagesPage.css'
const BODY_MAX = 1000
type Avatar = { form: string; hue: number }
type Hunter = {
username: string
display_name: string
avatar: Avatar
profile_public: boolean
is_wanderer: boolean
}
type Conversation = {
hunter: Hunter
last_body: string
last_at: string
last_from_me: boolean
unread: number
}
type ThreadMessage = {
id: string
body: string
from_me: boolean
created_at: string
read_at: string | null
}
function glyphOf(avatar: Avatar, size: number) {
const forms: GhostForm[] = ['wisp', 'banshee', 'fairy', 'shade']
const form = (forms as string[]).includes(avatar.form)
? (avatar.form as GhostForm)
: 'wisp'
return <GhostGlyph hue={avatar.hue} form={form} size={size} />
}
/** In-fiction relative time; exact clock times would break the register. */
function whenAgo(iso: string, t: (k: string, o?: Record<string, unknown>) => string): string {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
if (mins < 1) return t('messages.time.now')
if (mins < 60) return t('messages.time.minutes', { count: mins })
const hours = Math.round(mins / 60)
if (hours < 24) return t('messages.time.hours', { count: hours })
return t('messages.time.days', { count: Math.round(hours / 24) })
}
export function MessagesPage() {
const { username } = useParams<{ username?: string }>()
return username ? <Thread username={username} /> : <Inbox />
}
export default MessagesPage
function Inbox() {
const { t } = useTranslation()
const { user, checking } = useAuth()
const [state, setState] = useState<
{ s: 'loading' } | { s: 'ready'; rows: Conversation[] } | { s: 'error' }
>({ s: 'loading' })
usePageMeta({ title: t('messages.title'), path: '/messages' })
useEffect(() => {
if (checking || !user) return
const controller = new AbortController()
fetch('/api/messages', { credentials: 'include', signal: controller.signal })
.then((r) => {
if (!r.ok) throw new Error(String(r.status))
return r.json() as Promise<{ conversations: Conversation[] }>
})
.then((d) => setState({ s: 'ready', rows: d.conversations ?? [] }))
.catch(() => {
if (!controller.signal.aborted) setState({ s: 'error' })
})
return () => controller.abort()
}, [checking, user])
if (checking) return <div className="msg-page" />
if (!user) {
return (
<div className="msg-page">
<p className="msg-empty">{t('messages.needAccount')}</p>
<Link className="msg-back" to="/enter">
{t('messages.claimName')}
</Link>
</div>
)
}
return (
<div className="msg-page">
<header className="msg-header">
<h1 className="msg-title">{t('messages.title')}</h1>
<Link className="msg-back" to="/hunters">
{t('messages.findHunters')}
</Link>
</header>
{state.s === 'error' && <p className="msg-empty">{t('common.error')}</p>}
{state.s === 'ready' && state.rows.length === 0 && (
<p className="msg-empty">{t('messages.emptyInbox')}</p>
)}
{state.s === 'ready' && state.rows.length > 0 && (
<ul className="msg-list">
{state.rows.map((c) => (
<li key={c.hunter.username}>
<Link className="msg-row" to={`/messages/${c.hunter.username}`}>
<span className="msg-row-glyph" aria-hidden>
{glyphOf(c.hunter.avatar, 34)}
</span>
<span className="msg-row-body">
<span className="msg-row-top">
<span className="msg-row-name">{c.hunter.display_name}</span>
<span className="msg-row-when dim">{whenAgo(c.last_at, t)}</span>
</span>
<span className="msg-row-last dim">
{c.last_from_me ? t('messages.youPrefix') : ''}
{c.last_body}
</span>
</span>
{c.unread > 0 && (
<span className="msg-unread" aria-label={t('messages.unread')}>
{c.unread}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)
}
function Thread({ username }: { username: string }) {
const { t } = useTranslation()
const { user, checking } = useAuth()
const [hunter, setHunter] = useState<Hunter | null>(null)
const [messages, setMessages] = useState<ThreadMessage[] | null>(null)
const [canSend, setCanSend] = useState(true)
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const endRef = useRef<HTMLDivElement | null>(null)
usePageMeta({ title: hunter?.display_name ?? username, path: `/messages/${username}` })
const load = useCallback(
(signal?: AbortSignal) =>
fetch(`/api/messages/${encodeURIComponent(username)}`, {
credentials: 'include',
signal,
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status))
return r.json() as Promise<{
hunter: Hunter
messages: ThreadMessage[]
can_send: boolean
}>
})
.then((d) => {
setHunter(d.hunter)
setMessages(d.messages ?? [])
setCanSend(d.can_send)
}),
[username],
)
useEffect(() => {
if (checking || !user) return
const controller = new AbortController()
load(controller.signal).catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort()
}, [checking, user, load])
// Keep the newest message in view as the thread grows.
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' })
}, [messages])
const send = async () => {
const body = draft.trim()
if (!body || sending) return
setSending(true)
setError(null)
try {
const res = await fetch('/api/messages', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: username, body }),
})
if (!res.ok) {
// 403 is the guest case and deserves its own copy — everything else
// is a generic failure the seeker can simply retry.
setError(res.status === 403 ? t('messages.guestBlocked') : t('messages.sendFailed'))
return
}
setDraft('')
await load()
} catch {
setError(t('messages.sendFailed'))
} finally {
setSending(false)
}
}
if (checking) return <div className="msg-page" />
if (!user) {
return (
<div className="msg-page">
<p className="msg-empty">{t('messages.needAccount')}</p>
<Link className="msg-back" to="/enter">
{t('messages.claimName')}
</Link>
</div>
)
}
if (failed) {
return (
<div className="msg-page">
<p className="msg-empty">{t('messages.noSuchHunter')}</p>
<Link className="msg-back" to="/messages">
{t('messages.backToInbox')}
</Link>
</div>
)
}
return (
<div className="msg-page msg-thread-page">
<header className="msg-header">
<Link className="msg-back" to="/messages">
{t('messages.backToInbox')}
</Link>
{hunter && (
<div className="msg-thread-who">
<span aria-hidden>{glyphOf(hunter.avatar, 30)}</span>
{hunter.profile_public ? (
<Link className="msg-thread-name" to={`/hunters/${hunter.username}`}>
{hunter.display_name}
</Link>
) : (
<span className="msg-thread-name">{hunter.display_name}</span>
)}
</div>
)}
</header>
<div className="msg-thread" role="log" aria-live="polite">
{messages?.length === 0 && <p className="msg-empty">{t('messages.emptyThread')}</p>}
{messages?.map((m) => (
<div key={m.id} className={`msg-bubble ${m.from_me ? 'is-mine' : 'is-theirs'}`}>
{/* Text node: message bodies are never interpreted as markup. */}
<p className="msg-bubble-body">{m.body}</p>
<span className="msg-bubble-when dim">{whenAgo(m.created_at, t)}</span>
</div>
))}
<div ref={endRef} />
</div>
{canSend ? (
<form
className="msg-compose"
onSubmit={(e) => {
e.preventDefault()
void send()
}}
>
<textarea
className="msg-input"
value={draft}
onChange={(e) => setDraft(e.target.value.slice(0, BODY_MAX))}
placeholder={t('messages.placeholder')}
rows={2}
aria-label={t('messages.placeholder')}
/>
<div className="msg-compose-row">
<span className="msg-count dim">
{draft.length}/{BODY_MAX}
</span>
<button type="submit" className="panel-action" disabled={!draft.trim() || sending}>
{sending ? t('messages.sending') : t('messages.send')}
</button>
</div>
{error && (
<p className="msg-error" role="alert">
{error}
</p>
)}
</form>
) : (
<p className="msg-empty">
{t('messages.guestBlocked')}{' '}
<Link className="msg-back" to="/enter">
{t('messages.claimName')}
</Link>
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,393 @@
/* Own-profile editor. Tokens from SeancePage.css: bg #07070d ·
panel rgba(16,16,26,0.85) · phosphor #7cffb2 · violet #b26bff ·
blood #ff3b5c · border rgba(124,255,178,0.18). Cinzel display,
IBM Plex Mono UI. Touch targets never below 44px. */
.profile-page {
min-height: 100vh;
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
#07070d;
color: #d8e6dc;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
padding: 1.6rem clamp(0.9rem, 4vw, 3rem) 5rem;
}
/* ---- header ---- */
.profile-header {
max-width: 720px;
margin: 0 auto 2rem;
text-align: center;
}
.profile-back {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.55);
text-decoration: none;
transition: color 160ms ease;
}
.profile-back:hover,
.profile-back:focus-visible {
color: #7cffb2;
}
.profile-title {
font-family: 'Cinzel', Georgia, serif;
font-size: clamp(1.8rem, 6vw, 2.8rem);
letter-spacing: 0.2em;
margin: 0.4rem 0 0.5rem;
color: #eef7f0;
text-shadow: 0 0 18px rgba(124, 255, 178, 0.3), 0 0 44px rgba(178, 107, 255, 0.22);
}
.profile-subtitle {
margin: 0;
font-size: 0.78rem;
font-style: italic;
color: rgba(216, 230, 220, 0.55);
}
/* ---- wanderer prompt ---- */
.profile-wanderer {
max-width: 720px;
margin: 0 auto 1.8rem;
padding: 1rem 1.1rem;
background: rgba(178, 107, 255, 0.08);
border: 1px solid rgba(178, 107, 255, 0.4);
border-radius: 10px;
}
.profile-wanderer-line {
margin: 0 0 0.6rem;
font-size: 0.8rem;
line-height: 1.6;
color: rgba(232, 224, 255, 0.9);
}
.profile-wanderer-claim {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 1.1rem;
border: 1px solid rgba(178, 107, 255, 0.6);
border-radius: 999px;
color: #d9bcff;
text-decoration: none;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.profile-wanderer-claim:hover,
.profile-wanderer-claim:focus-visible {
background: rgba(178, 107, 255, 0.18);
color: #f1e6ff;
}
/* ---- states ---- */
.profile-state {
text-align: center;
font-size: 0.85rem;
letter-spacing: 0.1em;
color: rgba(124, 255, 178, 0.65);
padding: 3rem 0;
animation: profile-breathe 2.4s ease-in-out infinite;
}
.profile-state-error {
color: #ff3b5c;
animation: none;
}
/* ---- form panels ---- */
.profile-form {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.3rem;
}
.profile-panel {
background: rgba(16, 16, 26, 0.85);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 10px;
padding: 1.3rem clamp(0.9rem, 3vw, 1.6rem);
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-panel-title {
font-family: 'Cinzel', Georgia, serif;
font-size: 0.92rem;
letter-spacing: 0.22em;
text-transform: uppercase;
margin: 0;
color: rgba(124, 255, 178, 0.85);
}
.profile-note {
margin: 0;
font-size: 0.72rem;
line-height: 1.6;
font-style: italic;
color: rgba(216, 230, 220, 0.5);
}
/* ---- glyph preview ---- */
.profile-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.profile-preview .ghost-glyph {
animation: profile-drift 7s ease-in-out infinite;
}
.profile-preview-name {
margin: 0.4rem 0 0;
font-family: 'Cinzel', Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.1em;
color: #eef7f0;
}
.profile-preview-rank {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(178, 107, 255, 0.8);
}
/* ---- chips ---- */
.profile-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.profile-chip {
font-family: inherit;
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
min-height: 44px;
padding: 0 1.1rem;
color: rgba(216, 230, 220, 0.65);
background: rgba(10, 10, 18, 0.9);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 999px;
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
}
.profile-chip:hover {
color: #7cffb2;
border-color: rgba(124, 255, 178, 0.45);
}
.profile-chip-active,
.profile-chip-active:hover {
color: #07070d;
background: #7cffb2;
border-color: #7cffb2;
box-shadow: 0 0 16px rgba(124, 255, 178, 0.4);
}
/* ---- fields ---- */
.profile-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-label {
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(124, 255, 178, 0.7);
}
.profile-input,
.profile-textarea {
font-family: inherit;
font-size: 0.9rem;
color: #eef7f0;
background: rgba(7, 7, 13, 0.9);
border: 1px solid rgba(124, 255, 178, 0.22);
border-radius: 6px;
padding: 0.7rem 0.8rem;
min-height: 44px;
box-sizing: border-box;
width: 100%;
}
.profile-textarea {
resize: vertical;
line-height: 1.6;
}
.profile-input:focus-visible,
.profile-textarea:focus-visible {
outline: none;
border-color: #7cffb2;
box-shadow: 0 0 0 2px rgba(124, 255, 178, 0.2);
}
.profile-hint,
.profile-count {
font-size: 0.66rem;
letter-spacing: 0.08em;
color: rgba(216, 230, 220, 0.45);
}
.profile-count-over {
color: #ff3b5c;
}
.profile-error {
font-size: 0.7rem;
color: #ff3b5c;
letter-spacing: 0.06em;
}
.profile-hue-slider {
width: 100%;
min-height: 44px;
accent-color: #7cffb2;
}
/* ---- toggle ---- */
.profile-toggle {
display: flex;
align-items: center;
gap: 0.7rem;
min-height: 44px;
cursor: pointer;
}
.profile-toggle input {
width: 22px;
height: 22px;
accent-color: #7cffb2;
}
.profile-toggle-text {
font-size: 0.76rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #d8e6dc;
}
/* ---- actions ---- */
.profile-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.9rem;
}
.profile-save {
font-family: inherit;
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
min-height: 44px;
padding: 0 1.6rem;
color: #07070d;
background: #7cffb2;
border: 1px solid #7cffb2;
border-radius: 999px;
cursor: pointer;
transition: box-shadow 160ms ease, opacity 160ms ease;
}
.profile-save:hover:not(:disabled) {
box-shadow: 0 0 20px rgba(124, 255, 178, 0.45);
}
.profile-save:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.profile-view-link {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(178, 107, 255, 0.85);
text-decoration: none;
}
.profile-view-link:hover,
.profile-view-link:focus-visible {
color: #d9bcff;
}
.profile-saved {
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: #7cffb2;
text-shadow: 0 0 12px rgba(124, 255, 178, 0.5);
}
/* ---- motion ---- */
@keyframes profile-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@keyframes profile-drift {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.profile-state,
.profile-preview .ghost-glyph {
animation: none;
}
.profile-chip,
.profile-save,
.profile-back,
.profile-input,
.profile-textarea {
transition: none;
}
}

View File

@@ -0,0 +1,329 @@
// The seeker's own sigil — /profile. Edits display name, bio, gender, the
// procedural GhostGlyph avatar (form + hue), whether the profile is public,
// and an optional recovery email. Reads GET /api/profile/me, writes
// PATCH /api/profile (awaited, then a saved state — never optimistic).
//
// There is no image upload anywhere: the avatar is the same procedural glyph
// the entities wear, so there is nothing to moderate and no EXIF to leak.
import { useEffect, useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { GhostGlyph } from '../components/GhostGlyph'
import { useAuth } from '../state/auth'
import { genderLabel, progressPercent, rankTitle } from '../lib/hunterLabels'
import * as api from '../api'
import { AVATAR_FORMS, BIO_MAX, GENDERS, HUE_MAX } from '../api'
import type { Gender, OwnProfile } from '../api'
import type { GhostForm } from '../lib/types'
import './ProfilePage.css'
type LoadState = 'loading' | 'error' | 'ready'
type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const NAME_MAX = 40
/** Same shape the backend accepts — deliberately loose, it only has to be
* plausible enough to receive one recovery message. */
const EMAIL_RE = /^[^@\s]+@[^@\s.]+\.[^@\s]+$/
type Draft = {
displayName: string
bio: string
gender: Gender
form: GhostForm
hue: number
isPublic: boolean
email: string
}
function draftFrom(profile: OwnProfile): Draft {
return {
displayName: profile.display_name ?? '',
bio: profile.bio ?? '',
gender: profile.gender ?? 'unspecified',
form: profile.avatar_form ?? 'wisp',
hue: profile.avatar_hue ?? 150,
isPublic: profile.profile_public !== false,
email: profile.email ?? '',
}
}
export function ProfilePage() {
const { t } = useTranslation()
const { user, checking } = useAuth()
const [profile, setProfile] = useState<OwnProfile | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [draft, setDraft] = useState<Draft | null>(null)
const [saveState, setSaveState] = useState<SaveState>('idle')
const [saveError, setSaveError] = useState<string | null>(null)
const isWanderer = (user?.username ?? '').startsWith('wanderer-')
useEffect(() => {
if (!user) return
const controller = new AbortController()
api
.fetchOwnProfile(controller.signal)
.then((data) => {
setProfile(data)
setDraft(draftFrom(data))
setState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setState('error')
})
return () => controller.abort()
}, [user])
const bioRemaining = draft ? BIO_MAX - draft.bio.length : BIO_MAX
const nameTooLong = (draft?.displayName.length ?? 0) > NAME_MAX
const bioTooLong = bioRemaining < 0
const emailInvalid = !!draft?.email.trim() && !EMAIL_RE.test(draft.email.trim())
const invalid = nameTooLong || bioTooLong || emailInvalid
const patch = useMemo<api.ProfileUpdate | null>(() => {
if (!draft) return null
return {
display_name: draft.displayName.trim() || null,
bio: draft.bio.trim() || null,
gender: draft.gender,
avatar_form: draft.form,
avatar_hue: draft.hue,
profile_public: draft.isPublic,
email: draft.email.trim() || null,
}
}, [draft])
function edit(patchDraft: Partial<Draft>) {
setDraft((prev) => (prev ? { ...prev, ...patchDraft } : prev))
setSaveState('idle')
setSaveError(null)
}
async function handleSave(event: React.FormEvent) {
event.preventDefault()
if (!patch || invalid) return
setSaveState('saving')
setSaveError(null)
try {
// Awaited on purpose: the saved state must mean the veil actually
// took the change, not that we hoped it would.
const saved = await api.saveProfile(patch)
setProfile(saved)
setDraft(draftFrom(saved))
setSaveState('saved')
} catch (err) {
setSaveState('error')
setSaveError(err instanceof Error ? err.message : null)
}
}
if (checking) return null
if (!user) return <Navigate to="/enter" replace />
return (
<div className="profile-page">
<header className="profile-header">
<Link to="/seance" className="profile-back">
{t('profile.back')}
</Link>
<h1 className="profile-title">{t('profile.title')}</h1>
<p className="profile-subtitle">{t('profile.subtitle')}</p>
</header>
{isWanderer && (
<div className="profile-wanderer" role="note">
<p className="profile-wanderer-line">{t('profile.wanderer.body')}</p>
<Link to="/enter" className="profile-wanderer-claim">
{t('profile.wanderer.claim')}
</Link>
</div>
)}
{state === 'loading' && (
<p className="profile-state" role="status">
{t('profile.loading')}
</p>
)}
{state === 'error' && (
<p className="profile-state profile-state-error" role="alert">
{t('profile.loadError')}
</p>
)}
{state === 'ready' && draft && profile && (
<form className="profile-form" onSubmit={handleSave} noValidate>
<section className="profile-panel profile-sigil">
<h2 className="profile-panel-title">{t('profile.avatar.heading')}</h2>
<p className="profile-note">{t('profile.avatar.note')}</p>
<div className="profile-preview">
<GhostGlyph hue={draft.hue} form={draft.form} size={120} />
<p className="profile-preview-name">
{draft.displayName.trim() || profile.username}
</p>
<p className="profile-preview-rank">
{t('profile.rankLine', {
title: rankTitle(t, profile.rank.title),
level: profile.rank.level,
})}
</p>
</div>
<div
className="profile-chips"
role="group"
aria-label={t('profile.avatar.formLabel')}
>
{AVATAR_FORMS.map((form) => (
<button
key={form}
type="button"
className={`profile-chip${draft.form === form ? ' profile-chip-active' : ''}`}
aria-pressed={draft.form === form}
onClick={() => edit({ form })}
>
{t(`codex.form.${form}`, form)}
</button>
))}
</div>
<label className="profile-field profile-hue">
<span className="profile-label">
{t('profile.avatar.hueLabel', { hue: draft.hue })}
</span>
<input
type="range"
min={0}
max={HUE_MAX}
value={draft.hue}
className="profile-hue-slider"
onChange={(e) => edit({ hue: Number(e.target.value) })}
/>
</label>
</section>
<section className="profile-panel">
<h2 className="profile-panel-title">{t('profile.identity.heading')}</h2>
<label className="profile-field">
<span className="profile-label">{t('profile.identity.nameLabel')}</span>
<input
type="text"
className="profile-input"
value={draft.displayName}
maxLength={NAME_MAX * 2}
placeholder={profile.username}
onChange={(e) => edit({ displayName: e.target.value })}
/>
<span className="profile-hint">{t('profile.identity.nameHint')}</span>
{nameTooLong && (
<span className="profile-error" role="alert">
{t('profile.identity.nameTooLong', { max: NAME_MAX })}
</span>
)}
</label>
<label className="profile-field">
<span className="profile-label">{t('profile.identity.bioLabel')}</span>
<textarea
className="profile-textarea"
rows={4}
value={draft.bio}
onChange={(e) => edit({ bio: e.target.value })}
/>
<span
className={`profile-count${bioTooLong ? ' profile-count-over' : ''}`}
aria-live="polite"
>
{t('profile.identity.bioCount', { remaining: bioRemaining, max: BIO_MAX })}
</span>
{bioTooLong && (
<span className="profile-error" role="alert">
{t('profile.identity.bioTooLong', { max: BIO_MAX })}
</span>
)}
</label>
<div
className="profile-chips"
role="group"
aria-label={t('profile.gender.label')}
>
{GENDERS.map((gender) => (
<button
key={gender}
type="button"
className={`profile-chip${draft.gender === gender ? ' profile-chip-active' : ''}`}
aria-pressed={draft.gender === gender}
onClick={() => edit({ gender })}
>
{genderLabel(t, gender)}
</button>
))}
</div>
</section>
<section className="profile-panel">
<h2 className="profile-panel-title">{t('profile.veil.heading')}</h2>
<label className="profile-toggle">
<input
type="checkbox"
checked={draft.isPublic}
onChange={(e) => edit({ isPublic: e.target.checked })}
/>
<span className="profile-toggle-text">
{draft.isPublic ? t('profile.veil.public') : t('profile.veil.private')}
</span>
</label>
<p className="profile-note">
{draft.isPublic ? t('profile.veil.publicNote') : t('profile.veil.privateNote')}
</p>
<label className="profile-field">
<span className="profile-label">{t('profile.email.label')}</span>
<input
type="email"
className="profile-input"
value={draft.email}
autoComplete="email"
onChange={(e) => edit({ email: e.target.value })}
/>
<span className="profile-hint">{t('profile.email.note')}</span>
{emailInvalid && (
<span className="profile-error" role="alert">
{t('profile.email.invalid')}
</span>
)}
</label>
</section>
<div className="profile-actions">
<button
type="submit"
className="profile-save"
disabled={saveState === 'saving' || invalid}
>
{saveState === 'saving' ? t('profile.saving') : t('profile.save')}
</button>
{profile.profile_public && (
<Link to={`/hunters/${profile.username}`} className="profile-view-link">
{t('profile.viewPublic')}
</Link>
)}
{saveState === 'saved' && (
<p className="profile-saved" role="status">
{t('profile.saved')}
</p>
)}
{saveState === 'error' && (
<p className="profile-error" role="alert">
{saveError ?? t('profile.saveError')}
</p>
)}
</div>
</form>
)}
</div>
)
}