+
+
+ {t('hunters.backToRoster')}
+
+
+
+ {state === 'loading' && (
+
+ {t('hunters.profile.loading')}
+
+ )}
+ {state === 'error' && (
+
+ {t('common.error')}
+
+ )}
+ {state === 'missing' && (
+
+
{t('hunters.profile.veiledTitle')}
+
+ {t('hunters.profile.veiledBody', { username })}
+
+
+ {t('hunters.backToRoster')}
+
+
+ )}
+
+ {state === 'ready' && hunter && (
+ <>
+
+
+
+
+ {hunterName(hunter)}
+ @{hunter.username}
+
+
+ {t('profile.rankLine', {
+ title: rankTitle(t, hunter.rank.title),
+ level: hunter.rank.level,
+ })}
+
+
+
+ {hunter.rank.next_at == null
+ ? t('hunters.profile.progressMax')
+ : t('hunters.profile.progressNext', {
+ remaining: Math.max(0, hunter.rank.next_at - hunter.rank.encounters),
+ })}
+
+
+
+
+
- {t('hunters.profile.encounters')}
+ - {hunter.rank.encounters}
+
+ {hunter.gender && (
+
+
- {t('profile.gender.label')}
+ - {genderLabel(t, hunter.gender)}
+
+ )}
+ {joined && (
+
+
- {t('hunters.profile.joined')}
+ - {joined}
+
+ )}
+
+
+ {hunter.bio && {hunter.bio}
}
+
+ {/* Workstream S owns /messages/:username — this is only the door. */}
+
+ {t('hunters.profile.sendMessage')}
+
+
+
+
+ {t('hunters.profile.recentTitle')}
+ {hunter.recent_entities.length === 0 ? (
+ {t('hunters.profile.recentEmpty')}
+ ) : (
+
+ {hunter.recent_entities.map((entity) => (
+ -
+
+
+
+
+ {t(`codex.form.${asForm(entity.visual.form)}`)}
+
+
+ {entity.name}
+ {entity.epithet}
+
+ {t(`codex.rarity.${entity.rarity}`, entity.rarity)}
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/HuntersPage.css b/frontend/src/pages/HuntersPage.css
new file mode 100644
index 0000000..0871a06
--- /dev/null
+++ b/frontend/src/pages/HuntersPage.css
@@ -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;
+ }
+}
diff --git a/frontend/src/pages/HuntersPage.tsx b/frontend/src/pages/HuntersPage.tsx
new file mode 100644
index 0000000..a4dc14d
--- /dev/null
+++ b/frontend/src/pages/HuntersPage.tsx
@@ -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