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.
10 KiB
Lobby UI
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
docs/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).
On mount it reads the caller's account through account.ensure(...)
(see 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 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:
- The page calls
submitApplication(client, gameId, raceName)fromsrc/api/lobby.ts. - The wrapper builds an
ApplicationSubmitRequestFlatBuffers payload, posts it throughGalaxyClient.executeCommand, decodes theApplicationSubmitResponse, and returns anApplicationSummaryplain object. - The lobby page prepends the new application to the
my applicationslist 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 inenrollment_open. - Status starts as
pending. When the owner approves, backend creates a membership and the next refresh oflobby.my.games.listsurfaces the game inmy games. When the owner rejects, the application stays terminal inmy applicationswith statusrejected.
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:
flatbuffersruntime dependency inui/frontend/package.json;make -C ui fbs-tsdrivingflatc --tsto regenerate the bindings frompkg/schema/fbs/*.fbsintoui/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.