Files
galaxy-game/ui/docs/lobby.md
T
Ilia Denisov 2ecdecad1e feat(ui): lobby site-style sidebar + profile screen (#47)
- 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
2026-05-26 22:25:40 +02:00

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:

  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.