feat(frontend): PWA scaffolding — manifest, service worker, icons, theme (#108) #184

Merged
claude-bot merged 2 commits from feat/108-pwa-scaffolding into main 2026-07-17 02:44:28 +00:00
Contributor

Fixes #108. Final issue of the v3.5.0 (Frontend Platform & PWA) milestone, after #180, #181 and #183.

Why

index.html was bare — no manifest, no service worker, no theme-color, no icons. The app was neither installable nor offline-tolerant, and the theme was hard-coded to "dark" on first visit, ignoring prefers-color-scheme.

Service worker decisions worth reviewing

/api is NetworkOnly, 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. /auth and /public are 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. Pointing navigateFallback at 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, /public are 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 preview and the test suite would both have passed while the feature was broken in prod. Building the actual frontend-prod nginx image found:

  1. The CSP would have silently killed the theme fix. nginx serves 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 to public/theme-init.js loaded 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-pwa already registers the SW via an external /registerSW.js, so it was never at risk.
  2. The manifest was being served as application/octet-stream. nginx's bundled mime.types has no .webmanifest entry, and browsers can refuse a manifest on the wrong type — quietly costing installability, an explicit acceptance criterion here. Fixed with default_type on that one file. Note a types { ... } block would have been actively harmful: nginx only inherits types when the current level declares none, so it would have discarded the entire inherited mime map.

Theme

  • Resolve from prefers-color-scheme when no qb_theme is stored; an explicit stored choice still wins.
  • Applied before first paint, so light-mode users no longer get a dark flash.
  • Stopped persisting the theme on every mount. The old effect wrote qb_theme unconditionally — keeping that would have written the resolved value on the very first visit, so prefers-color-scheme would 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.
  • Media-scoped theme-color metas 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 into webapp/frontend/public/ — same filenames, no code change needed.

Acceptance criteria

  • Installable — manifest served as application/manifest+json with name/short_name/standalone/theme+background colours, 192+512 any icons and a maskable icon; sw.js + registerSW.js register cleanly. (Verified by asserting the generated artifacts and prod headers; no Lighthouse in the toolchain — see limitation below.)
  • Hard refresh offline shows the fallback, not a browser error — app shell + all 38 precache entries are cache-first with navigateFallback: /index.html.
  • No API response is ever served from SW cache as fresh0 precache entries reference /api, and both NetworkOnly rules survive into the built sw.js.
  • First visit with OS light mode → light theme; toggling and revisiting persists — 9 new tests in useTheme.test.jsx.
  • SW updates activate without users stuck on stale bundlesautoUpdate (skipWaiting/clientsClaim), rationale documented above and in vite.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.js with 38 precache entries.

Checked the built artifacts rather than the config: 0 /api precache entries, both NetworkOnly rules present, navigateFallback → index.html, denylist covers /api /auth /public, all 23 page chunks precached.

Against a real frontend-prod image: manifest type correct, 0 inline scripts, and theme-init.js/sw.js/registerSW.js/icons//dashboard all 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

Fixes #108. **Final issue of the v3.5.0 (Frontend Platform & PWA) milestone**, after #180, #181 and #183. ## Why `index.html` was bare — no manifest, no service worker, no `theme-color`, no icons. The app was neither installable nor offline-tolerant, and the theme was hard-coded to `"dark"` on first visit, ignoring `prefers-color-scheme`. ## Service worker decisions worth reviewing **`/api` is `NetworkOnly`, 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. `/auth` and `/public` are 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. Pointing `navigateFallback` at 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`, `/public` are 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 preview` and the test suite would both have passed while the feature was broken in prod. Building the actual **`frontend-prod` nginx image** found: 1. **The CSP would have silently killed the theme fix.** nginx serves `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 to `public/theme-init.js` loaded 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-pwa` already registers the SW via an external `/registerSW.js`, so it was never at risk. 2. **The manifest was being served as `application/octet-stream`.** nginx's bundled `mime.types` has no `.webmanifest` entry, and browsers can refuse a manifest on the wrong type — quietly costing installability, an explicit acceptance criterion here. Fixed with `default_type` on that one file. Note a `types { ... }` block would have been actively harmful: nginx only inherits `types` when the current level declares none, so it would have discarded the entire inherited mime map. ## Theme - Resolve from `prefers-color-scheme` when no `qb_theme` is stored; an explicit stored choice still wins. - Applied **before first paint**, so light-mode users no longer get a dark flash. - **Stopped persisting the theme on every mount.** The old effect wrote `qb_theme` unconditionally — keeping that would have written the resolved value on the very first visit, so `prefers-color-scheme` would 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. - Media-scoped `theme-color` metas 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 into `webapp/frontend/public/` — same filenames, no code change needed.** ## Acceptance criteria - [x] **Installable** — manifest served as `application/manifest+json` with name/short_name/standalone/theme+background colours, 192+512 `any` icons and a `maskable` icon; `sw.js` + `registerSW.js` register cleanly. *(Verified by asserting the generated artifacts and prod headers; no Lighthouse in the toolchain — see limitation below.)* - [x] **Hard refresh offline shows the fallback, not a browser error** — app shell + all 38 precache entries are cache-first with `navigateFallback: /index.html`. - [x] **No API response is ever served from SW cache as fresh** — **0** precache entries reference `/api`, and both `NetworkOnly` rules survive into the built `sw.js`. - [x] **First visit with OS light mode → light theme; toggling and revisiting persists** — 9 new tests in `useTheme.test.jsx`. - [x] **SW updates activate without users stuck on stale bundles** — `autoUpdate` (skipWaiting/clientsClaim), rationale documented above and in `vite.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.js` with **38 precache entries**. Checked the *built artifacts* rather than the config: 0 `/api` precache entries, both NetworkOnly rules present, `navigateFallback` → index.html, denylist covers `/api /auth /public`, all 23 page chunks precached. Against a real **`frontend-prod` image**: manifest type correct, **0 inline scripts**, and `theme-init.js`/`sw.js`/`registerSW.js`/icons/`/dashboard` all 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](https://claude.com/claude-code)
index.html was bare: no manifest, no service worker, no theme-color, no icons.
The app was neither installable nor offline-tolerant, and the theme defaulted to
a hard-coded "dark" on first visit, ignoring prefers-color-scheme.

Add vite-plugin-pwa with a manifest (standalone, maskable icon, apple-touch
icon) and placeholder QB monogram artwork — see the PR for where to swap in real
art.

Service worker:

- Precache the whole build, including all 23 route chunks from #105, so lazily
  loaded pages are cache-first and work offline once visited. The default 2 MiB
  cap is raised, otherwise the biggest chunks are silently dropped from the
  precache.
- Navigations fall back to the precached app shell, so a hard refresh with no
  network renders the real UI and the app's own error states rather than the
  browser's offline page. /api, /auth and /public are denylisted so the SW never
  hijacks the backend or the OIDC flow.
- /api and /auth/public are NetworkOnly, not NetworkFirst-with-a-short-TTL. Any
  cached copy of authenticated JSON can still be served as though fresh once the
  network drops, and 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.
- registerType autoUpdate (skipWaiting + clientsClaim). Chosen over an update
  prompt because the app is route-split: a tab left open across a deploy can ask
  for a lazy chunk whose hash no longer exists, so activating immediately keeps
  the precache and the running page on the same build.

Theme:

- Resolve the initial theme from prefers-color-scheme when no qb_theme is
  stored, keeping an explicit stored choice as an override.
- Apply it from an inline pre-paint script in index.html. ThemeProvider sets the
  class from an effect, which runs after first paint, so light-mode users saw a
  dark flash on every cold load.
- Stop persisting the theme on every mount. That would have written the resolved
  value on the first visit, so prefers-color-scheme would have been 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.
- Media-scoped theme-color metas so the browser chrome matches the rendered
  theme rather than always claiming the dark colour.

Verified in node:20: lint clean, 106/106 tests (9 new theme tests), build emits
manifest + sw.js with 37 precache entries. Checked the built artifacts rather
than the config: 0 precache entries reference /api, both NetworkOnly rules
survive, all 23 page chunks are precached, and a vite preview smoke check serves
all 8 PWA artifacts, 20/20 routes and 24/24 assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(frontend): make the PWA work under the production CSP and nginx (#108)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 44s
CI / Docker image build (pull_request) Successful in 57s
CI / Bot tests and audit (pull_request) Successful in 1m47s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m45s
CI / Backend migration, tests, and audit (pull_request) Successful in 5m42s
bd52911f1a
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>
claude-bot deleted branch feat/108-pwa-scaffolding 2026-07-17 02:44:29 +00:00
Sign in to join this conversation.
No description provided.