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.