637 lines
19 KiB
Markdown
637 lines
19 KiB
Markdown
|
|
# 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.
|