009ea560f9
Reshape the lobby UI from a single Overview into a two-level sidebar (games · profile · DEV synthetic-reports) with four games sub-panels (active-past · recruitment · invitations · private-games). Move the `create new game` button into the private-games panel, merge the applications section into recruitment cards as status chips, and add DEV-only synthetic-report loader as a top-level screen. Add a paid-tier gate at backend `lobby.game.create`: free callers get `403 forbidden` before the lobby service is invoked. The UI hides the private-games sub-panel + create button on free tier (DEV affordances flag overrides). Update every integration test that creates a game to use a new `testenv.PromoteToPaid` helper; add a new `TestLobbyFlow_FreeUserCreateGameForbidden`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
15 KiB
Markdown
265 lines
15 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`) | Always visible. |
|
|
| `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 resolves to
|
|
the first visible sub-panel in the canonical order (e.g. with no
|
|
games yet it lands on `recruitment`).
|
|
|
|
### `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.
|