Files
galaxy-game/ui/frontend/src/lib/screens/lobby-shell.svelte
T
Ilia Denisov 6fbab5417f
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
fix(ui): F8-04b lobby — auto-expand first available games sub + hide empty invitations
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>
2026-05-27 10:17:57 +02:00

541 lines
14 KiB
Svelte

<!--
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` (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
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 between sub-pages never re-renders an empty
placeholder.
-->
<script lang="ts">
import { onMount, type Snippet } from "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;
}
let { children }: Props = $props();
const DEV_AFFORDANCES =
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
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",
};
let navState = $derived.by<LobbyNavState>(() => ({
hasMyGames: lobbyData.myGames.length > 0,
hasInvitations: lobbyData.invitations.length > 0,
isPaidOrDev:
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
}));
let visibleSubs = $derived(visibleGamesSubScreens(navState));
let firstVisible = $derived(firstVisibleGamesScreen(navState));
function gotoScreen(screen: AppScreen): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoGamesParent(): void {
gotoScreen(firstVisible);
}
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));
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) {
const trimmed = current.displayName.trim();
if (trimmed.length > 0) return trimmed;
if (current.userName.length > 0) return current.userName;
}
return i18n.t("lobby.account_loading");
});
function gotoProfile(): void {
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(firstVisible);
}
} else if (screen === "synthetic-reports") {
if (!DEV_AFFORDANCES) {
appScreen.go(firstVisible);
}
} 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(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);
}
}
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<div class="layout">
<header class="topbar">
<button
type="button"
class="identity"
onclick={gotoProfile}
data-testid="lobby-account-name"
>
{identityLabel}
</button>
<button type="button" class="logout" onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<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 visibleSubs as sub (sub)}
<li>
<button
type="button"
class="nav-link sub"
class:active={appScreen.screen === sub}
aria-current={appScreen.screen === sub ? "page" : undefined}
onclick={() => gotoScreen(sub)}
data-testid="lobby-nav-{sub}"
>
{i18n.t(SUB_LABELS[sub])}
</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: i18n.t(
SUB_LABELS[activeGamesSub ?? "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 visibleSubs as sub (sub)}
<li>
<button
type="button"
class="popover-item"
class:active={appScreen.screen === sub}
role="option"
aria-selected={appScreen.screen === sub}
onclick={() => selectMobileSub(sub)}
data-testid="lobby-nav-{sub}-mobile"
>
{i18n.t(SUB_LABELS[sub])}
</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={syntheticActive}
aria-current={syntheticActive ? "page" : undefined}
onclick={() => gotoScreen("synthetic-reports")}
data-testid="lobby-nav-synthetic-reports"
>
{i18n.t("lobby.nav.synthetic_reports")}
</button>
</li>
{/if}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
{@render children()}
</main>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: var(--font-mono);
color: var(--color-text);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
}
.identity {
font: inherit;
font-size: var(--text-md);
color: var(--color-text);
background: transparent;
border: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.identity:hover {
background: var(--color-surface-hover);
}
.logout {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.logout:hover {
background: var(--color-surface-hover);
}
.body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.sidebar {
flex: 0 0 14rem;
border-right: 1px solid var(--color-border-subtle);
padding: var(--space-4) var(--space-3);
background: var(--color-surface);
}
.top-list,
.submenu {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.submenu {
margin-left: var(--space-3);
margin-top: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-base);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
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);
}
.nav-link.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.content {
flex: 1 1 auto;
padding: var(--space-5) var(--space-6);
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;
}
.sidebar {
flex: 0 0 auto;
border-right: none;
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.top-list {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
}
.nav-link {
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>