kanban-dev-webapp/docs/superpowers/plans/2026-09-21-phase-8b-pwa-shell.md

637 lines
19 KiB
Markdown
Raw Permalink Normal View History

# Phase 8b — PWA Shell Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `/intake/[token]` installable as a PWA on iOS Safari and Android Chrome — a real icon, a real splash, the wizard cached for offline viewing while a session is open. No new data, no new permissions, no new routes — just a thin wrapper around the public route Plan 8a shipped.
**Architecture:** Static assets under `public/` (manifest, icons, service worker) plus a few `<meta>` tags and a `<link rel="manifest">` in the existing `app/intake/layout.tsx`. The service worker is hand-written and intentionally tiny: cache-first for the static asset allowlist, network-first for `/intake/*` HTML, no IndexedDB, no background sync, no push.
**Tech Stack:** Next.js 16 (static asset serving) · Service Worker (vanilla JS) · vitest
**Spec:** `docs/superpowers/specs/2026-09-21-phase-8-public-intake-pwa-and-project-context-design.md` §9
**Prerequisite:** Phase 8a complete — `/intake/[token]` resolves and renders.
---
## Why hand-written, not a library
The PWA brief here is narrow: cache the wizard's static shell, get an icon
on the home screen, survive a flaky mobile network for the duration of an
in-progress interview. Workbox and similar libraries add reach (background
sync, push, IndexedDB, request routing policies), and the spec is explicit
that none of those are wanted here. A 60-line vanilla service worker does
the whole job.
## What this plan does NOT do
- Background sync of in-progress interviews across an app close.
- Push notifications.
- IndexedDB storage of transcript data.
- Cross-tab state.
- A native splash screen (the browser-supplied one is fine; it is the
same one every PWA gets).
---
## File structure
```
public/
├── manifest.webmanifest # NEW
├── sw.js # NEW (no TypeScript — public/ is served as-is)
└── icons/
├── source.svg # NEW (the one source of truth for the icons)
├── icon-192.png # NEW (committed binary)
├── icon-512.png # NEW (committed binary)
├── icon-maskable-512.png # NEW (committed binary)
└── apple-touch-icon.png # NEW (committed binary)
scripts/
└── build-icons.ts # NEW — generates PNGs from source.svg
src/app/intake/
└── layout.tsx # MODIFY — manifest link, theme color, iOS meta
```
---
## Task 1: The source SVG
**Files:**
- Create: `public/icons/source.svg`
- [ ] **Step 1: Write the source SVG**
`public/icons/source.svg`:
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2f6dad"/>
<g fill="#ffffff" font-family="-apple-system, system-ui, sans-serif" font-weight="700" text-anchor="middle">
<text x="256" y="310" font-size="240">!</text>
</g>
</svg>
```
The shape is intentionally simple: a rounded-square brand tile with a
single white exclamation mark. iOS and Android both render the icon on
the home screen at small sizes (40×40 to 60×60 logical px), and detail
above the brand colour does not survive that scale.
The exact brand colour `#2f6dad` matches the design system's default
brand colour (`DEFAULT_BRAND_COLOR` in `src/lib/brand-default.ts`). If
that ever changes, regenerate the icons.
- [ ] **Step 2: Commit**
```bash
git add public/icons/source.svg
git commit -m "feat(pwa): source SVG for the intake icon set"
```
---
## Task 2: The icon build script
**Files:**
- Create: `scripts/build-icons.ts`
- [ ] **Step 1: Install the SVG-to-PNG dependency**
```bash
npm install --save-dev @resvg/resvg-js
```
The `resvg-js` binding wraps the Rust `resvg` engine, which renders SVG to
PNG without a headless browser. It runs in any Node process, has no
system dependencies, and is a one-time build dependency that does not
ship in the runtime image.
If the install fails on this platform, the alternative is to commit the
icons once and skip the script entirely — `build-icons.ts` is convenience
infrastructure, not part of the deployed bundle.
- [ ] **Step 2: Write the build script**
`scripts/build-icons.ts`:
```ts
/**
* One-shot: render public/icons/source.svg into the four PNGs the manifest
* and iOS expect.
*
* Run with `npx tsx scripts/build-icons.ts` from the project root. The
* output is committed so the deployed bundle does not need `@resvg/resvg-js`
* at runtime.
*
* Sizes follow the manifest spec:
* - icon-192.png: 192×192, used by Android Chrome and most launchers
* - icon-512.png: 512×512, used by Android splash and Play Store
* - icon-maskable-512.png: 512×512 with the safe-zone padding Android
* applies when the icon is shown in a non-square mask (e.g. circle,
* squircle). The source SVG already has 96/512 = ~19% corner radius,
* which is within Android's safe zone.
* - apple-touch-icon.png: 180×180, used by iOS Safari's "Add to Home
* Screen". iOS does not honour the manifest for this; the link is in
* the layout's <head>.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Resvg } from "@resvg/resvg-js";
const root = join(process.cwd(), "public", "icons");
const svg = readFileSync(join(root, "source.svg"), "utf8");
const targets = [
{ file: "icon-192.png", size: 192 },
{ file: "icon-512.png", size: 512 },
{ file: "icon-maskable-512.png", size: 512 },
{ file: "apple-touch-icon.png", size: 180 },
];
for (const target of targets) {
const png = new Resvg(svg, {
fitTo: { mode: "width", value: target.size },
})
.render()
.asPng();
writeFileSync(join(root, target.file), png);
console.log(` + ${target.file} (${target.size}x${target.size})`);
}
console.log("Done.");
```
- [ ] **Step 3: Run it once**
```bash
npx tsx scripts/build-icons.ts
```
Expected output:
```
+ icon-192.png (192x192)
+ icon-512.png (512x512)
+ icon-maskable-512.png (512x512)
+ apple-touch-icon.png (180x180)
Done.
```
Verify the four PNGs exist in `public/icons/`:
```bash
ls -la public/icons/
```
Expected: `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`,
`apple-touch-icon.png` are all present alongside `source.svg`.
- [ ] **Step 4: Commit**
```bash
git add scripts/build-icons.ts package.json package-lock.json public/icons
git commit -m "feat(pwa): icon build script and committed PNG outputs"
```
---
## Task 3: The manifest
**Files:**
- Create: `public/manifest.webmanifest`
- [ ] **Step 1: Write the manifest**
`public/manifest.webmanifest`:
```json
{
"name": "Submit a request",
"short_name": "Submit",
"description": "Send a request to the developer without signing in.",
"start_url": "/intake/",
"scope": "/intake/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#2f6dad",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
```
`start_url` is `/intake/` (with the trailing slash) deliberately. The
browser will then redirect to whatever token the user last visited, OR
to the install-time URL if there is no history — either is fine. The
intake form is per-token anyway, and the developer will issue a fresh
link to a client who closes and re-opens the PWA after revocation.
- [ ] **Step 2: Commit**
```bash
git add public/manifest.webmanifest
git commit -m "feat(pwa): web manifest"
```
---
## Task 4: The service worker
**Files:**
- Create: `public/sw.js`
- [ ] **Step 1: Write the worker**
`public/sw.js`:
```js
/**
* The intake route's service worker.
*
* Deliberately small. Cache-first for the static asset allowlist (icons,
* manifest, the layout's CSS, the static fonts the intake route ships),
* network-first for /intake/* HTML. Cache capped at ~2 MB to keep storage
* pressure low on cheap phones.
*
* No background sync, no IndexedDB, no push. The PWA exists to (a) get an
* icon on the home screen and (b) keep the in-progress wizard usable
* across a flaky mobile network while it is open. That is the whole brief.
*/
const CACHE_NAME = "intake-v1";
const STATIC_ASSETS = [
"/",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
"/icons/apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
const sameOrigin = url.origin === self.location.origin;
if (!sameOrigin) return;
// /intake/* HTML — network-first, fall back to the cached shell only if the
// network is unreachable. The wizard's data lives on the server (PocketBase);
// we cannot serve stale answers.
if (url.pathname.startsWith("/intake/") && request.headers.get("accept")?.includes("text/html")) {
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match("/")))
);
return;
}
// Static asset allowlist — cache-first.
if (STATIC_ASSETS.includes(url.pathname)) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
)
);
return;
}
// Everything else: pass through. In particular, /api/public/intake and
// the intake Server Action must NOT be intercepted — a stale cached
// response there would corrupt an in-flight interview.
});
```
The cache name `intake-v1` is versioned so a future change to the static
asset allowlist triggers a one-time eviction on first load. The
`activate` handler drops any old caches immediately.
- [ ] **Step 2: Commit**
```bash
git add public/sw.js
git commit -m "feat(pwa): service worker for the intake route"
```
---
## Task 5: The manifest link in the intake layout
**Files:**
- Modify: `src/app/intake/layout.tsx`
- [ ] **Step 1: Add the manifest link and iOS meta**
`src/app/intake/layout.tsx`:
```tsx
import type { Metadata, Viewport } from "next";
export const metadata: Metadata = {
title: "Submit a request",
description: "Send a request to the developer without signing in.",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
title: "Submit a request",
statusBarStyle: "default",
},
};
export const viewport: Viewport = {
themeColor: "#2f6dad",
width: "device-width",
initialScale: 1,
};
/**
* The intake route's own layout, separate from src/app/layout.tsx.
*
* The public route must not ship the full client-board app shell, its fonts,
* its theme machinery or its analytics — those belong on a signed-in page.
* A service worker can cache a smaller payload faster, and offline, and
* there is no sidebar to render for an anonymous visitor anyway.
*
* Plan 8b fills in the PWA-specific bits: manifest link, theme color,
* iOS-specific meta tags, apple-touch-icon link. The service worker is
* registered from a tiny inline script in <body> below.
*/
export default function IntakeLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
</head>
<body>
{children}
<script
// Inline registration so there is no extra round trip and no
// client-component boundary needed. The service worker only
// intercepts /intake/* and the static asset allowlist, so the
// signed-in app shell is never affected even if registration
// fails on a browser that does not support service workers.
dangerouslySetInnerHTML={{
__html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function () {});
});
}
`,
}}
/>
</body>
</html>
);
}
```
The `<script>` registration is inline because it is six lines and the
service worker file path is stable. A separate `register-sw.tsx` client
component would buy nothing.
- [ ] **Step 2: Verify the build**
```bash
npm run build
```
Expected: build succeeds.
- [ ] **Step 3: Smoke-test the manifest**
With `npm run dev` running, fetch the manifest:
```bash
curl -sI http://localhost:3000/manifest.webmanifest | head -3
curl -s http://localhost:3000/manifest.webmanifest | head -10
```
Expected: 200 OK; the JSON body is the manifest from Task 3.
And the icon:
```bash
curl -sI http://localhost:3000/icons/icon-192.png | head -3
```
Expected: 200 OK; `Content-Type: image/png`.
And the service worker:
```bash
curl -sI http://localhost:3000/sw.js | head -3
```
Expected: 200 OK; `Content-Type: application/javascript` (or
`text/javascript` depending on Next's static-asset serving; either is
fine).
- [ ] **Step 4: Commit**
```bash
git add src/app/intake/layout.tsx
git commit -m "feat(pwa): manifest link, theme color and service worker registration"
```
---
## Task 6: Manual install verification
PWA behaviour is browser-state, not Node state. There is no automated
test for "does this install on iOS Safari?" — only a manual walkthrough.
**This task is work, not a summary. Run every step and act on what you see.**
- [ ] **Step 1: iOS Safari**
1. On a real iPhone, open a public intake URL in Safari.
2. Tap the Share button, then "Add to Home Screen".
3. Confirm the icon is the brand tile from Task 1, not a generic Safari
snapshot.
4. Tap "Add" and open the new icon from the home screen.
5. Confirm the URL bar is hidden (standalone display).
6. Run a short interview, kill the app, reopen it.
Expected: the wizard shell reloads from cache when offline; the network
request to PocketBase still fails gracefully (the page is honest about
it).
- [ ] **Step 2: Android Chrome**
1. On a real Android device or emulator, open the same intake URL in
Chrome.
2. Chrome should prompt "Add to Home Screen" or surface an install
banner; if not, use the menu's "Install app" item.
3. Confirm the icon renders correctly in the launcher.
4. Open the installed app, run an interview.
Expected: same as iOS.
- [ ] **Step 3: Offline behaviour**
1. On the device, enable airplane mode AFTER the wizard has loaded once.
2. Open the installed PWA.
3. The static shell renders. The wizard's "Send this" button shows an
error when tapped, because the network is down.
Expected: no silent failure; no white screen.
- [ ] **Step 4: Signed-in app is untouched**
1. Open the regular board at `/p/<clientSlug>/<projectSlug>` on the same
device, signed in.
2. Confirm the AppShell, sidebar, and theme still load exactly as before.
3. Confirm no `/sw.js` is loaded — the service worker is scoped to
`/intake/` and does not intercept other paths.
Expected: zero regression on the signed-in surface.
- [ ] **Step 5: Note any fixes**
If any step above did not behave as expected, fix it before declaring the
plan done. Common fixes:
- Icon colour mismatch → regenerate icons after fixing `source.svg`.
- Service worker scope too wide → fix the `start_url` / `scope` in the
manifest, or add `if (!sameOrigin) return;` guards in `sw.js`.
- Standalone display not engaging → confirm the manifest link is in the
served HTML head (`view-source:` on the public route should show
`<link rel="manifest" href="/manifest.webmanifest">`).
- [ ] **Step 6: Commit any fixes**
```bash
git add -A
git commit -m "fix(pwa): issues found during install verification"
```
---
## Task 7: Final test run
**This task is work, not a summary.**
- [ ] **Step 1: Run every test**
```bash
set -a && source .env.local && set +a && npm run test:all
```
Expected: PASS. This plan adds no test files; the count is unchanged
from 8a.
- [ ] **Step 2: Verify the build and the colour check**
```bash
npm run build
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app
```
Expected: build succeeds; the grep prints nothing. The single hex
literal in `src/app/intake/layout.tsx` (`#2f6dad`) is a CSS custom
property value for `theme-color`, not a colour class — it is the one
exception the colour check accepts and the grep's `*.tsx` scope
deliberately does not flag it because the grep does not match hex
literals in metadata exports.
---
## Definition of done
1. `manifest.webmanifest` is served from `/` and parses as valid JSON.
2. The four icons exist in `public/icons/` and render correctly.
3. `sw.js` is served from `/` and registers without errors in
Chrome DevTools' Application tab.
4. iOS Safari and Android Chrome install the PWA, show the correct
icon, and open in standalone mode.
5. After an airplane-mode cycle, the static shell renders and the
submit step fails gracefully with a clear "you're offline" message.
6. The full client-board app shell, fonts, and theme machinery are NOT
shipped on the intake route — verified by inspecting the response for
`/intake/[token]` in the browser's network panel.
7. The signed-in flow is untouched.
8. `npm run test:all` and `npm run build` both still pass.
## Deliberately not in this plan
- **Background sync.** A real "finish an interview offline, submit when
reconnected" feature. The spec deliberately excludes it; the wizard
cache exists for the in-progress session, not for offline submission.
- **Push notifications.** Outside the product. Web push needs a service
worker with a push subscription, a backend that can send to it, and a
user-visible reason to be on the device.
- **IndexedDB persistence of the transcript.** The interview lives on
the server side of the next-step call. Persisting it locally would
mean writing two state machines.
- **Cross-tab state.** Multiple tabs of the same PWA is a real edge case
on Android and not worth handling here.
- **A custom splash screen.** The browser's default is good enough for a
PWA that lives behind a token.
- **An offline fallback page.** When the network is down AND the cache
is empty, the service worker passes through and the browser shows its
own "no internet" page. That is fine; the wizard's UX has nothing to
add.
- **A "this site is a PWA" prompt.** Some browsers surface one. We do
not control that.