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.ts (built-in $service-worker, no Workbox) + 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; the Clear-Site-Data header stays as best-effort) |
| 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 (cache-first, exact manifest): full hashed
build,static/icons/, prerendered/offline. Excludesmodels/,blender-assets/,logo/,favicon.svg(iOS cache budget). - 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.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.
Accepted cost: SvelteKit's version is app-global — every deploy invalidates the whole precache (no per-file revisioning exists for the SSR path). The re-download is background; cache names key on VERCEL_GIT_COMMIT_SHA (kit.version.name).
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.
kit.version.pollInterval: 60spowersupdated.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
NotificationService, 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),VAPID_SUBJECT— generate withbunx web-push generate-vapid-keys; the same trio must exist in Vercel env for prod.
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.NotificationService.send()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