Status: BUILT, 2026-07-03 (dev, uncommitted). Phases 0–2 of the mobile-app plan: manifest + icons (installable), service worker + offline fallback + update flow (closes the PRD "PWA" requirement), and web push as a fourth notification channel. Design settled by a 6-agent, 2-round cross-pollinated task force; delivery-mode verdicts below are its output.
Terminology. "PWA" here means the installable web app itself — v10r ships no store wrapper. The showcase at
/showcases/pwais the living demo of everything in this doc.
The delivery-mode verdict
| Option | Verdict | Why |
|---|---|---|
| Pure installable PWA | BUILT | Everything in-repo, reuses the whole stack, zero store tax; manifest alone is installable on Chrome (Lighthouse 11 dropped the SW requirement) and iOS 26 (share-sheet "Open as Web App" defaults on) |
| Native fifth client (Expo) | Blueprint only | See architecture/native-client.md |
| TWA (Play Store) | Parked | Only sanctioned wrapper; wraps this same PWA unchanged — but NOT maintenance-free (Google target-SDK policy forces ~annual re-bumps or new-user discoverability is lost) |
| Capacitor (both variants) | Rejected | Static bundle = SSR→SPA rewrite killing cookies/CSRF/form actions/passkeys(rpID)/Google OAuth (webview disallowed_useragent); remote-URL shell = vendor-discouraged, documented SW-update wedge |
| Tauri 2 mobile | Rejected | Same WebView constraints as Capacitor, less field evidence |
On iOS there is no sound wrapper at all: the installed PWA is the iOS app.
What ships where
| Piece | Location |
|---|---|
| Manifest (localized) | src/routes/manifest.webmanifest/+server.ts — dynamic so name/description follow the Paraglide cookie locale; the <link> in app.html carries crossorigin="use-credentials" (manifest fetches omit cookies without it); explicit Cache-Control: private, max-age=3600 (cookie-localized body; also pre-empts the hooks' no-store stamp) |
| Icons | static/icons/ — generated by bun run pwa:icons (scripts/pwa/generate-icons.ts, sharp) from static/logo/logo-hero.svg; maskable variants keep content in the 80% safe zone |
| Service worker | src/service-worker/index.ts (SvelteKit's $app/service-worker + $app/manifest + $app/env, no Workbox; own tsconfig.json extending $app/tsconfig/service-worker) + pure policy module #lib/pwa/sw-policy.ts with unit tests |
| Offline fallback | src/routes/offline/+page.svelte — top-level (outside the locale tree), prerendered, self-contained, tri-lingual inline |
| Update UX | #lib/components/shell/UpdatePrompt.svelte + Toast action slot (#lib/state/toast.svelte.ts show()) |
| Sign-out hygiene | #lib/pwa/sign-out.ts signOutAndFlush() (unsubscribes this device's push, flushes SW caches, then erases the confirmed-lost unsaved work; the Clear-Site-Data header stays as best-effort) |
| Unsaved work at sign-out | #lib/state/unsaved-work.ts — the user menu drains pending spreadsheet saves (bounded), names what is still unsaved in the sign-out confirm, and discards the drafts only after a successful sign-out. Forced exits (revoked/expired session) send no sign-out request, so drafts survive for recovery on the next sign-in |
| Web push | providers/web-push.ts + push_subscriptions table + POST/DELETE /api/notifications/push + #lib/pwa/push.ts + settings card |
| Standalone login ladder | auth/login/+page.svelte reorders under display-mode: standalone |
Service-worker caching contract (locked)
"Offline-capable" is scoped honestly: installable shell, instant repeat loads, branded /offline fallback — not offline CRUD.
- Precache the offline shell, not the build (cache-first): prerendered
/offline, the hashed assets its HTML loads (offlineShellAssets),static/icons/. Every other hashed file enters the cache on first use. Excludesmodels/,blender-assets/,logo/,favicon.svg(iOS cache budget). The full build was precached until 2026-10: ~2.2 MB gz of JS plus every font face, downloaded in the background by every first-time visitor (SvelteKit registers the worker for everyone, astype: 'module') and again after every deploy — for no offline gain, since HTML is never cached and every offline navigation lands on/offlineanyway. - HTML +
__data.json: network-only, all routes, no exceptions. Every HTML response is personalized (palettetransformPageChunk);__data.jsoncarries root-layout session data. Offline navigation ⇒ precached/offline. - Never intercepted at all: non-GET,
/api/*(SSE lives there),accept: text/event-stream, cross-origin. Not proxying is strictly safer than proxying. - Runtime cache admission (positive allowlist, all must hold): same-origin GET,
response.ok,type === 'basic', noSet-Cookie, nono-store/private, no query strings — refuse, never normalize (cache-key normalization is how SvelteSpill-class cache deception is born). - No automatic
skipWaiting()— deploy-skew protection: the old worker keeps serving its own consistent cache until user-consented activation or all-tabs-closed.controllerchangereloads all tabs together. - Kill switch:
KILL_SWITCH = trueinsrc/service-worker/index.ts+ deploy ⇒ every installed client wipes caches, unregisters, and reloads as a plain browser tab on its next navigation (/service-worker.jsis servedCache-Control: no-cacheviavercel.json, so propagation is one navigation, not 24h). This is the recovery lever for a shipped-broken worker.
Deploy skew without a full precache. SvelteKit's version is app-global, so every deploy replaces the cache (names key on VERCEL_GIT_COMMIT_SHA, version.name in the sveltekit() options in vite.config.ts). An old tab that needs a chunk the new deploy removed does not break: Kit's client catches the failed import, checks version.json, updates the worker and reloads natively (client.js, updated.check() → native_navigation).
Update flow
- Tier 1 (silent, default): pending update + user navigates ⇒ the client-side nav becomes a full-page load;
SKIP_WAITINGposted on the way. Rides a navigation the user already asked for. - Tier 2 (prompt): navigation-less sessions get ONE persistent "Reload" toast after 30 minutes, never while an input is focused.
version.pollInterval: 60s(sveltekit()options) powersupdated.currentfrom$app/state.
Web push contract
- One payload shape — Declarative Web Push JSON (
{web_push: 8030, notification: {title, body, navigate, lang}}): Safari/iOS 18.4+ renders it with no SW code; the SWpushhandler parses the identical JSON for Chrome/Android. Field-validation caveat: declarative rendering has little public field evidence — verify on-device before relying on it (the classic SW path is the guaranteed floor everywhere). - No PII in payloads (lock screens): title = brand, body = generic localized category line (
notif_push_*keys),navigate= same-origin path (/account/notifications?n=<id>); real content loads behind session auth after the tap. - Delivery is synchronous — fanned out beside SSE in
sendNotification, bypassing the outbox: the outbox drain is cron-driven on Vercel (notification-deliverycron, daily on the Hobby plan — Vercel rejects sub-daily crons there; the 15s interval scheduler only runs on persistent platforms). A push send is one fast HTTPS POST per device, so push is unaffected by the drain cadence. - Subscription lifecycle: per user+device rows (
notifications.push_subscriptions, endpoint unique), created only under a session, capped at 10/user (oldest evicted), pruned on push-service 404/410, removed for the device on sign-out, cascade-deleted with the account. - Types without a push settings column (
success,follow) never route to push — this mirrors telegram/discord (4-column precedent) and is intentional, not a bug: the router'skey in settingsguard skips absent columns. - Env:
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY(sensitive) — generate withbunx web-push generate-vapid-keys; the same pair must exist in Vercel env for prod.VAPID_SUBJECTis optional: it defaults to the https site URL (BETTER_AUTH_URL).
Production-hardening verdicts (research pass, 2026-07-03)
A resy (specs/vendor docs) + scout (field reports) pass audited the update and push paths. Outcomes — three fixes landed, two non-issues recorded so nobody "fixes" them later, one open question:
waitUntil()is load-bearing on Vercel (fixed): Vercel freezes the function instance once the response returns; un-awaited promises are documented as not guaranteed to finish.sendNotification()wrapsrouteExternal(outbox insert + push fan-out) inwaitUntilfrom@vercel/functions— the supported API; SvelteKit'splatformobject does not expose it. Off-Vercel it degrades to fire-and-forget. NotewaitUntilwork still dies at the function'smaxDurationand has no retry — acceptable for a sub-second, 10-device-capped send.- The SW
pushhandler always shows a notification (fixed): iOS/WebKit revokes the subscription after repeated "silent" pushes (a push event with noshowNotification()), and the server-side send still reports success when that happens; Chrome shows a generic "site updated in background" notice. Malformed payloads get a bland brand-titled fallback instead of an early return. controllerchangereload is one-shot (fixed): the event can fire repeatedly (DevTools "Update on reload" bug); an unguarded reload listener is the documented infinite-reload-loop pattern (Angular hit ~20 consecutive reloads).- NOT a gap —
pushsubscriptionchangeis deliberately unhandled: as of mid-2026 no engine fires it usefully for "the browser rotated my subscription" (WebKit: never dispatched; Firefox: bare event, bug open since 2018; Chrome 137: only on permission re-grant, with both subscription fields empty). Send-time 404/410 pruning is the effective substitute the ecosystem actually uses. Do not wire this event up expecting it to work. - NOT a gap —
Cache-Control: no-cacheon/service-worker.jsis belt-and-suspenders: the defaultupdateViaCache: 'imports'already bypasses the HTTP cache for the top-level SW script; the header guards CDN/edge layers. - Open question (device test): whether Safari 18.4+ also dispatches the legacy
pushevent for aweb_push-tagged payload it renders declaratively — secondary sources conflict; if it does, iOS shows a duplicate notification. Verify on a real iPhone before trusting the dual path. Related:mutable: truepayloads fire a different event (pushnotification), which this SW intentionally does not listen for. - Context for log-watchers: subscriptions rot fast (one vendor dataset: ~70% delivery when fresh, <10% at 9 months — push services rarely report dead endpoints proactively), and Chrome's Jan 2026 low-engagement auto-unsubscribe policy raises baseline 410 volume. Elevated pruning in the logs is expected behavior, not an incident. Push writes no delivery rows (outbox bypass); the per-send
[notifications] push …log line in Vercel function logs is the monitoring surface.
Standalone login ladder
In display-mode: standalone (installed app) the login page reorders: email OTP primary (completes fully in-app) → passkey (conditional-UI autofill already makes it effectively-first for enrolled users) → magic link demoted with a caveat → GitHub/Google visible-but-demoted ("opens in your browser"). Browser-mode login is unchanged.
Why: on iOS the emailed magic link opens in Safari's cookie jar, not the installed app's — the user gets logged into the wrong container (Outline hit exactly this in production; their fix converged on OTP). The verify page uses ONE <input autocomplete="one-time-code" maxlength="6"> styled as segments — per-box maxlength patterns truncate iOS QuickType's code insertion (it is not a paste event). An interrupted flow leaves a 10-minute localStorage marker; the login page offers a resume card.
Open item: Google OAuth inside the installed iOS app
Unresolved upstream (no primary Google statement). The disallowed_useragent webview ban most plausibly does NOT apply to real installed PWAs; the documented risks are a window.open-after-await quirk (moot — we use redirect flow) and the redirect completing in a Safari tab instead of the standalone window (a papercut, not a block). Gate before relying on it: on a real iPhone against prod, 10 cold-start Google sign-ins from the installed app — show the button by default only at ≥9/10 first-attempt success with sessions landing in the standalone context; otherwise standalone leans on OTP/passkey (which it already prefers).
Related
- architecture/native-client.md — the fifth-client seam (planned)
- architecture/multi-client-core.md — why push is "one more provider"
- ../stack/capabilities/pwa.md — platform capability reference
/showcases/pwa— the living demo