diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md
index 6620a3f..2edf145 100644
--- a/ui/docs/lobby.md
+++ b/ui/docs/lobby.md
@@ -61,12 +61,20 @@ contents follow the lobby-data store and the account tier:
| --------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `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. |
+| `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 resolves to
-the first visible sub-panel in the canonical order (e.g. with no
-games yet it lands on `recruitment`).
+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
diff --git a/ui/frontend/src/lib/lobby-nav.ts b/ui/frontend/src/lib/lobby-nav.ts
new file mode 100644
index 0000000..6a89667
--- /dev/null
+++ b/ui/frontend/src/lib/lobby-nav.ts
@@ -0,0 +1,66 @@
+// Pure helpers for the lobby `games` submenu.
+//
+// Both the sidebar (`lib/screens/lobby-shell.svelte`) and the bare-`lobby`
+// resolver (`lib/screens/lobby-screen.svelte`) need to agree on which
+// sub-screen is the "first available" — the one auto-expanded in the
+// sidebar and the redirect target when the user lands on the bare lobby
+// alias. Centralising the predicates here keeps the two surfaces from
+// drifting apart.
+//
+// The functions are intentionally pure: they take a resolved snapshot of
+// lobby / account state, return the visible sub-screen list (in canonical
+// order) and the first entry, and never touch stores or globals. Callers
+// derive the state from `lobbyData` / `account` and the DEV-affordances
+// flag.
+
+export type GamesSubScreen =
+ | "games-active-past"
+ | "games-recruitment"
+ | "games-invitations"
+ | "games-private-games";
+
+/**
+ * LobbyNavState is the resolved snapshot of the lobby state the
+ * games-submenu visibility rules depend on. Build it from the live
+ * stores at the call site; the helpers below stay pure on top.
+ */
+export interface LobbyNavState {
+ /** Player has at least one own / past game in `lobby.my-games.list`. */
+ readonly hasMyGames: boolean;
+ /** Player has at least one pending invite in `lobby.invites.list`. */
+ readonly hasInvitations: boolean;
+ /** Player is on a paid tier, or the build exposes DEV affordances. */
+ readonly isPaidOrDev: boolean;
+}
+
+/**
+ * visibleGamesSubScreens returns the games-submenu sub-screens that
+ * are currently visible, in canonical display order:
+ *
+ * 1. `games-active-past` — only when the player has games.
+ * 2. `games-recruitment` — always visible.
+ * 3. `games-invitations` — only when the player has pending invites.
+ * 4. `games-private-games` — only when paid-tier or DEV.
+ *
+ * `games-recruitment` is unconditional, so the returned array is
+ * always non-empty.
+ */
+export function visibleGamesSubScreens(
+ state: LobbyNavState,
+): readonly GamesSubScreen[] {
+ const out: GamesSubScreen[] = [];
+ if (state.hasMyGames) out.push("games-active-past");
+ out.push("games-recruitment");
+ if (state.hasInvitations) out.push("games-invitations");
+ if (state.isPaidOrDev) out.push("games-private-games");
+ return out;
+}
+
+/**
+ * firstVisibleGamesScreen returns the first entry from
+ * `visibleGamesSubScreens(state)`. Since `games-recruitment` is
+ * unconditional, the result is always defined.
+ */
+export function firstVisibleGamesScreen(state: LobbyNavState): GamesSubScreen {
+ return visibleGamesSubScreens(state)[0]!;
+}
diff --git a/ui/frontend/src/lib/screens/games-invitations-screen.svelte b/ui/frontend/src/lib/screens/games-invitations-screen.svelte
index 5199ce7..4338006 100644
--- a/ui/frontend/src/lib/screens/games-invitations-screen.svelte
+++ b/ui/frontend/src/lib/screens/games-invitations-screen.svelte
@@ -35,8 +35,14 @@ accept / decline actions. Accepted invites move the inviting game into
actionError = null;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
+ // Apply both mutations in the same microtask so the lobby
+ // shell's auto-kick-when-empty effect sees the freshly added
+ // game already in `myGames` when invitations drop to zero —
+ // otherwise the user briefly lands on `recruitment` between
+ // the two writes before the games list catches up.
+ const games = await listMyGames(client);
+ lobbyData.setMyGames(games);
lobbyData.removeInvitation(invite.inviteId);
- lobbyData.setMyGames(await listMyGames(client));
} catch (err) {
actionError = describeLobbyError(err);
} finally {
diff --git a/ui/frontend/src/lib/screens/lobby-screen.svelte b/ui/frontend/src/lib/screens/lobby-screen.svelte
index 8d1624a..884f84b 100644
--- a/ui/frontend/src/lib/screens/lobby-screen.svelte
+++ b/ui/frontend/src/lib/screens/lobby-screen.svelte
@@ -6,24 +6,55 @@ component is what the dispatcher renders when the active screen is
the historical `lobby` alias (e.g. a snapshot persisted before the
split, or programmatic `appScreen.go("lobby")` from non-shell code).
-The resolver navigates to the first visible games sub-page at mount
-time and renders a thin LobbyShell placeholder while the redirect
-runs. The destination depends on the account tier and on whether the
-caller has any games yet — both are computed by the shell, so we just
-pick `games-recruitment` here as the canonical landing (always
-visible) and let the shell's submenu logic surface the others.
+On mount the resolver awaits the lobby fan-out + account fetch, then
+hands off to `firstVisibleGamesScreen()` — the same helper the shell
+sidebar uses, so the auto-expanded sub-page picked here is the same
+one the sidebar will highlight (e.g. `games-active-past` when the
+player has games, `games-invitations` when there are pending invites
+but no games, etc.). While the data is in flight the user sees the
+lobby chrome with the i18n list-loading placeholder.
-->
diff --git a/ui/frontend/src/lib/screens/lobby-shell.svelte b/ui/frontend/src/lib/screens/lobby-shell.svelte
index fb8fe7a..815ffd5 100644
--- a/ui/frontend/src/lib/screens/lobby-shell.svelte
+++ b/ui/frontend/src/lib/screens/lobby-shell.svelte
@@ -8,8 +8,11 @@ F8-04b extends the sidebar to a two-level hierarchy:
- top-level items: `games` (with submenu), `profile`, and DEV-only
`synthetic test reports`;
- `games` submenu: `active-past` (hidden when the player has no games),
- `recruitment` (always), `invitations` (always), `private games`
- (paid-tier only; DEV overrides).
+ `recruitment` (always), `invitations` (hidden when no pending invites),
+ `private games` (paid-tier only; DEV overrides). The visibility rules
+ live in `lib/lobby-nav.ts` so the bare-`lobby` resolver
+ (`lobby-screen.svelte`) and this shell agree on the auto-expanded
+ sub-page.
Desktop (>640px): the submenu stays expanded as long as the active
screen is one of the games sub-panels. Mobile (≤640px, existing
@@ -24,16 +27,17 @@ placeholder.
-->