The branch plan-puzzle-v1-tests had accumulated changes from a sibling thread (Plan Puzzle v1 backend + frontend) that hadn't been committed yet, plus a new src/lib/git/repo-url helper used by the Forgejo ingest path. Pick them all up so the working tree is clean and the branch reflects everything since its fork. Files (Plan Puzzle): src/app/actions/client-proposal.ts src/app/actions/owner-plan.ts src/app/actions/reschedule.ts src/app/actions/reschedule-helpers.ts (+ test) src/components/admin/project-form.tsx src/components/roadmap/roadmap-builder.tsx src/components/roadmap/timeline-view.tsx src/lib/cards/write-with-audit.ts (+ test) src/lib/roadmap/timeline.ts (+ test) New helper: src/lib/git/repo-url.ts (+ test) Verified: npm run build clean, 466/466 unit tests pass.
19 KiB
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:
<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
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
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:
/**
* 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
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/:
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
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:
{
"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
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:
/**
* 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
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:
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
npm run build
Expected: build succeeds.
- Step 3: Smoke-test the manifest
With npm run dev running, fetch the manifest:
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:
curl -sI http://localhost:3000/icons/icon-192.png | head -3
Expected: 200 OK; Content-Type: image/png.
And the service worker:
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
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
- On a real iPhone, open a public intake URL in Safari.
- Tap the Share button, then "Add to Home Screen".
- Confirm the icon is the brand tile from Task 1, not a generic Safari snapshot.
- Tap "Add" and open the new icon from the home screen.
- Confirm the URL bar is hidden (standalone display).
- 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
- On a real Android device or emulator, open the same intake URL in Chrome.
- Chrome should prompt "Add to Home Screen" or surface an install banner; if not, use the menu's "Install app" item.
- Confirm the icon renders correctly in the launcher.
- Open the installed app, run an interview.
Expected: same as iOS.
- Step 3: Offline behaviour
- On the device, enable airplane mode AFTER the wizard has loaded once.
- Open the installed PWA.
- 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
- Open the regular board at
/p/<clientSlug>/<projectSlug>on the same device, signed in. - Confirm the AppShell, sidebar, and theme still load exactly as before.
- Confirm no
/sw.jsis 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/scopein the manifest, or addif (!sameOrigin) return;guards insw.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
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
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
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
manifest.webmanifestis served from/and parses as valid JSON.- The four icons exist in
public/icons/and render correctly. sw.jsis served from/and registers without errors in Chrome DevTools' Application tab.- iOS Safari and Android Chrome install the PWA, show the correct icon, and open in standalone mode.
- After an airplane-mode cycle, the static shell renders and the submit step fails gracefully with a clear "you're offline" message.
- 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. - The signed-in flow is untouched.
npm run test:allandnpm run buildboth 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.