Files
galaxy-game/ui/docs/lobby.md
T
Ilia Denisov 6fbab5417f
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
Two follow-up nits on the F8-04b sidebar:

1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to
   `games-recruitment` unconditionally on mount. With games already
   in the player's roster the sidebar then highlighted the wrong
   sub-page. The resolver now awaits the lobby fan-out + account
   fetch, then hands off to the same `firstVisibleGamesScreen` helper
   the sidebar uses — so a fresh entry with games lands on
   `active-past`, the canonical-order fallback stays `recruitment`.

2. `games-invitations` was unconditionally visible in the sidebar.
   Now it follows the `active-past` rule: hidden until the
   pending-invites list reports >=1. The lobby shell's auto-kick
   effect treats it symmetrically — accepting / declining the last
   invite moves the player to the next visible sub-page once the
   fan-out has resolved.

Acceptance order in games-invitations-screen.acceptInvite was also
swapped to setMyGames-before-removeInvitation: both mutations land
in the same microtask, so the new auto-kick sees the freshly added
game in `myGames` when invitations drop to zero and routes the
player to `active-past` instead of bouncing through `recruitment`.

The visibility predicates and canonical order live in the new
`src/lib/lobby-nav.ts` pure helper, shared between the sidebar and
the resolver so they cannot disagree. Unit tests cover every
combination of (hasMyGames, hasInvitations, isPaidOrDev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:17:57 +02:00

273 lines
16 KiB
Markdown

# 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.