# 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`| `<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")`, 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           | `""`    | `<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 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.<code>` 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.
