From be7f06e1633b458bcfa633c1d3908d06bd4cf63c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 23 May 2026 20:07:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20screen-level=20history=20for=20the?= =?UTF-8?q?=20app-shell=20(Back=20=E2=86=92=20lobby)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the screen into browser history via SvelteKit shallow routing (pushState/replaceState with page.state) so Back/Forward move between screens while the URL stays at /game/. Overlays (game, lobby-create) push; lobby/login replace. A popstate→page.state effect syncs the store back without re-pushing (no loop); the boot stamp puts a restored overlay above the load entry so Back falls through to lobby. In-game view switches never touch history. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/app.d.ts | 8 ++++++- ui/frontend/src/lib/app-nav.svelte.ts | 30 +++++++++++++++++++++++++++ ui/frontend/src/routes/+page.svelte | 25 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/ui/frontend/src/app.d.ts b/ui/frontend/src/app.d.ts index 45ffc10..3b7f826 100644 --- a/ui/frontend/src/app.d.ts +++ b/ui/frontend/src/app.d.ts @@ -3,7 +3,13 @@ declare global { const __APP_VERSION__: string; namespace App { - // future-phase types added later + // Shallow-routing state for the single-URL app-shell: the screen + // (and active game) live in `page.state` so browser Back/Forward + // move between screens while the address bar stays at /game/. + interface PageState { + screen?: "login" | "lobby" | "lobby-create" | "game"; + gameId?: string | null; + } } } diff --git a/ui/frontend/src/lib/app-nav.svelte.ts b/ui/frontend/src/lib/app-nav.svelte.ts index 7065357..66107ec 100644 --- a/ui/frontend/src/lib/app-nav.svelte.ts +++ b/ui/frontend/src/lib/app-nav.svelte.ts @@ -20,6 +20,8 @@ // in the shell via SvelteKit shallow routing; this module is the source of // truth, history only mirrors it. +import { pushState, replaceState } from "$app/navigation"; + export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; export type GameView = @@ -158,6 +160,34 @@ class AppScreenStore { this.#gameId = null; } persist(); + this.#syncHistory(); + } + + /** + * syncFromHistory applies a screen restored from browser history (a + * Back/Forward popstate) WITHOUT pushing a new entry. An absent/unknown + * screen (the load entry beneath an overlay) falls back to lobby. + */ + syncFromHistory(screen: AppScreen | undefined, gameId: string | null): void { + const next = + screen !== undefined && APP_SCREENS.includes(screen) ? screen : "lobby"; + this.#screen = next; + this.#gameId = next === "game" ? gameId : null; + persist(); + } + + // Mirror the screen into browser history via shallow routing (the URL is + // unchanged — the address bar stays at /game/). Overlays (game, + // lobby-create) push a new entry so browser Back returns to the lobby + // beneath; lobby/login replace in place. + #syncHistory(): void { + if (typeof window === "undefined") return; + const state: App.PageState = { screen: this.#screen, gameId: this.#gameId }; + if (this.#screen === "game" || this.#screen === "lobby-create") { + pushState("", state); + } else { + replaceState("", state); + } } } diff --git a/ui/frontend/src/routes/+page.svelte b/ui/frontend/src/routes/+page.svelte index 4bfa9cd..091162b 100644 --- a/ui/frontend/src/routes/+page.svelte +++ b/ui/frontend/src/routes/+page.svelte @@ -12,6 +12,31 @@ import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; import GameShell from "$lib/game/game-shell.svelte"; + import { pushState } from "$app/navigation"; + import { page } from "$app/state"; + + // Screen-level browser history (Back → lobby) without changing the URL. + // On the first authenticated render, stamp a restored overlay (game / + // lobby-create) on top of the load entry so Back falls through to lobby. + let historyStamped = $state(false); + $effect(() => { + if (session.status === "authenticated" && !historyStamped) { + historyStamped = true; + if (appScreen.screen === "game" && appScreen.gameId !== null) { + pushState("", { screen: "game", gameId: appScreen.gameId }); + } else if (appScreen.screen === "lobby-create") { + pushState("", { screen: "lobby-create" }); + } + } + }); + + // Sync the store from history on Back/Forward (popstate updates + // `page.state`). Skipped until the baseline is stamped so it never + // clobbers the restored screen on first render. + $effect(() => { + if (!historyStamped) return; + appScreen.syncFromHistory(page.state.screen, page.state.gameId ?? null); + }); {#if session.status === "authenticated"}