Phase 28 (Step 10): docs — diplomail UI topic + FUNCTIONAL mirror
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s

- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
  surface, recipient-by-race-name decision, threading model,
  translation toggle, push events, badge, layout, and
  accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
  UI's per-race threading rule, the absent read-receipt UX, and
  the recipient-by-race-name compose path. Mirrored verbatim into
  `docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
  stage" block matching the implementation plan, and the artifact
  list updated to the actual file set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:48:16 +02:00
parent db81bd8e08
commit c48bc83890
4 changed files with 191 additions and 2 deletions
+97
View File
@@ -0,0 +1,97 @@
# In-game diplomatic mail UI
Phase 28 wires the in-game mail view that consumes the `diplomail`
subsystem in the backend. The route lives at `/games/:id/mail`
(registered in Phase 10) 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`](../../pkg/schema/fbs/diplomail.fbs);
the gateway translation lives in
[`gateway/internal/backendclient/mail_commands.go`](../../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` (the Phase 22
projection of `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 on
`recipient_race_name`. Thread messages are sorted oldest → newest
for chat-style rendering; the unread badge counts incoming
`read_at === null` rows 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 by `message_id` into 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 (per Phase 28 decisions): 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.