From 1dda91fd966c2b23be2505a734254a7e47653933 Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 08:57:12 -0400 Subject: [PATCH] Play: app-safe in-app Back + canonicalize shareable URLs (Codex audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * In-app Back arrow is now deterministic on deep links: if there's in-app history it pops (history.back); otherwise it navigates to the parent screen (game → selection → hub) instead of leaving the site. Device Back stays native. * Canonicalize ?game/?v: unknown game → hub; an invalid v for the game (e.g. word&v=large or wordsearch&v=5) → the game's default, via replaceState so the URL is clean and local-storage keys/status match. Derived variant/size are also clamped so a bad URL can never crash the game with an invalid variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/play/+page.svelte | 31 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index b760d8c..b9d5888 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -12,8 +12,8 @@ let sp = $derived($page.url.searchParams); let game = $derived(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word'); let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select')); - let variant = $derived(sp.get('v') || '5'); - let wsSize = $derived(sp.get('v') || 'med'); + let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5'); + let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med'); let date = $state(''); let wordStatus = $state({ 5: null, 6: null }); @@ -78,11 +78,28 @@ return 'Play'; } - // Forward navigations push a history entry (via goto); the in-app Back button - // pops it (history.back), so it mirrors the device Back button exactly. - function openGame(g) { goto('/play?game=' + g); } - function pick(v) { goto('/play?game=' + game + '&v=' + v); } - function back() { history.back(); } + // Forward navigations push a history entry (via goto). The in-app Back button + // pops it (history.back) when we have in-app history, but on a direct deep link + // (no in-app history) it navigates to the parent screen instead of leaving the + // site. Device Back stays browser-native either way. + let appNavDepth = 0; + function openGame(g) { appNavDepth++; goto('/play?game=' + g); } + function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); } + function back() { + if (appNavDepth > 0) { appNavDepth--; history.back(); } + else goto(view === 'play' ? '/play?game=' + game : '/play'); + } + + // Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the + // game → its default (replaceState, so it doesn't add a history entry). + $effect(() => { + const g = sp.get('game'), v = sp.get('v'); + if (g && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; } + if (g && v) { + const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large']; + if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true }); + } + }); // Daily Word on mobile = a focused viewport: lock scroll + hide footer. Cleanup // ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.