Proposal: App History Model — Depth-1 in Standalone¶
Status: Shipped · Last updated: 2026-07-16
Problem¶
Installed as a PWA, PlebChat should look and feel like an app: navigation happens through on-screen controls (mode pill, rails, back buttons we draw ourselves), and the browser's back/forward concept doesn't exist. Today an accidental edge swipe on iOS/Android "goes back" through the session history — mid-chat, mid-book — which reads as the app glitching, not as navigation. There is no API to disable the gesture in a standalone PWA.
Considered and rejected: hash-based routing¶
Hash routing (plebchat.me/#/read) was considered and rejected. The
decisive point: it doesn't address the problem. A hash change pushes a
session-history entry exactly like a path change — the swipe gesture works
on the history stack, not on the URL format, so hash URLs "go back"
precisely as often as path URLs. What hash routing buys is serving from
hosts that can't rewrite URLs (raw file://), which we don't need: GitHub
Pages is handled by the 404.html fallback, and a future desktop wrapper
serves over a custom protocol with its own fallback
(docs/proposals/tauri-desktop.md). Meanwhile it would cost the SEO
surface (sitemap.xml can't list fragments; the prerendered per-route
shells collapse to one) and permanently uglify every shared link. SvelteKit
2 supports it natively (router.type: 'hash'), so this is a considered
"no", not a "can't".
Decision: keep history at depth 1 when installed¶
The swipe can't be disabled, but it can be made a no-op: if the session history never grows past one entry, there is nothing to go back to and the gesture just rubber-bands. So:
- Installed (standalone display-mode): every in-app navigation REPLACES
the single history entry. URLs still change — deep links, PWA relaunch,
and the
handledInitialUrlmirrors are untouched — but no stack accumulates behind them. - In a browser tab: normal web manners. The brochure/marketing pages are ordinary web pages there; a visitor clicking Terms from the footer expects back to work. Push as usual.
The Android system back gesture consequently exits the installed app (depth-1 means back leaves) — which is standard app behavior and the intent: on-screen controls are the only in-app navigation.
Mechanics¶
One seam, $lib/shell/history.ts:
isStandalone()—(display-mode: standalone)media query plus iOS's legacynavigator.standaloneflag.navigate(url, opts?)— thegoto()wrapper every programmatic navigation goes through; addsreplaceState: truewhen standalone.installHistoryPolicy()— root-layout install; mirrors the policy onto anchor navigation by togglingdata-sveltekit-replacestateon<body>(SvelteKit reads link options from ancestor elements, and body covers portalled popovers too). Tracks display-mode changes.
Existing pieces that already conformed: the ?demo/?book=/?a=/
?project= query mirrors (all replaceState since birth) and the front
door's logged-in redirect. The settings page's back button keeps its
history.back() in browser tabs and falls through to navigate('/') when
there's no stack — i.e. always, when installed.
Belt-and-suspenders: overscroll-behavior: none at the document level
(was -y only) also disables Chrome/Android's swipe-to-navigate where the
browser honors it. iOS ignores it; iOS is covered by depth-1. The inner
html.ios-browser :where(*) containment rule is device-verified machinery
and stays y-only.
Binding rule¶
All programmatic in-app navigation goes through navigate() from
$lib/shell/history.ts, never raw goto() (the front door's
always-replace redirect is the one deliberate exception). Anchors need
nothing — they inherit from <body>.
Testing¶
e2e/history.test.ts: neither Playwright nor CDP can emulate the
display-mode media feature (Emulation.setEmulatedMedia does prefers-*
only — verified 2026-07-16), so the tests drive isStandalone()'s other
real branch: iOS's legacy navigator.standalone flag, injected via
addInitScript. Asserts: browser-tab clicks grow history.length;
emulated-standalone clicks don't; the body attribute tracks the mode.