- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id `<code>`, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47
8.4 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 renders the caller's display_name (falling back
to the immutable user_name handle, then to a loading placeholder
while user.account.get resolves) 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 issues user.account.get through src/api/account.ts
and renders an identity read-out (immutable user_name, email)
plus a three-field form:
| 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 |
Free-text IANA name. Placeholder shows the browser's current zone; backend validates with time.LoadLocation. |
Save fires user.profile.update and/or user.settings.update
conditionally on which fields actually changed, then returns 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.
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.