- STATE/ROADMAP/config updated to reflect Phase 09.1 execution - 09-01 plan refreshed (gap-closure detail) - 09-02..09-05 plans updated during execution - Add untracked 09-06 plan + 01-01/01-02 PWA scaffolding plans (orphaned from earlier sessions) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-pwa-scaffolding | 01 | execute | 1 |
|
true |
|
|
Purpose: PWA-01, PWA-02, PWA-03 — make Pulse installable to a phone home screen and have the install land on /mobile in standalone (chromeless) mode. No service worker, no offline.
Output: public/manifest.json (new file) and an updated app/layout.tsx that adds the manifest reference and a Next 16 viewport export. Verifiable by curl http://localhost:3100/manifest.json and grep on app/layout.tsx.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @docs/superpowers/specs/2026-05-03-mobile-shell-design.md @CLAUDE.md @app/layout.tsx @app/globals.css @app/styles/brand.cssExisting app/layout.tsx shape (what is currently there):
- Imports
Metadatafromnext(already imported). - Exports
const metadata: Metadata = { title, description, icons: { icon: [...], shortcut: '/favicon.png', apple: '/wulff-logo.png' } }. - Does NOT currently export
viewport. Next 16 expects a separateviewportexport of typeViewportfromnext. - The metadata object already has an
iconsfield. Do NOT remove it; the PWAmanifestfield is added alongsideicons.
Brand colors (already defined in app/styles/brand.css):
- Wulf primary blue:
#0075AD(oklch0.540 0.136 233.3). This is thetheme_color. - Light shell background: white (
#FFFFFF). This is thebackground_color(the manifest only allows one; the light shell is the standard splash background).
Existing icon assets (in /public):
/public/wulff-logo.png— square PNG, used today asmetadata.icons.apple(apple-touch-icon)./public/favicon.png— square PNG./public/branding/wulf-mark.png— Wulf "W" mark, square PNG./public/branding/wulf-wordmark.png— Wulf "Pulse" wordmark. None of these have explicit pixel sizes verified, but they're used today and PWA install tools accept them with"sizes": "any".
Next.js 16 metadata API for manifest:
- The recommended way to reference a manifest is
metadata.manifest = '/manifest.json'in the metadata export. Next emits<link rel="manifest" href="/manifest.json" />automatically. This satisfies the spec wording (<link rel="manifest">) without hand-rolling the link tag. - Alternative: hand-roll
<link rel="manifest" href="/manifest.json" />inside<head>. Either approach is acceptable per the spec; prefermetadata.manifestbecause the file already uses the metadata API.
Next.js 16 viewport API:
- Import:
import type { Viewport } from 'next'. - Export:
export const viewport: Viewport = { ... }(separate frommetadata; Next 16 deprecatedmetadata.viewport). - The
viewportFitfield is camelCase in TS; Next emitsviewport-fit=coverin the rendered<meta name="viewport">tag. - Reasonable default fields:
width: 'device-width',initialScale: 1,viewportFit: 'cover'. Do NOT addmaximumScaleoruserScalable: false(accessibility).
Theme color / dark mode caveat:
- The manifest only allows one
theme_color. Use the Wulf blue#0075ADso the system UI tint matches the brand in both light and dark modes. - Optionally also add a viewport
themeColorarray withmedia: '(prefers-color-scheme: dark)'variants in the viewport export. This is a Next.js helper that emits<meta name="theme-color">per-scheme. NOT required for PWA-01..03; only add if it falls out naturally.
Verification commands the executor will use:
curl -sf http://localhost:3100/manifest.json | jq .(dev server must be running)grep -E "viewportFit|viewport-fit" app/layout.tsxgrep -E 'manifest:|rel="manifest"' app/layout.tsxnpx tsc --noEmit --pretty(must pass)
{
"name": "Pulse",
"short_name": "Pulse",
"description": "Wulf Consulting operations console — tickets, RMM, backups, and analytics on the go.",
"start_url": "/mobile",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#0075AD",
"background_color": "#FFFFFF",
"icons": [
{
"src": "/wulff-logo.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/branding/wulf-mark.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicon.png",
"sizes": "any",
"type": "image/png",
"purpose": "any"
}
]
}
Notes on the choices (so a reviewer doesn't have to ask):
nameandshort_nameboth "Pulse" — matches spec §4 verbatim.start_url: "/mobile"— spec §4 verbatim. The phone install lands on the mobile shell, not the desktop dashboard.scope: "/"— allow the standalone window to navigate anywhere in the app without falling out to the browser. (Spec doesn't specify; root scope is the safe default for an installed PSA console.)display: "standalone"— spec §4 verbatim. Chromeless app surface.orientation: "portrait"— phone-first per the spec's overall framing (§1, §2). Tablet landscape is explicit out-of-scope (§7).theme_color: "#0075AD"— Wulf brand blue fromapp/styles/brand.cssline 28 (--wulf-blue). Matches the--primarytoken in both light and dark modes (oklch values resolve to this brand blue, slightly lifted for dark).background_color: "#FFFFFF"— light shell background. Manifest only allows one value; the iOS/Android splash uses this. White matches Pulse's default theme on light devices and is acceptable on dark devices (brief flash, not a regression).icons— three entries reusing existing assets in/public. Using"sizes": "any"because the assets are not explicitly sized — install tools accept this for PNGs and pick the largest. Do NOT generate new icon PNGs in this task; reuse what's there. (A future polish phase can add density-specific 192/512 icons if install warns.)
Do NOT:
- Add a
serviceworkerfield (no SW in v1, spec §4 explicit). - Add
display_overrideorprefer_related_applications(not needed; not in spec). - Add
categoriesorlang(cosmetic; not in spec scope). - Reference
next-pwaor any plugin (forbidden by spec §4 and CLAUDE.md). - Edit any existing migration, lib/, or component file.
The file must be served directly by Next.js as a static asset — placing it at public/manifest.json makes it available at http://localhost:3100/manifest.json.
test -f public/manifest.json && jq -e '.name == "Pulse" and .short_name == "Pulse" and .display == "standalone" and .start_url == "/mobile" and .theme_color == "#0075AD" and .background_color == "#FFFFFF" and (.icons | length) >= 1' public/manifest.json
<acceptance_criteria>
- File public/manifest.json exists.
- jq -r .name public/manifest.json outputs Pulse.
- jq -r .short_name public/manifest.json outputs Pulse.
- jq -r .display public/manifest.json outputs standalone.
- jq -r .start_url public/manifest.json outputs /mobile.
- jq -r .theme_color public/manifest.json outputs #0075AD.
- jq -r .background_color public/manifest.json outputs #FFFFFF.
- jq -e '.icons | length >= 1' public/manifest.json exits 0.
- jq -e '.icons[0].src' public/manifest.json outputs a path beginning with / (e.g., /wulff-logo.png).
- File is valid JSON: jq empty public/manifest.json exits 0.
- No serviceworker field present: jq -e '.serviceworker == null' public/manifest.json exits 0.
- When dev server is running on port 3100: curl -sf http://localhost:3100/manifest.json exits 0 and the body equals the file contents.
</acceptance_criteria>
public/manifest.json exists, is valid JSON, contains the spec-mandated fields with the values above, references at least one icon from /public, and is reachable at http://localhost:3100/manifest.json when the dev server is running.
Change 1 — add manifest: '/manifest.json' to the existing metadata object.
The current export looks like:
export const metadata: Metadata = {
title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
icons: {
icon: [
{ url: "/favicon.png", sizes: "any" },
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
],
shortcut: "/favicon.png",
apple: "/wulff-logo.png",
},
};
Add a manifest field alongside icons. The result should be:
export const metadata: Metadata = {
title: "Pulse · Operations console",
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.png", sizes: "any" },
{ url: "/wulff-logo.png", sizes: "32x32", type: "image/png" },
],
shortcut: "/favicon.png",
apple: "/wulff-logo.png",
},
};
Next.js 16 emits <link rel="manifest" href="/manifest.json" /> automatically from this field — this satisfies the spec wording (<link rel="manifest"> from §4) without hand-rolling the tag.
Change 2 — add a Viewport import and a separate viewport export.
Update the next type import on line 1. The current import is:
import type { Metadata } from "next";
Change it to:
import type { Metadata, Viewport } from "next";
Then, immediately after the metadata export (and before export default function RootLayout(...)), add:
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#FFFFFF" },
{ media: "(prefers-color-scheme: dark)", color: "#0A0A0A" },
],
};
Notes on the choices:
viewportFit: 'cover'— the only field PWA-03 strictly requires. Emitsviewport-fit=coverin the rendered<meta name="viewport">tag. With this set, Phase 2's safe-area-inset utilities can paint behind the home indicator.width: 'device-width'andinitialScale: 1— standard mobile viewport defaults; they were absent before and Next 16 would warn without them. Adding them here removes the warning and makes the viewport explicit.themeColor— paired light/dark values for the system browser chrome (status bar tint). Light = white (matches manifestbackground_color); dark =#0A0A0A(close to the existing--backgroundoklch0.145 0 0inapp/globals.cssline 83). This is OPTIONAL for PWA-03 (the manifest'stheme_coloralready covers the install chrome), but it's a one-line improvement that ships better dark-mode rendering and costs nothing. Keep it; remove if it ever conflicts with a future per-page override.- Do NOT add
maximumScale,userScalable: false, orminimumScale— accessibility regression.
Do NOT:
- Touch the
RootLayoutfunction body. - Touch the
ThemeProvider,AppNavigation,CommandPalette,TaglineFooter,Toaster, orAuthProviderimports. - Add any
<head>JSX (no hand-rolled<link rel="manifest">tag — let Next emit it frommetadata.manifest). - Touch the
IBM_Plex_Sans/IBM_Plex_Monofont setup. - Add
'use client'— root layout is a server component. grep -q 'manifest: "/manifest.json"' app/layout.tsx && grep -q 'viewportFit: "cover"' app/layout.tsx && grep -q 'import type { Metadata, Viewport } from "next"' app/layout.tsx && grep -q 'export const viewport: Viewport' app/layout.tsx && npx tsc --noEmit --pretty 2>&1 | tee /tmp/tsc-out && ! grep -E "app/layout\.tsx.*error" /tmp/tsc-out <acceptance_criteria>grep -E '^import type \{ Metadata, Viewport \} from "next"' app/layout.tsxmatches one line (orMetadataandViewportboth appear in a single named-import line fromnext).grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsxmatches one line inside themetadataobject.grep -E '^export const viewport: Viewport = \{' app/layout.tsxmatches exactly one line.grep -E 'viewportFit:\s*"cover"' app/layout.tsxmatches one line inside theviewportexport.grep -E 'width:\s*"device-width"' app/layout.tsxmatches one line.grep -E 'initialScale:\s*1' app/layout.tsxmatches one line.- The
metadata.iconsobject is unchanged (still containsapple: "/wulff-logo.png"):grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsxmatches. - The
RootLayoutdefault export is unchanged:grep -E 'export default function RootLayout' app/layout.tsxmatches. - No
'use client'pragma added:! grep -E "^'use client'" app/layout.tsx. - Type check passes:
npx tsc --noEmit --prettyexits 0 (or, if other files have unrelated pre-existing errors, no error rows mentionapp/layout.tsx). - When dev server is running: viewing http://localhost:3100/ source contains
viewport-fit=cover(e.g.curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'exits 0). Optional manual check; not strictly required for the automated gate. </acceptance_criteria>app/layout.tsxexports bothmetadata(now withmanifest: "/manifest.json") andviewport(withviewportFit: "cover",width: "device-width",initialScale: 1, and themeColor light/dark pair). Type check passes. TheRootLayoutbody is unchanged. PWA-02 (manifest reference) and PWA-03 (viewport-fit=cover) are satisfied.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser ↔ static asset (/manifest.json) | Public client read of a manifest. No auth, no input. |
| Browser ↔ rendered HTML head | Public client read of <meta name="viewport"> and <link rel="manifest">. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-01-01 | Information Disclosure | public/manifest.json | accept | Manifest is intended to be world-readable per W3C Web App Manifest spec. Contains only public app branding (name, theme color, icon paths) — no secrets, no user data, no endpoints. |
| T-01-02 | Tampering | app/layout.tsx viewport export | accept | Server-rendered; no user input flows into the viewport meta. No injection vector. |
| T-01-03 | Denial of Service | manifest fetch | accept | Static file served by Next.js; same risk profile as /favicon.png. No new attack surface. |
Summary: No new threat surface introduced. manifest.json is public per W3C spec; viewport meta is a public client hint; no auth, data, or endpoints are introduced. ASVS-L1 baseline preserved.
</threat_model>
# Manifest is reachable and well-formed
curl -sf http://localhost:3100/manifest.json | jq -e '.name == "Pulse" and .display == "standalone" and .start_url == "/mobile"'
# Manifest is referenced from root layout (Next emits the link tag automatically)
curl -s http://localhost:3100/ | grep -E 'rel="manifest"'
# Viewport meta includes viewport-fit=cover
curl -s http://localhost:3100/ | grep -E 'viewport-fit=cover'
# Type check passes
npx tsc --noEmit --pretty
# No service worker file shipped (negative check — must be absent)
test ! -f public/sw.js && test ! -f public/service-worker.js
# next-pwa is not in dependencies
! grep -E '"next-pwa"' package.json
<success_criteria>
public/manifest.jsonexists with name "Pulse", short_name "Pulse", display "standalone", start_url "/mobile", theme_color "#0075AD", background_color "#FFFFFF", and at least one icon (PWA-01).app/layout.tsxreferences the manifest viametadata.manifest = "/manifest.json", which makes Next.js emit<link rel="manifest" href="/manifest.json" />in the rendered HTML head (PWA-02).app/layout.tsxexportsviewport: ViewportwithviewportFit: "cover"so the rendered<meta name="viewport">tag containsviewport-fit=cover(PWA-03).npx tsc --noEmit --prettypasses.- No service worker file or
next-pwadependency introduced. </success_criteria>