Skip to content

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 handledInitialUrl mirrors 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 legacy navigator.standalone flag.
  • navigate(url, opts?) — the goto() wrapper every programmatic navigation goes through; adds replaceState: true when standalone.
  • installHistoryPolicy() — root-layout install; mirrors the policy onto anchor navigation by toggling data-sveltekit-replacestate on <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.