Files
galaxy-game/ui/docs/lobby.md
T
Ilia Denisov a679d9cdcb
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s
fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
PR-feedback round on #60:

- Time-zone field is now a continent-grouped <select> populated from
  `Intl.supportedValuesOf("timeZone")`, with the browser-detected
  zone pre-selected when no value is stored. A stored zone the
  runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
  the form stays put and shows a transient `saved` notice, cleared
  on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
  the session; lobby + profile share it through `account.ensure()`,
  so navigating Overview ⇄ Profile no longer flashes the
  "loading account…" placeholder or fires a second gateway call.
  Profile save writes through to the store so the shell identity
  strip picks up the new display name without refetching. Cleared
  on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
  for the timezone dropdown and identity-strip stability across
  navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
  the new timezone picker shape, and the save-stay behaviour.
2026-05-26 22:38:14 +02:00

177 lines
10 KiB
Markdown

# Lobby UI
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
shell, the Overview sections, the profile sub-screen, and the
defaults baked into the create-game form.
## Shell
Lobby and profile share a single chrome implemented in
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project
site's VitePress layout: a left page-list sidebar (Overview /
Profile), a top identity strip on the right, and the page content in
the right-hand column. The shell uses `var(--font-mono)` so the
post-login pages adopt the "nerdy" type stack that the public site
already uses.
The identity strip reads the caller's account from
`lib/account-store.svelte.ts` — a session-wide cache that fetches
`user.account.get` once on first access and is written through after
every Profile save. Both `lobby-screen.svelte` and
`profile-screen.svelte` populate the same cache through
`account.ensure(client)`, so switching Overview ⇄ Profile never
re-issues `user.account.get` and the strip never flashes the
`lobby.account_loading` placeholder mid-navigation. The cache is
cleared by `session.signOut("user")` / `signOut("revoked")` so a
different user signing in on the same browser does not briefly see
the previous identity.
The strip falls back to `display_name` → immutable `user_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It
renders as a `data-testid="lobby-account-name"` button; clicking it
switches the top-level screen to `profile`
(`appScreen.go("profile")`). The e2e suites use that testid as their
lobby-loaded signal. The logout button sits next to it
(`session.signOut("user")`).
The sidebar always renders both pages; clicking the active page is a
no-op. The shell collapses to a horizontal scrolling strip below
640px.
## Overview sections
The Overview page renders one column of sections, top to bottom.
Cards inside each section take the full available width.
| Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
| `create new game` | (always visible) | — | Opens the create screen (`appScreen.go("lobby-create")`) |
| `my games` | `no games yet` | `lobby.my.games.list` | Click → enters the game on the map view (`activeView.reset()` + `appScreen.go("game", { gameId })`) |
| `pending invitations`| `no invitations` | `lobby.my.invites.list` | Accept (`lobby.invite.redeem`) / Decline (`lobby.invite.decline`) |
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
## Profile sub-screen
`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of
`lobby` and `lobby-create`). The browser Back stack treats it the
same as the create screen — pushing a fresh history entry on entry,
falling back to lobby on Back/Forward (see
[`navigation.md`](navigation.md)).
On mount it reads the caller's account through `account.ensure(...)`
(see [Shell](#shell)) — the first visit issues `user.account.get`,
subsequent visits resolve from the session-wide cache without a
gateway round-trip. The form renders an identity read-out (immutable
`user_name`, `email`) plus three editable fields:
| Field | Endpoint | Notes |
| --------------------- | --------------------- | -------------------------------------------------------------- |
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
| `preferred_language` | `user.settings.update`| `<select>` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. |
| `time_zone` | `user.settings.update`| `<select>` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. |
Save fires `user.profile.update` and/or `user.settings.update`
conditionally on which fields actually changed, then **stays on the
profile** and surfaces a transient `profile-saved-notice` line
(`data-testid="profile-saved-notice"`). Editing any field clears the
notice. Only the explicit `cancel` button navigates back to the lobby
(`appScreen.go("lobby")`). When the saved `preferred_language` is one
the UI also ships translations for, the active i18n locale switches
in-place so the rest of the session matches the new preference. The
write-through is also pushed into the shared `account` store so the
shell identity strip picks up the new `display_name` without a second
`user.account.get`.
`GameSummary` carries a `current_turn` field that the lobby UI does
not display directly — the in-game shell reads it from the same
payload to load the matching `user.games.report` for the map view
without an additional gateway call. See
[`game-state.md`](game-state.md) for the consumer's view.
## Application lifecycle
`Submit application` on a public-game card toggles an inline race-name
form on the same card (no overlay/modal infrastructure yet — the
in-game shell that introduces overlays lands later). On submit:
1. The page calls `submitApplication(client, gameId, raceName)` from
`src/api/lobby.ts`.
2. The wrapper builds an `ApplicationSubmitRequest` FlatBuffers
payload, posts it through `GalaxyClient.executeCommand`, decodes
the `ApplicationSubmitResponse`, and returns an
`ApplicationSummary` plain object.
3. The lobby page prepends the new application to the
`my applications` list and collapses the inline form. The page
does not refresh the public-games list — backend semantics are
that the public game still exists and is still in
`enrollment_open`.
4. Status starts as `pending`. When the owner approves, backend
creates a membership and the next refresh of `lobby.my.games.list`
surfaces the game in `my games`. When the owner rejects, the
application stays terminal in `my applications` with status
`rejected`.
## Invite lifecycle
A pending invite arrives in `pending invitations` either when the
inviter targets the user by id (`invited_user_id` is set) or when the
user redeems a code-based invite from somewhere outside the lobby.
The user can accept (`lobby.invite.redeem`) or decline
(`lobby.invite.decline`):
- **Accept** — the invite card disappears, the page refreshes
`my games`, and the freshly-joined game appears there.
- **Decline** — the invite card disappears. No membership is
created.
## Create-game form
The form posts `lobby.game.create` through the gateway with
`visibility="private"` hard-coded; the user surface never produces a
public game (FUNCTIONAL.md §3.3). Fields:
| Field | Visibility | Default | Notes |
| --------------------- | ---------------- | ------- | ------------------------------------------------------ |
| `game_name` | always | `""` | Non-empty client-side check |
| `description` | always | `""` | |
| `turn_schedule` | always | `0 0 * * *` | Plain text input, hint says "five-field cron" |
| `enrollment_ends_at` | always | `""` | `<input type="datetime-local">`, RFC 3339 on submit |
| `min_players` | Advanced toggle | `2` | `<details>` block |
| `max_players` | Advanced toggle | `8` | |
| `start_gap_hours` | Advanced toggle | `24` | |
| `start_gap_players` | Advanced toggle | `2` | |
| `target_engine_version` | Advanced toggle | `v1` | Falls back to `v1` if blank |
On success the create screen returns to the lobby
(`appScreen.go("lobby")`) and the new game shows up in `my games`
once the lobby's onMount has had a chance to refresh the list (the
lobby screen remounts on return, so its onMount re-fires).
## Errors
Lobby errors raised by the gateway carry a canonical code
(`invalid_request`, `subject_not_found`, `forbidden`, `conflict`,
`internal_error`). The `LobbyError` thrown by `lobby.ts` exposes the
code; the page maps it to the matching `lobby.error.<code>` i18n key
and falls back to the gateway-supplied message via
`lobby.error.unknown` for any unknown code.
## Why FlatBuffers on the TS side
The gateway encodes lobby payloads through `pkg/transcoder/lobby.go`
into FlatBuffers bytes; the browser must decode them with the same
schema. The TS integration ships:
- `flatbuffers` runtime dependency in `ui/frontend/package.json`;
- `make -C ui fbs-ts` driving `flatc --ts` to regenerate the bindings
from `pkg/schema/fbs/*.fbs` into `ui/frontend/src/proto/galaxy/fbs/`;
- a Vitest round-trip suite (`tests/lobby-fbs.test.ts`) that catches
binding drift in CI.
`user.account.get` decodes through the generated `AccountResponse`
table, so the lobby greeting works against a real local stack as well
as the mocked Playwright fixtures.