# PWA — Installable App Shell

> **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](./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).

## Related

- [architecture/native-client.md](./architecture/native-client.md) — the fifth-client seam (planned)
- [architecture/multi-client-core.md](./architecture/multi-client-core.md) — why push is "one more provider"
- [../stack/capabilities/pwa.md](../stack/capabilities/pwa.md) — platform capability reference
- `/showcases/pwa` — the living demo
