# 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 four `games` sub-panels, the profile sub-screen, the DEV-only synthetic-reports loader, the paid-tier gate, and the defaults baked into the create-game form. ## Shell Lobby pages, profile, and the synthetic-reports loader share a single chrome implemented in `lib/screens/lobby-shell.svelte`. The chrome mirrors the project site's VitePress layout: a two-level left sidebar, 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. Top-level sidebar items: | Item | Visibility | | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | | `games` | always; renders a submenu (see below) | | `profile` | always | | `synthetic test reports` | only when `VITE_GALAXY_DEV_AFFORDANCES === "true"` (DEV / dev-deploy bundles); stripped from prod by Vite | 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. Every sub-screen populates the same cache through `account.ensure(client)`, so navigating between panels 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 matching lobby-data cache (`lib/lobby-data.svelte.ts`) is cleared in the same path so the public-games / invitations / applications snapshots do not leak across sessions. 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")`). Clicking the active item is a no-op (mirrors the F8-02 idiom from issue #45). The sidebar collapses to a horizontal scrolling strip below 640px; the `games` item then renders as a dropdown labeled `games · {active-sub} ▾` (see [Mobile layout](#mobile-layout)). ## Games panels The `games` parent expands into a submenu in the canonical order below. Visibility predicates are evaluated per-render so the submenu contents follow the lobby-data store and the account tier: | Sub-panel | Source | Visibility | | --------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `active-past` | `lobby.my.games.list` | Visible only when the list is non-empty. Empty → the sub-panel is hidden entirely (no empty card surfaces). | | `recruitment` | `lobby.public.games.list` ⨝ `lobby.my.applications.list` | Always visible. Public games where the caller is **not** the owner; each card surfaces the caller's application status as a chip (`pending` / `approved` / `rejected` / `unknown`) when there is one. Stale `pending`/`approved` applications on closed games render as standalone "applied" cards; stale `rejected`/`unknown` ones are hidden. | | `invitations` | `lobby.my.invites.list` (status=`pending`) | Visible only when the pending-invites list is non-empty. Empty (or while the fan-out is still in flight) → the sub-panel is hidden, mirroring the `active-past` rule. | | `private games` | `lobby.my.games.list` filtered by `owner_user_id === me` ∧ `game_type === "private"` | Paid tier only (`account.entitlement.is_paid === true`). `VITE_GALAXY_DEV_AFFORDANCES` overrides for DEV bundles. | Clicking the `games` parent without choosing a sub-panel — or arriving on the bare `lobby` alias (a pre-split persisted snapshot, or a programmatic `appScreen.go("lobby")`) — resolves to the first visible sub-panel in the canonical order. The resolver awaits the `lobby.*.list` fan-out and `account` fetch before deciding, so a fresh entry with games already in the player's roster lands on `active-past`, an invitee-only account lands on `invitations`, and the deterministic fallback is `recruitment` (always visible). The predicates live in `src/lib/lobby-nav.ts` and are shared between this resolver and the sidebar's submenu, so the two surfaces never disagree. ### `recruitment` — inline application form `Submit application` on a recruitment card toggles an inline race-name form on the same card. The form is rendered when the caller either has no application for that game **or** the latest application status is `rejected` (so the caller can try again). On `pending` / `approved` the form is hidden — a single-line "your application is awaiting approval" / "your application was accepted" note replaces it. 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`. 3. The lobby-data store prepends the new application and the inline form collapses. The public-games snapshot is unchanged. 4. Status starts as `pending`. When the owner approves, backend creates a membership and the next refresh surfaces the game in `active-past` (with the membership) — the recruitment card stops showing the form because the application is `approved`. ### `private games` — create-game entry point The right-hand corner of the `private games` panel hosts the `create new game` button (`data-testid="lobby-create-button"`). It opens the `lobby-create` top-level screen. When the panel is hidden (free tier, no DEV override) the button is not in the DOM and the underlying `lobby.game.create` is rejected with `403 forbidden` by backend regardless of UI state — see [Tier gate](#tier-gate). ### Invite lifecycle (invitations) A pending invite arrives in `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 lobby-data store refreshes `lobby.my.games.list`, and the freshly-joined game appears in `active-past`. - **Decline** — the invite card disappears. No membership is created. ## Mobile layout The sidebar collapses to a horizontal scrolling strip below 640px (the breakpoint set by `lobby-shell.svelte`). On mobile the `games` item is replaced by a single `games · {active-sub} ▾` button. Tapping the button opens a popover (`role="listbox"`) listing every **visible** games sub-panel; tapping a sub-panel selects it and closes the popover. Tapping outside or pressing `Escape` closes the popover without changing the active page. Re-tapping the active sub-panel inside the popover is a no-op — the same idiom as the F8-02 turn-navigator fix in issue #45. Hidden sub-panels (e.g. `active-past` when the player has no games, `private games` on free tier without the DEV override) do not appear in the popover, mirroring the desktop submenu. ## Tier gate `lobby.game.create` is gated by the paid tier: - **UI**: the `private games` sub-panel and the `create new game` button are hidden from the sidebar / panel chrome when `account.entitlement.is_paid !== true`. `VITE_GALAXY_DEV_AFFORDANCES === "true"` flips both back on so the owner can exercise paid-only flows from a free-tier test account. - **Backend**: `POST /api/v1/user/lobby/games` checks `EntitlementProvider.IsPaid(ctx, userID)` before invoking `lobby.Service.CreateGame`. Free callers receive `403 {"error":{"code":"forbidden","message":"creating private games requires a paid subscription"}}`. The `lobby-create` screen catches the `forbidden` `LobbyError` and renders an inline message (`lobby.create.error.forbidden`); no redirect, no toast. Admin-driven public-game creation (`POST /api/v1/admin/games`) bypasses the gate. Known limitation: the `account` cache is not invalidated when an admin upgrades the user mid-session — the user has to log out and back in to see `private games` appear. The matching follow-up is out of scope for this change; the cache pattern lives in `account-store.svelte.ts::ensure`. ## Synthetic reports (DEV) `lib/screens/synthetic-reports-screen.svelte` lifts the old Overview dev-loader into its own top-level screen, surfaced only when `VITE_GALAXY_DEV_AFFORDANCES === "true"`. Reports are JSON files produced offline by the Go CLI in `tools/local-dev/legacy-report/`; loading one opens the map view against a synthetic snapshot. See `ui/docs/testing.md#synthetic-reports` for the workflow. The flag is statically evaluated by Vite, so prod bundles strip the whole screen out of the tree and the matching `synthetic-reports` AppScreen literal becomes unreachable; the shell's $effect re-routes a stale snapshot pointing at it to the first visible games sub-panel without surfacing an error. ## Profile sub-screen `lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of `games-*` / `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`| `` 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")`, which the shell resolves to the first visible games sub-panel). 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. ## 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 | `""` | ``, RFC 3339 on submit | | `min_players` | Advanced toggle | `2` | `
` 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 navigates to the `games-private-games` sub-panel so the freshly-created game shows up immediately (the lobby-data store refreshes on the next sub-panel mount). On failure the gateway error is rendered inline below the form via `lobby-create-error`; `forbidden` from the backend tier gate is translated to `lobby.create.error.forbidden` (paid-tier message) instead of the generic operation-forbidden text. ## 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; each page maps it to the matching `lobby.error.` i18n key and falls back to the gateway-supplied message via `lobby.error.unknown` for any unknown code. The `lobby-create` screen overrides `forbidden` to the dedicated paid-tier message (`lobby.create.error.forbidden`). ## 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 — the lobby greeting works against a real local stack as well as the mocked Playwright fixtures, and the entitlement projection (`account.entitlement.is_paid`) lights up the paid-tier sub-panels.