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

@@ -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>
)
}