Play: app-safe in-app Back + canonicalize shareable URLs (Codex audit)

* 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) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 08:57:12 -04:00
parent d6015dd44e
commit 1dda91fd96
+24 -7
View File
@@ -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.