MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that. - PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path. - ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35. - ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups. - ui/docs/README.md (new): grouped topic-doc index. - De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.1 KiB
In-game diplomatic mail UI
The in-game mail view consumes the diplomail subsystem in the
backend. The route lives at /games/:id/mail and replaces the
active view when the user opens the "diplomatic mail" entry in the
header menu.
Wire surface
Eight ConnectRPC commands sit between UI and backend, all under the
user.games.mail.* namespace:
| Command | Backend REST endpoint |
|---|---|
user.games.mail.inbox |
GET /api/v1/user/games/{id}/mail/inbox |
user.games.mail.sent |
GET …/mail/sent |
user.games.mail.message.get |
GET …/mail/messages/{message_id} |
user.games.mail.send |
POST …/mail/messages |
user.games.mail.broadcast |
POST …/mail/broadcast |
user.games.mail.admin |
POST …/mail/admin |
user.games.mail.read |
POST …/mail/messages/{id}/read |
user.games.mail.delete |
DELETE …/mail/messages/{id} |
The FlatBuffers schemas live under
pkg/schema/fbs/diplomail.fbs;
the gateway translation lives in
gateway/internal/backendclient/mail_commands.go.
Recipient by race name
The compose flow does not consult a memberships listing. The
recipient picker reads gameState.report.races[].name (projected
from report.player[]), and the send request carries the
chosen race name as recipient_race_name. The backend resolves it
against Memberships.ListMembers(gameID, "active") and rejects with
forbidden if the matching member is no longer active. This keeps
the UI off the lobby surface for the common case.
Threading model
MailStore.entries is the derived rune the active view consumes. It
projects the union of inbox and sent into:
- Per-race threads — every personal message keyed by another
race contributes to a thread keyed on that race name. Incoming is
keyed on
sender_race_name; outgoing is keyed onrecipient_race_name. Thread messages are sorted oldest → newest for chat-style rendering; the unread badge counts incomingread_at === nullrows only. - Stand-alone items — system mail (
sender_kind=system), admin notifications (sender_kind=admin), and the caller's own paid-tier broadcasts (broadcast_scope=game_broadcast). Backend returns one row per recipient for paid-tier broadcasts; the UI collapses them bymessage_idinto a single stand-alone item.
read_at and deleted_at are not surfaced to the user in any pane
— they only drive the badge counter and the optimistic mark-read
state. This is intentional: the user-facing spec for diplomatic mail
does not promise read receipts.
Translation toggle
When a message detail carries translated_body, the body and (if
non-empty) subject default to the translated rendering. Each message
pane exposes a "Show original" / "Show translation" button that
flips the per-message state. Messages without a cached translation
render the original directly with no toggle.
Push events
diplomail.message.received push frames are dispatched from
api/events.svelte.ts via the singleton SubscribeEvents stream. The
in-game layout (routes/games/[id]/+layout.svelte) parses the
verified payload, calls mailStore.applyPushEvent(gameId) (which
re-fetches the inbox — the payload only carries a preview), and
raises a toast through lib/toast.svelte.ts with a "view"
deep-link to /games/:id/mail.
The header view-menu's mail entry shows mailStore.unreadCount as
an inline pill — the only chrome the badge needs.
Layout
Desktop (≥ 768 px) renders a two-pane CSS grid: list on the left, detail on the right. Mobile flips to a single-pane stack; tapping a list row hides the list and shows the detail with a back button.
Accessibility
- Bodies render through Svelte's default text-content path (no HTML parsing) per the backend rule of treating message text as plain UTF-8.
- The compose dialog uses native form controls; the recipient
picker is a
<select>so screen-readers and keyboard users get the standard semantics. - The reply box and the compose body are real
<textarea>s so shift-enter newlines, paste, and selection behave correctly.