a679d9cdcb
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.
177 lines
10 KiB
Markdown
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.
|