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>
15 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 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).
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:
- The page calls
submitApplication(client, gameId, raceName)fromsrc/api/lobby.ts. - The wrapper builds an
ApplicationSubmitRequestFlatBuffers payload, posts it throughGalaxyClient.executeCommand, decodes theApplicationSubmitResponse, and returns anApplicationSummary. - The lobby-data store prepends the new application and the inline form collapses. The public-games snapshot is unchanged.
- Status starts as
pending. When the owner approves, backend creates a membership and the next refresh surfaces the game inactive-past(with the membership) — the recruitment card stops showing the form because the application isapproved.
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.
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 inactive-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 gamessub-panel and thecreate new gamebutton are hidden from the sidebar / panel chrome whenaccount.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/gameschecksEntitlementProvider.IsPaid(ctx, userID)before invokinglobby.Service.CreateGame. Free callers receive403 {"error":{"code":"forbidden","message":"creating private games requires a paid subscription"}}. Thelobby-createscreen catches theforbiddenLobbyErrorand 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).
On mount it reads the caller's account through account.ensure(...)
(see 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 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:
flatbuffersruntime dependency inui/frontend/package.json;make -C ui fbs-tsdrivingflatc --tsto regenerate the bindings frompkg/schema/fbs/*.fbsintoui/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.