Zum Hauptinhalt springen

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

  1. 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. Excludes models/, 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, as type: 'module') and again after every deploy — for no offline gain, since HTML is never cached and every offline navigation lands on /offline anyway.
  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/index.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.

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_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.
  • version.pollInterval: 60s (sveltekit() options) 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 sendNotification, 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) — generate with bunx web-push generate-vapid-keys; the same pair must exist in Vercel env for prod. VAPID_SUBJECT is 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() 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

Geht dieses Pattern noch besser? Sag uns, wie.

Feedback geben