fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s

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>
This commit is contained in:
Ilia Denisov
2026-05-27 10:17:57 +02:00
parent 8e8b34d112
commit 6fbab5417f
6 changed files with 307 additions and 85 deletions
@@ -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 {
+42 -11
View File
@@ -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.
-->
<script lang="ts">
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import { account } from "$lib/account-store.svelte";
import {
firstVisibleGamesScreen,
type LobbyNavState,
} from "$lib/lobby-nav";
import LobbyShell from "./lobby-shell.svelte";
onMount(() => {
// The shell's own $effect keeps the user on a valid sub-page if
// the resolved choice ever becomes invisible; we just kick off
// the redirect on first paint.
appScreen.go("games-recruitment");
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
onMount(async () => {
// Bootstrap the lobby client + fan-out; share the in-flight
// promise the games-* screens would otherwise kick off.
const client = await lobbyData.ensure();
// `account.ensure` is required for the paid-tier predicate.
// Failures (e.g. transient network blip) fall back to the
// non-paid path quietly — `account.current` simply stays null.
if (client !== null) {
try {
await account.ensure(client);
} catch {
/* not fatal — first-visible falls back to recruitment. */
}
}
const navState: LobbyNavState = {
hasMyGames: lobbyData.myGames.length > 0,
hasInvitations: lobbyData.invitations.length > 0,
isPaidOrDev:
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
};
// Guard against the user navigating away mid-await (signOut,
// browser Back). If the screen state has changed since mount,
// leave it alone.
if (appScreen.screen === "lobby") {
appScreen.go(firstVisibleGamesScreen(navState));
}
});
</script>
+66 -69
View File
@@ -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.
-->
<script lang="ts">
import { onMount, type Snippet } from "svelte";
import {
appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
type AppScreen,
} from "$lib/app-nav.svelte";
import { appScreen, isLobbySubScreen, type AppScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
import { lobbyData } from "$lib/lobby-data.svelte";
import {
firstVisibleGamesScreen,
visibleGamesSubScreens,
type GamesSubScreen,
type LobbyNavState,
} from "$lib/lobby-nav";
interface Props {
children: Snippet;
@@ -44,48 +48,22 @@ placeholder.
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number];
const SUB_LABELS: Record<GamesSubScreen, TranslationKey> = {
"games-active-past": "lobby.nav.games.active_past",
"games-recruitment": "lobby.nav.games.recruitment",
"games-invitations": "lobby.nav.games.invitations",
"games-private-games": "lobby.nav.games.private_games",
};
interface GamesSubItem {
id: GamesSubId;
labelKey: TranslationKey;
visible: () => boolean;
}
let navState = $derived.by<LobbyNavState>(() => ({
hasMyGames: lobbyData.myGames.length > 0,
hasInvitations: lobbyData.invitations.length > 0,
isPaidOrDev:
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
}));
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [
{
id: "games-active-past",
labelKey: "lobby.nav.games.active_past",
visible: () => lobbyData.myGames.length > 0,
},
{
id: "games-recruitment",
labelKey: "lobby.nav.games.recruitment",
visible: () => true,
},
{
id: "games-invitations",
labelKey: "lobby.nav.games.invitations",
visible: () => true,
},
{
id: "games-private-games",
labelKey: "lobby.nav.games.private_games",
visible: () =>
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
},
];
function visibleGamesSubs(): ReadonlyArray<GamesSubItem> {
return GAMES_SUBS.filter((sub) => sub.visible());
}
function firstVisibleGamesScreen(): AppScreen {
const visible = visibleGamesSubs();
// recruitment is unconditionally visible, so visible is never
// empty — but keep the fallback for type safety.
return visible[0]?.id ?? "games-recruitment";
}
let visibleSubs = $derived(visibleGamesSubScreens(navState));
let firstVisible = $derived(firstVisibleGamesScreen(navState));
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
@@ -94,11 +72,20 @@ placeholder.
}
function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen());
gotoScreen(firstVisible);
}
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
let activeGamesSub = $derived.by<GamesSubScreen | null>(() => {
const s = appScreen.screen;
if (
s === "games-active-past" ||
s === "games-recruitment" ||
s === "games-invitations" ||
s === "games-private-games"
) {
return s;
}
return null;
});
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
@@ -171,11 +158,11 @@ placeholder.
const screen = appScreen.screen;
if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen());
appScreen.go(firstVisible);
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen());
appScreen.go(firstVisible);
}
} else if (screen === "games-active-past") {
// Hide-when-empty is asymmetric: we only kick the user out if
@@ -184,7 +171,17 @@ placeholder.
// fresh navigation would bounce off this screen before the
// fan-out resolves.
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
appScreen.go(firstVisibleGamesScreen());
appScreen.go(firstVisible);
}
} else if (screen === "games-invitations") {
// Same asymmetric rule as `games-active-past`: hide-when-empty
// fires only after the fan-out resolves, so a fresh nav onto
// invitations does not bounce while the list is still loading.
// An invite redeemed / declined from this screen drops the
// only pending entry → user is moved to the first visible
// sub-page on the next tick.
if (!lobbyData.loading && lobbyData.invitations.length === 0) {
appScreen.go(firstVisible);
}
}
});
@@ -225,17 +222,17 @@ placeholder.
class="submenu desktop-only"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
{#each visibleSubs as sub (sub)}
<li>
<button
type="button"
class="nav-link sub"
class:active={appScreen.screen === sub.id}
aria-current={appScreen.screen === sub.id ? "page" : undefined}
onclick={() => gotoScreen(sub.id)}
data-testid="lobby-nav-{sub.id}"
class:active={appScreen.screen === sub}
aria-current={appScreen.screen === sub ? "page" : undefined}
onclick={() => gotoScreen(sub)}
data-testid="lobby-nav-{sub}"
>
{i18n.t(sub.labelKey)}
{i18n.t(SUB_LABELS[sub])}
</button>
</li>
{/each}
@@ -258,9 +255,9 @@ placeholder.
data-testid="lobby-nav-games-mobile"
>
{i18n.t("lobby.nav.games.mobile_toggle", {
label: activeGamesSub
? i18n.t(activeGamesSub.labelKey)
: i18n.t("lobby.nav.games.recruitment"),
label: i18n.t(
SUB_LABELS[activeGamesSub ?? "games-recruitment"],
),
})}
<span aria-hidden="true"></span>
</button>
@@ -270,18 +267,18 @@ placeholder.
role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
{#each visibleSubs as sub (sub)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub.id}
class:active={appScreen.screen === sub}
role="option"
aria-selected={appScreen.screen === sub.id}
onclick={() => selectMobileSub(sub.id)}
data-testid="lobby-nav-{sub.id}-mobile"
aria-selected={appScreen.screen === sub}
onclick={() => selectMobileSub(sub)}
data-testid="lobby-nav-{sub}-mobile"
>
{i18n.t(sub.labelKey)}
{i18n.t(SUB_LABELS[sub])}
</button>
</li>
{/each}