feat(frontend): PWA scaffolding — manifest, service worker, icons, theme (#108) #184
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/108-pwa-scaffolding"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #108. Final issue of the v3.5.0 (Frontend Platform & PWA) milestone, after #180, #181 and #183.
Why
index.htmlwas bare — no manifest, no service worker, notheme-color, no icons. The app was neither installable nor offline-tolerant, and the theme was hard-coded to"dark"on first visit, ignoringprefers-color-scheme.Service worker decisions worth reviewing
/apiisNetworkOnly, not NetworkFirst-with-a-short-TTL. The issue asked for "network-first with a short timeout and no long-lived cache", but its own acceptance criterion is that no API response is ever served from SW cache as fresh — and any cached copy of authenticated JSON can be served as though current once the network drops. For session-scoped data that means showing one user's campaigns after a logout or an expiry. NetworkOnly is the only strategy that cannot do that. Offline, API calls simply fail and the app shows its normal error state./authand/publicare NetworkOnly too, so the OIDC flow is never intercepted.Navigations fall back to the precached app shell, not a static
offline.html. For an SPA this is strictly better: a hard refresh offline renders the real UI and the app's own error states, and client-side routes still work. PointingnavigateFallbackat a static offline page would break offline routing, and a separate page would only ever be reachable if the shell itself were evicted./api,/auth,/publicare denylisted so the SW never hijacks the backend.registerType: autoUpdate(skipWaiting + clientsClaim) over an update prompt — the app is route-split now (#105), so a tab left open across a deploy can request a lazy chunk whose hash no longer exists. Activating immediately keeps the precache and the running page on the same build.All 23 route chunks from #105 are precached, so lazily-loaded pages are cache-first and work offline once visited. The default 2 MiB precache cap is raised, or the biggest chunks (the wiki's markdown bundle) get silently dropped.
Two production-only bugs caught by verifying against the real image
vite previewand the test suite would both have passed while the feature was broken in prod. Building the actualfrontend-prodnginx image found:script-src 'self', which blocks inline scripts — and my pre-paint theme script was inline. It worked in dev, in preview, and in tests, and would have done nothing in production: the dark flash would have survived the change meant to remove it. Fixed by moving it topublic/theme-init.jsloaded via<script src>, which leaves the CSP untouched (no'unsafe-inline') and avoids a CSP hash — that would break just as silently on any whitespace change.vite-plugin-pwaalready registers the SW via an external/registerSW.js, so it was never at risk.application/octet-stream. nginx's bundledmime.typeshas no.webmanifestentry, and browsers can refuse a manifest on the wrong type — quietly costing installability, an explicit acceptance criterion here. Fixed withdefault_typeon that one file. Note atypes { ... }block would have been actively harmful: nginx only inheritstypeswhen the current level declares none, so it would have discarded the entire inherited mime map.Theme
prefers-color-schemewhen noqb_themeis stored; an explicit stored choice still wins.qb_themeunconditionally — keeping that would have written the resolved value on the very first visit, soprefers-color-schemewould be consulted exactly once and a user who never touched the toggle would stay pinned to whatever their OS said that day. Only an explicit toggle writes the override now.theme-colormetas so the browser chrome matches the theme actually rendered.Icons
Placeholder QB monogram artwork (indigo
#4f46e5), generated with ImageMagick:pwa-192,pwa-512, a maskable 512 with its content inside the 80% safe zone (launchers crop otherwise), a 180px apple-touch-icon, and a favicon. Swap real art intowebapp/frontend/public/— same filenames, no code change needed.Acceptance criteria
application/manifest+jsonwith name/short_name/standalone/theme+background colours, 192+512anyicons and amaskableicon;sw.js+registerSW.jsregister cleanly. (Verified by asserting the generated artifacts and prod headers; no Lighthouse in the toolchain — see limitation below.)navigateFallback: /index.html./api, and bothNetworkOnlyrules survive into the builtsw.js.useTheme.test.jsx.autoUpdate(skipWaiting/clientsClaim), rationale documented above and invite.config.js.Verification
node:20 (matching CI): lint exit 0; 106/106 tests across 14 files (was 97; +9 theme tests); build emits manifest +
sw.jswith 38 precache entries.Checked the built artifacts rather than the config: 0
/apiprecache entries, both NetworkOnly rules present,navigateFallback→ index.html, denylist covers/api /auth /public, all 23 page chunks precached.Against a real
frontend-prodimage: manifest type correct, 0 inline scripts, andtheme-init.js/sw.js/registerSW.js/icons//dashboardall 200.Honest limitation: no Lighthouse or headless browser exists in this toolchain, so "installable" is verified by asserting every input Lighthouse checks (manifest fields, icon sizes/purposes, SW registration, correct MIME) rather than by running Lighthouse itself. Likewise the offline behaviour is verified from the generated precache manifest and strategies, not by toggling a real browser offline. Worth one manual Lighthouse run before release.
🤖 Generated with Claude Code
Verifying against the real frontend-prod image rather than vite preview turned up two problems that only exist in production: The pre-paint theme script was inline, and nginx serves the app with `script-src 'self'` (nginx.conf), which blocks inline scripts. It worked in dev, in vite preview and in the test suite, and would have silently done nothing in production — the dark flash would have survived the fix meant to remove it. Move it to public/theme-init.js and load it with a plain <script src>. That keeps the CSP untouched rather than weakening it with 'unsafe-inline', and avoids a CSP hash, which would break just as silently on any whitespace change. vite-plugin-pwa already registers the service worker via an external /registerSW.js, so it was never at risk. nginx's bundled mime.types has no entry for .webmanifest, so the manifest was served as application/octet-stream, which browsers can refuse — quietly costing installability. Set the type for that one file via default_type; a types{} block would have discarded the entire inherited mime.types map, since nginx only inherits types when the current level declares none. Verified against a built frontend-prod image: manifest is application/manifest+json, the served index.html has zero inline scripts (all three are external and CSP-clean), and theme-init.js, sw.js, registerSW.js, the icons and the SPA routes all serve 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>