feat(ui): app-shell behaviour — restore validation, return-to-lobby, push

- A restored game that no longer exists (cancelled/removed/revoked) drops to
  the lobby with a toast instead of the in-game error state: game-state
  exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
  visibility-listener double-install on in-place game switches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 20:11:54 +02:00
parent be7f06e163
commit 80545e9f9d
5 changed files with 73 additions and 0 deletions
+27
View File
@@ -118,6 +118,17 @@ export class GameStateStore {
*/
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
error: string | null = $state(null);
/**
* notFound is the distinct "this game is not in the player's list"
* signal, set when `findGame` returns null (cancelled, removed, or
* access revoked). It is a clean flag the app-shell reads after
* `init` to drop a restored/stale game back to the lobby with a
* toast, rather than string-matching the `error` message. Transient
* network failures keep `notFound` false (they take the catch path)
* so they still surface the in-game error state for a retry. Reset
* to false at the start of every `setGame` / `advanceToPending`.
*/
notFound = $state(false);
/**
* currentTurn mirrors the engine's turn number for the running
* game (lifted from the lobby record on `setGame`). Phase 14
@@ -218,6 +229,7 @@ export class GameStateStore {
this.gameId = gameId;
this.status = "loading";
this.error = null;
this.notFound = false;
this.report = null;
this.wrapMode = await readWrapMode(this.cache, gameId);
@@ -229,6 +241,7 @@ export class GameStateStore {
if (summary === null) {
this.status = "error";
this.error = `game ${gameId} is not in your list`;
this.notFound = true;
return;
}
this.gameName = summary.gameName;
@@ -306,11 +319,13 @@ export class GameStateStore {
}
this.status = "loading";
this.error = null;
this.notFound = false;
try {
const summary = await this.findGame(this.gameId);
if (summary === null) {
this.status = "error";
this.error = `game ${this.gameId} is not in your list`;
this.notFound = true;
return;
}
this.gameName = summary.gameName;
@@ -558,6 +573,18 @@ export class GameStateStore {
private installVisibilityListener(): void {
if (typeof document === "undefined") return;
// Idempotent on re-init. In the single-URL app-shell a direct
// game → game switch (a push deep-link or a refresh-restore)
// re-runs `init` without unmounting the shell, so a naive
// `addEventListener` here would stack a second listener on every
// switch. Drop any previously-registered one first so exactly one
// stays live.
if (this.visibilityListener !== null) {
document.removeEventListener(
"visibilitychange",
this.visibilityListener,
);
}
const listener = (): void => {
if (document.visibilityState === "visible" && this.status === "ready") {
void this.refresh();