Skip to main content

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/pwa is 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.

  1. Precache (cache-first, exact manifest): full hashed build, static/icons/, prerendered /offline. Excludes models/, blender-assets/, logo/, favicon.svg (iOS cache budget).
  2. HTML + __data.json: network-only, all routes, no exceptions. Every HTML response is personalized (palette transformPageChunk); __data.json carries root-layout session data. Offline navigation ⇒ precached /offline.
  3. Never intercepted at all: non-GET, /api/* (SSE lives there), accept: text/event-stream, cross-origin. Not proxying is strictly safer than proxying.
  4. Runtime cache admission (positive allowlist, all must hold): same-origin GET, response.ok, type === 'basic', no Set-Cookie, no no-store/private, no query strings — refuse, never normalize (cache-key normalization is how SvelteSpill-class cache deception is born).
  5. No automatic skipWaiting() — deploy-skew protection: the old worker keeps serving its own consistent cache until user-consented activation or all-tabs-closed. controllerchange reloads all tabs together.
  6. Kill switch: KILL_SWITCH = true in src/service-worker.ts + deploy ⇒ every installed client wipes caches, unregisters, and reloads as a plain browser tab on its next navigation (/service-worker.js is served Cache-Control: no-cache via vercel.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_WAITING posted 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: 60s powers updated.current from $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 SW push handler 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-delivery cron, 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's key in settings guard skips absent columns.
  • Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (sensitive), VAPID_SUBJECT — generate with bunx 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() wraps routeExternal (outbox insert + push fan-out) in waitUntil from @vercel/functions — the supported API; SvelteKit's platform object does not expose it. Off-Vercel it degrades to fire-and-forget. Note waitUntil work still dies at the function's maxDuration and has no retry — acceptable for a sub-second, 10-device-capped send.
  • The SW push handler always shows a notification (fixed): iOS/WebKit revokes the subscription after repeated "silent" pushes (a push event with no showNotification()), 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.
  • controllerchange reload 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 — pushsubscriptionchange is 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-cache on /service-worker.js is belt-and-suspenders: the default updateViaCache: '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 push event for a web_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: true payloads 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).

← Back to Blueprint

Think this pattern could be better? Tell us how.

Leave feedback