feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run

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>
This commit is contained in:
Ilia Denisov
2026-05-26 23:53:53 +02:00
parent 98d1fe6cae
commit 009ea560f9
44 changed files with 2486 additions and 1118 deletions
+351 -35
View File
@@ -1,36 +1,110 @@
<!--
Shared chrome for the post-login "site"-style pages the lobby
landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
Shared chrome for the post-login lobby pages and the profile screen.
Renders a left page-list sidebar (mirroring the project site's
VitePress layout) plus a top identity strip ("Player-xxxx" → opens
profile, logout). Children fill the right-hand column.
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).
Desktop (>640px): the submenu stays expanded as long as the active
screen is one of the games sub-panels. Mobile (≤640px, existing
horizontal-strip breakpoint): the `games` item becomes a dropdown
labeled "games · {active-sub} ▾"; tapping toggles the popover, tapping
outside or pressing Escape closes it, and re-selecting the active
sub-item is a no-op (mirrors the F8-02 idiom from issue #45).
The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty
placeholder: both screens populate the same cache through
`account.ensure(client)` and the shell renders the latest value.
store so navigating between sub-pages never re-renders an empty
placeholder.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { onMount, type Snippet } from "svelte";
import {
appScreen,
isLobbySubScreen,
LOBBY_SUB_SCREENS,
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";
type Page = "overview" | "profile";
import { lobbyData } from "$lib/lobby-data.svelte";
interface Props {
activePage: Page;
children: Snippet;
}
let { activePage, children }: Props = $props();
let { children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number];
interface GamesSubItem {
id: GamesSubId;
labelKey: TranslationKey;
visible: () => boolean;
}
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";
}
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoGamesParent(): void {
gotoScreen(firstVisibleGamesScreen());
}
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
});
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
let profileActive = $derived.by(() => appScreen.screen === "profile");
let syntheticActive = $derived.by(() => appScreen.screen === "synthetic-reports");
let identityLabel = $derived.by(() => {
const current = account.current;
if (current !== null) {
@@ -41,19 +115,79 @@ placeholder: both screens populate the same cache through
return i18n.t("lobby.account_loading");
});
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void {
gotoPage("profile");
gotoScreen("profile");
}
async function logout(): Promise<void> {
await session.signOut("user");
}
// Mobile dropdown state for the games submenu. Closed on subitem
// selection, click outside, Escape, and on tap of the toggle when
// already open.
let mobileMenuOpen = $state(false);
let mobileMenuEl: HTMLElement | null = null;
function toggleMobileMenu(): void {
mobileMenuOpen = !mobileMenuOpen;
}
function selectMobileSub(screen: AppScreen): void {
mobileMenuOpen = false;
gotoScreen(screen);
}
function handleMobileKeydown(event: KeyboardEvent): void {
if (event.key === "Escape" && mobileMenuOpen) {
mobileMenuOpen = false;
}
}
function handleDocumentPointerDown(event: PointerEvent): void {
if (!mobileMenuOpen) return;
if (mobileMenuEl !== null && event.target instanceof Node) {
if (!mobileMenuEl.contains(event.target)) {
mobileMenuOpen = false;
}
}
}
onMount(() => {
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleMobileKeydown);
return () => {
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleMobileKeydown);
};
});
// Persisted snapshot may restore the user onto `games-private-games`
// after a tier downgrade or onto `synthetic-reports` in a prod
// bundle that strips the DEV affordance. Resolve quietly to the
// first visible games sub-page instead of letting the dispatcher
// render an empty shell.
$effect(() => {
const screen = appScreen.screen;
if (screen === "games-private-games") {
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisibleGamesScreen());
}
} else if (screen === "games-active-past") {
// Hide-when-empty is asymmetric: we only kick the user out if
// the lobby-data store has actually reported zero games (not
// during the initial `loading=true` window). Otherwise a
// fresh navigation would bounce off this screen before the
// fan-out resolves.
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
appScreen.go(firstVisibleGamesScreen());
}
}
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
@@ -73,21 +207,116 @@ placeholder: both screens populate the same cache through
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul>
{#each PAGES as page (page.id)}
<ul class="top-list">
<li class="games-item">
<button
type="button"
class="nav-link parent"
class:active={gamesActive}
aria-current={gamesActive ? "page" : undefined}
onclick={gotoGamesParent}
data-testid="lobby-nav-games"
>
{i18n.t("lobby.nav.games")}
</button>
<!-- Desktop submenu: always expanded when in `games-*` -->
<ul
class="submenu desktop-only"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<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}"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
<!-- Mobile dropdown: only the active sub is shown as a
button, tap toggles the popover with the visible
subs. Re-tap on the active sub is a no-op. -->
<div
class="mobile-dropdown mobile-only"
bind:this={mobileMenuEl}
>
<button
type="button"
class="nav-link mobile-toggle"
class:active={gamesActive}
aria-haspopup="listbox"
aria-expanded={mobileMenuOpen}
onclick={toggleMobileMenu}
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"),
})}
<span aria-hidden="true"></span>
</button>
{#if mobileMenuOpen}
<ul
class="mobile-popover"
role="listbox"
aria-label={i18n.t("lobby.nav.games.aria_label")}
>
{#each visibleGamesSubs() as sub (sub.id)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub.id}
role="option"
aria-selected={appScreen.screen === sub.id}
onclick={() => selectMobileSub(sub.id)}
data-testid="lobby-nav-{sub.id}-mobile"
>
{i18n.t(sub.labelKey)}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</li>
<li>
<button
type="button"
class="nav-link"
class:active={profileActive}
aria-current={profileActive ? "page" : undefined}
onclick={gotoProfile}
data-testid="lobby-nav-profile"
>
{i18n.t("lobby.nav.profile")}
</button>
</li>
{#if DEV_AFFORDANCES}
<li>
<button
type="button"
class="nav-link"
class:active={activePage === page.id}
aria-current={activePage === page.id ? "page" : undefined}
onclick={() => gotoPage(page.screen)}
data-testid="lobby-nav-{page.id}"
class:active={syntheticActive}
aria-current={syntheticActive ? "page" : undefined}
onclick={() => gotoScreen("synthetic-reports")}
data-testid="lobby-nav-synthetic-reports"
>
{i18n.t(page.labelKey)}
{i18n.t("lobby.nav.synthetic_reports")}
</button>
</li>
{/each}
{/if}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
@@ -160,7 +389,8 @@ placeholder: both screens populate the same cache through
background: var(--color-surface);
}
.sidebar ul {
.top-list,
.submenu {
list-style: none;
margin: 0;
padding: 0;
@@ -169,6 +399,11 @@ placeholder: both screens populate the same cache through
gap: var(--space-1);
}
.submenu {
margin-left: var(--space-3);
margin-top: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
@@ -183,6 +418,16 @@ placeholder: both screens populate the same cache through
cursor: pointer;
}
.nav-link.sub {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
}
.nav-link.parent.active {
color: var(--color-text);
font-weight: var(--weight-medium);
}
.nav-link:hover {
background: var(--color-surface-hover);
color: var(--color-text);
@@ -200,6 +445,62 @@ placeholder: both screens populate the same cache through
max-width: 48rem;
}
.mobile-dropdown {
position: relative;
display: none;
}
.mobile-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
justify-content: space-between;
}
.mobile-popover {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
right: 0;
z-index: 5;
list-style: none;
margin: 0;
padding: var(--space-1);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.15));
}
.popover-item {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.popover-item:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.popover-item.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.mobile-only {
display: none;
}
@media (max-width: 640px) {
.body {
flex-direction: column;
@@ -210,7 +511,7 @@ placeholder: both screens populate the same cache through
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.sidebar ul {
.top-list {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
@@ -219,9 +520,24 @@ placeholder: both screens populate the same cache through
white-space: nowrap;
padding: var(--space-1) var(--space-3);
}
.games-item {
position: relative;
}
.desktop-only {
display: none;
}
.mobile-only,
.mobile-dropdown {
display: block;
}
.content {
padding: var(--space-4);
max-width: none;
}
/* The games-item's parent button is replaced by the mobile
dropdown toggle. */
.nav-link.parent {
display: none;
}
}
</style>