feat(ui): screen-level history for the app-shell (Back → lobby)

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) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 20:07:03 +02:00
parent b6770d394c
commit be7f06e163
3 changed files with 62 additions and 1 deletions
+7 -1
View File
@@ -3,7 +3,13 @@ declare global {
const __APP_VERSION__: string; const __APP_VERSION__: string;
namespace App { 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;
}
} }
} }
+30
View File
@@ -20,6 +20,8 @@
// in the shell via SvelteKit shallow routing; this module is the source of // in the shell via SvelteKit shallow routing; this module is the source of
// truth, history only mirrors it. // truth, history only mirrors it.
import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
export type GameView = export type GameView =
@@ -158,6 +160,34 @@ class AppScreenStore {
this.#gameId = null; this.#gameId = null;
} }
persist(); 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);
}
} }
} }
+25
View File
@@ -12,6 +12,31 @@
import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import GameShell from "$lib/game/game-shell.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);
});
</script> </script>
{#if session.status === "authenticated"} {#if session.status === "authenticated"}