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

@@ -15,6 +15,8 @@ from app.routes.codex import router as codex_router
from app.routes.conditions import router as conditions_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router
from app.routes.messages import router as messages_router
from app.routes.profile import router as profile_router
from app.routes.seances import router as seances_router
from app.routes.seo import router as seo_router
from app.routes.shop import router as shop_router
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
))
# Hunter profile columns. All nullable (or defaulted) so existing
# rows, guests included, stay valid without a backfill.
for column, ddl in (
("display_name", "VARCHAR(48)"),
("bio", "VARCHAR(280)"),
("gender", "VARCHAR(16)"),
("avatar_form", "VARCHAR(16)"),
("avatar_hue", "INTEGER"),
):
await conn.execute(
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
)
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
"profile_public BOOLEAN NOT NULL DEFAULT true"
))
# Defense-in-depth: purchase_unlock() already enforces one row per
# (user, unlock_key) via a row-locked check-then-insert, so this
# constraint should never actually find a conflict on a live DB.
@@ -100,6 +119,8 @@ app.include_router(codex_router)
app.include_router(conditions_router)
app.include_router(device_router)
app.include_router(inventory_router)
app.include_router(messages_router)
app.include_router(profile_router)
app.include_router(seances_router)
# Registered before the SPA catch-all below, or /robots.txt and
# /sitemap.xml would be served index.html instead.

View File

@@ -5,6 +5,7 @@ from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.event import Event
from app.models.inventory_item import InventoryItem
from app.models.message import Message
from app.models.sigil import Sigil
from app.models.unlock import UnlockRecord
from app.models.user import User
@@ -19,6 +20,7 @@ __all__ = [
"EntitySighting",
"Event",
"InventoryItem",
"Message",
"Sigil",
"UnlockRecord",
"WaitlistEntry",

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
# The wire cap. Enforced in three places on purpose: the Pydantic schema
# (rejects with a clear 422/400 before touching the DB), the route's own
# check (so a body built any other way still can't slip through), and the
# column type below — Text, because Postgres VARCHAR(n) truncation/erroring
# is a worse failure mode than a validated length, and a future cap change
# then needs no migration.
BODY_MAX_CHARS = 1000
class Message(Base):
"""One plain-text message from one hunter to another.
Deliberately minimal (hunters-and-messages spec, Workstream S): no
attachments, no editing, no groups, no threads-as-rows — a "thread" is
simply every row between two user ids, ordered by time.
`read_at` is null until the *recipient* opens the thread; the sender
never marks anything read, so this doubles as the unread signal.
"""
__tablename__ = "messages"
# Both directions are queried: the conversation list and thread view each
# need "sent by me" OR'd with "sent to me", and the unread aggregate
# scans (recipient_id, read_at). The composite indexes below serve those
# ordered scans; the per-column indexes on the FKs come from
# index=True and keep single-sided lookups cheap.
__table_args__ = (
Index("ix_messages_sender_recipient_created", "sender_id", "recipient_id", "created_at"),
Index("ix_messages_recipient_sender_created", "recipient_id", "sender_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
recipient_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
@@ -14,6 +14,27 @@ class User(Base):
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# --- hunter profile ---------------------------------------------------
# All nullable so every pre-existing row — including the guest
# `wanderer-` accounts minted by the open door — stays valid without a
# backfill. Absent values are treated as "not set" and fall back at the
# serialization layer, never here.
#
# The avatar is procedural: a GhostForm plus a hue, rendered by the same
# GhostGlyph component that draws entities. No uploads means no
# moderation surface, no EXIF stripping and no blob storage — and if a
# real image is ever wanted, an `avatar_url` slots in beside these
# without changing anything else.
display_name: Mapped[str | None] = mapped_column(String(48), nullable=True)
bio: Mapped[str | None] = mapped_column(String(280), nullable=True)
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_form: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_hue: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Hides the profile from /api/hunters and 404s the public page. It does
# NOT stop messages arriving — privacy here is about being browsed, not
# about being unreachable.
profile_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
essence: Mapped[int] = mapped_column(Integer, default=0)
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's

114
backend/app/rank.py Normal file
View File

@@ -0,0 +1,114 @@
"""A hunter's rank — pure maths over three numbers already on the User row.
No DB, no I/O, no clock: `level_for` and `progress_for` are total functions of
their arguments, so the API layer can call them on values it already loaded and
the tests can exercise absurd inputs without a database.
The curve
---------
Everything is converted into one currency, "standing", so the three sources of
progress can be compared:
standing = encounters * ENCOUNTER_WEIGHT
+ max(0, essence) // ESSENCE_PER_POINT
+ max(0.0, favor) * FAVOR_WEIGHT
Contact is what the app is *about*, so an encounter is worth ten points while
ten essence is worth one — a seeker who only buys unlocks climbs very slowly.
Favor is a hidden [-1, 1] score nudged by judgment correctness; it contributes
at most a few points, enough to break a tie between two equally-travelled
hunters but never enough to be a second progression track. Negative essence and
negative favor are floored at zero rather than subtracting, so a hunter can
never be *demoted* by a bad judgment — rank is a record of what you have done.
Thresholds are `0, 10, 40, 100, 220, 450`: level 1 costs exactly one encounter
(a new hunter finishes their first séance and immediately sees the bar move —
this is the point of the curve), then each step costs roughly 2.2x the last.
Geometric growth means the early levels arrive in a single sitting while
`oracle` is a genuine long-haul goal (~45 distinct spirits), without a
hand-tuned table that has to be re-justified every time a level is added.
"""
# One-word, in-fiction titles, indexed by level.
TITLES: tuple[str, ...] = (
"curious",
"sensitive",
"channeler",
"medium",
"adept",
"oracle",
)
# Standing required to *reach* each level; index == level. Strictly increasing.
THRESHOLDS: tuple[int, ...] = (0, 10, 40, 100, 220, 450)
MAX_LEVEL = len(THRESHOLDS) - 1
ENCOUNTER_WEIGHT = 10
ESSENCE_PER_POINT = 10
FAVOR_WEIGHT = 5.0
def standing_for(encounters: int, essence: int, favor: float) -> int:
"""The single progression currency. Total and non-negative for any input,
including negative essence/favor and non-finite favor."""
try:
enc = max(0, int(encounters))
ess = max(0, int(essence))
fav = float(favor)
except (TypeError, ValueError):
return 0
# NaN fails every comparison, so test for it rather than clamping.
if not (fav == fav): # noqa: PLR0124 — NaN check without importing math
fav = 0.0
fav = min(1.0, max(0.0, fav))
return enc * ENCOUNTER_WEIGHT + ess // ESSENCE_PER_POINT + int(fav * FAVOR_WEIGHT)
def level_for(encounters: int, essence: int, favor: float) -> int:
"""Highest level whose threshold the hunter's standing has reached,
clamped to [0, MAX_LEVEL]."""
standing = standing_for(encounters, essence, favor)
level = 0
for candidate, threshold in enumerate(THRESHOLDS):
if standing >= threshold:
level = candidate
else:
break
return level
def title_for(level: int) -> str:
"""Title for a level, clamped — never raises on an out-of-range level."""
return TITLES[min(MAX_LEVEL, max(0, int(level)))]
def progress_for(encounters: int, essence: int, favor: float) -> dict:
"""Rank plus the numbers a progress bar needs.
`next_at` is the standing required for the next level (None at MAX_LEVEL),
and `progress` is the 0.0..1.0 fraction of the way there (1.0 at
MAX_LEVEL, so a maxed bar renders full rather than empty).
"""
standing = standing_for(encounters, essence, favor)
level = level_for(encounters, essence, favor)
floor = THRESHOLDS[level]
if level >= MAX_LEVEL:
next_at: int | None = None
progress = 1.0
else:
next_at = THRESHOLDS[level + 1]
span = next_at - floor
progress = min(1.0, max(0.0, (standing - floor) / span))
return {
"level": level,
"title": title_for(level),
# Echoed back so a caller rendering the bar doesn't need a second
# source for the count it is labelling.
"encounters": max(0, int(encounters)) if isinstance(encounters, (int, float)) else 0,
"standing": standing,
"next_at": next_at,
"progress": round(progress, 4),
}

View File

@@ -16,6 +16,97 @@ from app.models.user import User
router = APIRouter(prefix="/api", tags=["codex"])
# How many distinct hunters the dossier names. Everyone else is still
# counted in `total_encounters` — the roster is a window, not the truth.
ROSTER_LIMIT = 20
def _user_column(name: str):
"""The profile columns land in `users` via a parallel workstream (the
hunter-profiles spec §"Data model"). Until that migration exists this
module must not reference them in SQL at all, or every Codex request
500s on an unknown column. Resolve them by name and fall back to a
literal default, so the same code path works before and after.
"""
return getattr(User, name, None)
def _hunter_entry(
username: str,
display_name: str | None,
avatar_form: str | None,
avatar_hue: int | None,
profile_public: bool | None,
times: int,
last_seen,
) -> dict:
# A hidden profile is still a hunter who was there: counted, named
# nowhere. `public` is what the UI keys the link off.
public = True if profile_public is None else bool(profile_public)
return {
"username": username,
"display_name": display_name or username,
"avatar_form": avatar_form,
"avatar_hue": avatar_hue,
"public": public,
"times_contacted": times,
"last_seen": last_seen.isoformat() if last_seen else None,
}
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
"""The roster of hunters who have contacted this spirit, newest-first.
ONE aggregate query — group the sightings by hunter and carry the count
and latest contact out of the same scan. Never a lookup per hunter.
"""
display_name = _user_column("display_name")
avatar_form = _user_column("avatar_form")
avatar_hue = _user_column("avatar_hue")
profile_public = _user_column("profile_public")
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
times = func.count(EntitySighting.id).label("times")
columns = [User.username, times, last_seen]
optional = [display_name, avatar_form, avatar_hue, profile_public]
columns.extend(col for col in optional if col is not None)
rows = (
await db.execute(
select(*columns)
.join(User, User.id == EntitySighting.user_id)
.where(EntitySighting.entity_id == entity_id)
.group_by(User.id)
.order_by(desc(last_seen))
.limit(ROSTER_LIMIT)
)
).all()
roster = [
_hunter_entry(
row.username,
getattr(row, "display_name", None) if display_name is not None else None,
getattr(row, "avatar_form", None) if avatar_form is not None else None,
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
getattr(row, "profile_public", None) if profile_public is not None else None,
row.times,
row.last_seen,
)
for row in rows
]
# Distinct hunters, not sighting rows (`sightings` already reports those)
# — so the UI can honestly say "and N more" past the roster window.
total = (
await db.scalar(
select(func.count(func.distinct(EntitySighting.user_id))).where(
EntitySighting.entity_id == entity_id
)
)
or 0
)
return roster, total
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
return {
@@ -72,17 +163,45 @@ async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
discoverer = None
discoverer_entry = None
if entity.discovered_by:
user = await db.get(User, entity.discovered_by)
discoverer = user.username if user else None
if user is not None:
discoverer = user.username
discoverer_entry = _hunter_entry(
user.username,
getattr(user, "display_name", None),
getattr(user, "avatar_form", None),
getattr(user, "avatar_hue", None),
getattr(user, "profile_public", None),
0,
None,
)
sightings = await db.scalar(
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
)
roster, total_encounters = await _encounters(db, entity.id)
card = _entity_card(entity, discoverer)
card["persona"] = entity.persona
card["voice"] = entity.voice_profile
card["sightings"] = sightings or 0
# The summoner, with enough to render a glyph and decide on a link.
# `times_contacted`/`last_seen` come from their roster row when they
# have one — discovery alone doesn't imply a surviving sighting row.
if discoverer_entry is not None:
for hunter in roster:
if hunter["username"] == discoverer_entry["username"]:
discoverer_entry["times_contacted"] = hunter["times_contacted"]
discoverer_entry["last_seen"] = hunter["last_seen"]
break
card["discoverer"] = discoverer_entry
card["discoverer_public"] = (
discoverer_entry["public"] if discoverer_entry else False
)
card["encounters"] = roster
card["total_encounters"] = total_encounters
return card

View File

@@ -0,0 +1,254 @@
"""Whispers between hunters — plain text, no attachments, no editing, no
groups. Workstream S of docs/superpowers/specs/
2026-07-30-hunters-and-messages-design.md.
Security note, because it is the whole point of this module: every query is
scoped to the caller's own user id. A "thread" is not a row anybody can
address by id — it is derived from (sender_id, recipient_id) pairs where one
side is always `user.id`, so there is no id a caller could guess to read
somebody else's mail. See tests/test_messages.py's cross-user leak tests.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import case, desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.deps import get_current_user
from app.models.message import BODY_MAX_CHARS, Message
from app.models.user import User
from app.routes.auth import WANDERER_PREFIX
from app.rate_limit import RateLimiter
from app.schemas import MessageIn
router = APIRouter(prefix="/api/messages", tags=["messages"])
# Per-user, not per-IP: sending requires an account, so the user id is the
# real actor. 20 an hour is plenty for conversation and useless for spam.
send_limiter = RateLimiter(max_requests=20, window_seconds=3600)
CONVERSATION_LIMIT = 50
THREAD_LIMIT_DEFAULT = 50
THREAD_LIMIT_MAX = 100
EXCERPT_CHARS = 160
AVATAR_FORMS = ("wisp", "banshee", "fairy", "shade")
def _hunter_out(user: User) -> dict:
"""Public identity of a correspondent.
Workstream P owns `display_name` / `avatar_form` / `avatar_hue` /
`profile_public` and lands separately, so every one is read with
`getattr(..., None)`: this module works identically before and after
that merge, and the mailbox never depends on a profile being public —
privacy hides the profile page, not the mailbox.
"""
form = getattr(user, "avatar_form", None)
hue = getattr(user, "avatar_hue", None)
return {
"username": user.username,
"display_name": getattr(user, "display_name", None) or user.username,
"avatar": {
"form": form if form in AVATAR_FORMS else "wisp",
"hue": hue if isinstance(hue, int) and 0 <= hue <= 359 else 150,
},
# False only when Workstream P exists AND the hunter opted out; the
# UI uses this purely to decide whether to link to their profile.
"profile_public": getattr(user, "profile_public", True) is not False,
"is_wanderer": user.username.startswith(WANDERER_PREFIX),
}
def _message_out(message: Message, me_id) -> dict:
return {
"id": str(message.id),
"body": message.body,
"from_me": message.sender_id == me_id,
"created_at": message.created_at.isoformat(),
"read_at": message.read_at.isoformat() if message.read_at else None,
}
async def _unread_by_sender(db: AsyncSession, me_id) -> dict:
"""ONE aggregate query for every unread count — never a lookup per
conversation. Keyed by the sender's user id."""
rows = await db.execute(
select(Message.sender_id, func.count(Message.id))
.where(Message.recipient_id == me_id, Message.read_at.is_(None))
.group_by(Message.sender_id)
)
return {sender_id: count for sender_id, count in rows}
@router.post("", status_code=status.HTTP_201_CREATED)
async def send_message(
payload: MessageIn,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# The one deliberate account-only capability besides device pairing: a
# wanderer's name is temporary and unrecoverable, so a reply would have
# nowhere to land. They can still RECEIVE.
if user.username.startswith(WANDERER_PREFIX):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"a wanderer has no name to sign — claim one before you whisper",
)
body = payload.body.strip()
if not body:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "an empty whisper carries nothing")
if len(body) > BODY_MAX_CHARS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"the veil will not carry more than {BODY_MAX_CHARS} characters at once",
)
to = payload.to.strip()
if to.lower() == user.username.lower():
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
recipient = await db.scalar(select(User).where(User.username == to))
if recipient is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
# Guard the id comparison too: the username check above is the friendly
# path, this one is what actually makes self-messaging impossible.
if recipient.id == user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
if not send_limiter.allow(str(user.id)):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"you have whispered enough for one hour — let the veil settle",
)
message = Message(sender_id=user.id, recipient_id=recipient.id, body=body)
db.add(message)
await db.commit()
await db.refresh(message)
return {"message": _message_out(message, user.id), "to": _hunter_out(recipient)}
@router.get("")
async def list_conversations(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
"""Every correspondent, their last message excerpt, and unread counts.
Two queries total regardless of how many conversations exist: one
window-function pass for the newest message per correspondent, and one
GROUP BY for unread counts.
"""
correspondent = case(
(Message.sender_id == user.id, Message.recipient_id), else_=Message.sender_id
)
ranked = (
select(
Message.id,
Message.sender_id,
Message.body,
Message.created_at,
Message.read_at,
correspondent.label("correspondent_id"),
func.row_number()
.over(partition_by=correspondent, order_by=desc(Message.created_at))
.label("rn"),
)
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
.subquery()
)
rows = (
await db.execute(
select(
ranked.c.sender_id,
ranked.c.body,
ranked.c.created_at,
ranked.c.read_at,
User,
)
.join(User, User.id == ranked.c.correspondent_id)
.where(ranked.c.rn == 1)
.order_by(desc(ranked.c.created_at))
.limit(CONVERSATION_LIMIT)
)
).all()
unread = await _unread_by_sender(db, user.id)
conversations = [
{
"hunter": _hunter_out(other),
"excerpt": body[:EXCERPT_CHARS],
"truncated": len(body) > EXCERPT_CHARS,
"last_at": created_at.isoformat(),
"last_from_me": sender_id == user.id,
"unread": unread.get(other.id, 0),
}
for sender_id, body, created_at, read_at, other in rows
]
return {
"conversations": conversations,
# Exposed here on purpose rather than on GET /api/profile/me, so the
# badge does not depend on Workstream P.
"unread_total": sum(unread.values()),
}
@router.get("/{username}")
async def read_thread(
username: str,
limit: int = Query(default=THREAD_LIMIT_DEFAULT, ge=1, le=THREAD_LIMIT_MAX),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""The thread with one hunter, newest LAST (reading order), capped at
`limit` most-recent messages. Marks the caller's inbound messages read."""
other = await db.scalar(select(User).where(User.username == username))
if other is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
if other.id == user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
# Both legs are pinned to (user.id, other.id) in both orders — no third
# party's rows can match, whatever `username` is.
scope = or_(
(Message.sender_id == user.id) & (Message.recipient_id == other.id),
(Message.sender_id == other.id) & (Message.recipient_id == user.id),
)
total = await db.scalar(select(func.count(Message.id)).where(scope)) or 0
newest = (
await db.execute(
select(Message).where(scope).order_by(desc(Message.created_at)).limit(limit)
)
).scalars().all()
messages = list(reversed(newest))
# One UPDATE marks everything they sent us read — done after reading the
# rows so the response still shows the read_at the caller had on open.
await db.execute(
update(Message)
.where(
Message.recipient_id == user.id,
Message.sender_id == other.id,
Message.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
await db.commit()
return {
"hunter": _hunter_out(other),
"messages": [_message_out(m, user.id) for m in messages],
"total": total,
"has_more": total > len(messages),
"can_send": not user.username.startswith(WANDERER_PREFIX),
}

View File

@@ -0,0 +1,278 @@
"""Hunter profiles: the identity an account buys you.
The open door means a séance needs no account at all, so registering has to
be worth something on its own. This is that something — a name other hunters
see, a rank that grows from what you have actually contacted, and a public
page. Device pairing and messages are the other two.
Privacy rules that the tests pin, because they are easy to regress:
- `email` is returned by GET /api/profile/me and by NOTHING else. It
exists for account recovery; it is never part of a public payload.
- `profile_public = False` makes the public page 404 and drops the hunter
from the roster. It deliberately does NOT stop messages arriving —
privacy here is about being browsed, not about being unreachable.
- Guests (`wanderer-` accounts) have profiles like anyone else. They are
real rows; nothing here special-cases them.
"""
import re
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import rank
from app.db import get_db
from app.deps import get_current_user
from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.user import User
from app.rate_limit import RateLimiter
router = APIRouter(prefix="/api", tags=["profile"])
# Editing a profile is cheap, but it is still a write — enough headroom for
# someone fiddling with a hue slider, tight enough to be uninteresting to
# abuse.
profile_limiter = RateLimiter(max_requests=30, window_seconds=60)
GENDERS = {"male", "female", "unspecified"}
AVATAR_FORMS = {"wisp", "banshee", "fairy", "shade"}
BIO_MAX = 280
DISPLAY_NAME_MAX = 48
HUE_MAX = 359
ROSTER_LIMIT = 24
RECENT_ENTITIES = 12
# Same shape as routes/shop.py's — deliberately permissive, since this is a
# recovery hint rather than a verified identity.
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
async def _encounter_count(db: AsyncSession, user_id) -> int:
"""Distinct spirits this hunter has contacted."""
return int(
await db.scalar(
select(func.count(func.distinct(EntitySighting.entity_id))).where(
EntitySighting.user_id == user_id
)
)
or 0
)
def _rank_block(encounters: int, user: User) -> dict:
return rank.progress_for(encounters, user.essence or 0, user.favor or 0.0)
def _avatar(user: User) -> dict:
return {
"avatar_form": user.avatar_form,
"avatar_hue": user.avatar_hue,
}
def _public_fields(user: User) -> dict:
"""Everything safe to show a stranger. Note the absence of `email` — that
omission is the point of this helper existing."""
return {
"username": user.username,
"display_name": user.display_name,
"bio": user.bio,
"gender": user.gender or "unspecified",
**_avatar(user),
}
@router.get("/profile/me")
async def my_profile(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
encounters = await _encounter_count(db, user.id)
return {
**_public_fields(user),
"profile_public": bool(user.profile_public),
# Own profile only.
"email": user.email,
"rank": _rank_block(encounters, user),
}
@router.patch("/profile")
async def update_profile(
payload: dict,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Partial update: only keys actually present are touched, so a client
can send one field without having to echo the rest back."""
if not profile_limiter.allow(str(user.id)):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS, "too many changes — slow down"
)
if not isinstance(payload, dict):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "expected an object")
def _optional_text(key: str, limit: int) -> str | None:
"""Trim, cap, and treat empty string as an explicit clear."""
raw = payload[key]
if raw is None:
return None
if not isinstance(raw, str):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be text"
)
text_value = raw.strip()
if len(text_value) > limit:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"{key} must be {limit} characters or fewer",
)
return text_value or None
if "display_name" in payload:
user.display_name = _optional_text("display_name", DISPLAY_NAME_MAX)
if "bio" in payload:
user.bio = _optional_text("bio", BIO_MAX)
if "gender" in payload:
gender = payload["gender"]
if gender not in GENDERS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such gender option"
)
user.gender = gender
if "avatar_form" in payload:
form = payload["avatar_form"]
if form not in AVATAR_FORMS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such sigil form"
)
user.avatar_form = form
if "avatar_hue" in payload:
hue = payload["avatar_hue"]
# bool is an int subclass in Python — reject it explicitly so
# `avatar_hue: true` can't silently become hue 1.
if not isinstance(hue, int) or isinstance(hue, bool) or not (0 <= hue <= HUE_MAX):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"hue must be 0-{HUE_MAX}"
)
user.avatar_hue = hue
if "profile_public" in payload:
visible = payload["profile_public"]
if not isinstance(visible, bool):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "profile_public must be true/false"
)
user.profile_public = visible
if "email" in payload:
email = payload["email"]
if email is None or (isinstance(email, str) and not email.strip()):
user.email = None
elif isinstance(email, str) and _EMAIL_RE.match(email.strip().lower()):
user.email = email.strip().lower()
else:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
)
await db.commit()
await db.refresh(user)
encounters = await _encounter_count(db, user.id)
return {
**_public_fields(user),
"profile_public": bool(user.profile_public),
"email": user.email,
"rank": _rank_block(encounters, user),
}
@router.get("/hunters")
async def hunter_roster(db: AsyncSession = Depends(get_db)):
"""Public hunters, most-travelled first.
Encounter counts come from ONE grouped query rather than a lookup per
hunter — the roster is the most-hit public endpoint here and an N+1
would show immediately.
"""
counts_q = (
select(
EntitySighting.user_id,
func.count(func.distinct(EntitySighting.entity_id)).label("n"),
)
.group_by(EntitySighting.user_id)
.subquery()
)
rows = (
await db.execute(
select(User, func.coalesce(counts_q.c.n, 0).label("encounters"))
.outerjoin(counts_q, counts_q.c.user_id == User.id)
.where(User.profile_public.is_(True))
.order_by(func.coalesce(counts_q.c.n, 0).desc(), User.created_at)
.limit(ROSTER_LIMIT)
)
).all()
return {
"hunters": [
{
"username": user.username,
"display_name": user.display_name,
**_avatar(user),
"rank": _rank_block(int(encounters or 0), user),
}
for user, encounters in rows
]
}
@router.get("/hunters/{username}")
async def hunter_profile(username: str, db: AsyncSession = Depends(get_db)):
user = await db.scalar(select(User).where(User.username == username))
# A hidden profile is indistinguishable from one that never existed —
# confirming the account exists would leak exactly what hiding it was
# meant to prevent.
if user is None or not user.profile_public:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such hunter walks here")
encounters = await _encounter_count(db, user.id)
# Most recently contacted spirits, distinct, newest first.
latest = (
select(
EntitySighting.entity_id,
func.max(EntitySighting.seen_at).label("last_seen"),
)
.where(EntitySighting.user_id == user.id)
.group_by(EntitySighting.entity_id)
.subquery()
)
recent = (
await db.execute(
select(Entity, latest.c.last_seen)
.join(latest, latest.c.entity_id == Entity.id)
.order_by(latest.c.last_seen.desc())
.limit(RECENT_ENTITIES)
)
).all()
return {
**_public_fields(user),
"joined_at": user.created_at.isoformat() if user.created_at else None,
"rank": _rank_block(encounters, user),
"recent_entities": [
{
"id": str(entity.id),
"name": entity.name,
"epithet": entity.epithet,
"rarity": entity.rarity_tier,
"visual": entity.visual_profile or {},
}
for entity, _seen in recent
],
}

View File

@@ -59,3 +59,13 @@ class SigilOut(BaseModel):
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MessageIn(BaseModel):
"""A whisper sent from one hunter to another (Workstream S)."""
# Length rules live in routes/messages.py, not here: an empty or
# oversized body is a 400 with in-fiction copy the UI can show, which
# is friendlier than Pydantic's 422 validation envelope.
to: str
body: str

View File

@@ -1,9 +1,13 @@
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app.models.contact_session import ContactSession
from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.user import User
def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"):
@@ -81,3 +85,112 @@ async def test_stats_counts_veil_activity(client, db_session):
async def test_codex_is_public_without_auth(client):
assert (await client.get("/api/codex")).status_code == 200
assert (await client.get("/api/stats")).status_code == 200
# --- the encounter record: who summoned a spirit, and who else has met it ---
# (hunter-profiles wave). The data already existed — every contact is an
# EntitySighting row, discovery is Entity.discovered_by.
async def _hunter(db_session, username: str, **profile):
"""A hunter. `profile` fields (display_name, profile_public, …) are set
only if the parallel profile migration has landed, so these tests pass
either way."""
user = User(username=username, password_hash="x")
for key, value in profile.items():
if hasattr(User, key):
setattr(user, key, value)
db_session.add(user)
await db_session.flush()
return user
async def _contact(db_session, entity, user, seen_at):
session = ContactSession(user_id=user.id, entity_id=entity.id, mode="wire")
db_session.add(session)
await db_session.flush()
db_session.add(
EntitySighting(
entity_id=entity.id,
session_id=session.id,
user_id=user.id,
seen_at=seen_at,
)
)
@pytest.mark.asyncio
async def test_detail_surfaces_the_discoverer(client, db_session):
entity = _make_entity()
db_session.add(entity)
summoner = await _hunter(db_session, "mora", display_name="Mora Vane")
await db_session.flush()
entity.discovered_by = summoner.id
await _contact(
db_session, entity, summoner, datetime(2026, 7, 1, tzinfo=timezone.utc)
)
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["discovered_by"] == "mora"
assert body["discoverer"]["username"] == "mora"
assert body["discoverer"]["times_contacted"] == 1
assert body["discoverer"]["last_seen"] is not None
# Public by default, so the dossier may link to their hunter page.
assert body["discoverer_public"] is True
assert body["discoverer"]["display_name"] in {"mora", "Mora Vane"}
@pytest.mark.asyncio
async def test_encounter_roster_aggregates_per_hunter(client, db_session):
entity = _make_entity()
db_session.add(entity)
twice = await _hunter(db_session, "reva")
once = await _hunter(db_session, "isolde")
await _contact(db_session, entity, twice, datetime(2026, 7, 1, tzinfo=timezone.utc))
await _contact(db_session, entity, twice, datetime(2026, 7, 2, tzinfo=timezone.utc))
await _contact(db_session, entity, once, datetime(2026, 7, 9, tzinfo=timezone.utc))
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["sightings"] == 3
# Distinct hunters, not sighting rows.
assert body["total_encounters"] == 2
roster = body["encounters"]
assert [h["username"] for h in roster] == ["isolde", "reva"] # newest first
by_name = {h["username"]: h for h in roster}
assert by_name["reva"]["times_contacted"] == 2
assert by_name["isolde"]["times_contacted"] == 1
assert by_name["reva"]["last_seen"].startswith("2026-07-02")
@pytest.mark.asyncio
async def test_private_hunters_are_counted_but_not_linkable(client, db_session):
if not hasattr(User, "profile_public"):
pytest.skip("profile_public lands with the profile workstream")
entity = _make_entity()
db_session.add(entity)
hidden = await _hunter(db_session, "nocturne", profile_public=False)
seen = await _hunter(db_session, "calla", profile_public=True)
await _contact(db_session, entity, hidden, datetime(2026, 7, 3, tzinfo=timezone.utc))
await _contact(db_session, entity, seen, datetime(2026, 7, 4, tzinfo=timezone.utc))
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["total_encounters"] == 2
flags = {h["username"]: h["public"] for h in body["encounters"]}
assert flags == {"nocturne": False, "calla": True}
@pytest.mark.asyncio
async def test_unmet_spirit_has_an_empty_roster(client, db_session):
entity = _make_entity()
db_session.add(entity)
await db_session.commit()
await db_session.refresh(entity)
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["encounters"] == []
assert body["total_encounters"] == 0
assert body["discoverer"] is None
assert body["discoverer_public"] is False

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