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

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_namelobby.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:

  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.