wulf-pulse/.planning/phases/01-pwa-scaffolding/01-01-PLAN.md
lorentz 7b644e475f docs(planning): sync STATE/ROADMAP + capture untracked phase plans before master merge
- 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>
2026-05-14 07:30:51 -04:00

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-pwa-scaffolding 01 execute 1
public/manifest.json
app/layout.tsx
true
PWA-01
PWA-02
PWA-03
truths artifacts key_links
Visiting /manifest.json returns valid JSON with name 'Pulse', short_name 'Pulse', display 'standalone', start_url '/mobile'
The manifest theme_color matches the Wulf brand blue (#0075AD) and background_color matches the light shell
app/layout.tsx references the manifest so Chrome/Safari pick it up automatically (via metadata.manifest or <link rel='manifest'>)
app/layout.tsx exports a viewport object whose viewportFit is 'cover' so the rendered <meta name='viewport'> contains 'viewport-fit=cover'
Installing Pulse to a phone home screen launches a chromeless app that opens to /mobile
path provides contains
public/manifest.json Web App Manifest — name, short_name, display, start_url, theme/background, icons "display": "standalone"
path provides contains
app/layout.tsx Root layout exporting metadata (with manifest) and viewport (with viewportFit:'cover') viewportFit
from to via pattern
app/layout.tsx public/manifest.json metadata.manifest or <link rel='manifest'> manifest
from to via pattern
public/manifest.json /mobile start_url field "start_url"\s*:\s*"/mobile"
from to via pattern
app/layout.tsx (viewport export) rendered <meta name='viewport'> Next.js viewport export → viewport-fit=cover in DOM viewportFit
Add the PWA install surface: a valid Web App Manifest at `/manifest.json`, a manifest reference from the root layout, and a viewport export with `viewportFit: 'cover'` so future shell phases can paint behind the device home indicator.

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.css

Existing app/layout.tsx shape (what is currently there):

  • Imports Metadata from next (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 separate viewport export of type Viewport from next.
  • The metadata object already has an icons field. Do NOT remove it; the PWA manifest field is added alongside icons.

Brand colors (already defined in app/styles/brand.css):

  • Wulf primary blue: #0075AD (oklch 0.540 0.136 233.3). This is the theme_color.
  • Light shell background: white (#FFFFFF). This is the background_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 as metadata.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; prefer metadata.manifest because the file already uses the metadata API.

Next.js 16 viewport API:

  • Import: import type { Viewport } from 'next'.
  • Export: export const viewport: Viewport = { ... } (separate from metadata; Next 16 deprecated metadata.viewport).
  • The viewportFit field is camelCase in TS; Next emits viewport-fit=cover in the rendered <meta name="viewport"> tag.
  • Reasonable default fields: width: 'device-width', initialScale: 1, viewportFit: 'cover'. Do NOT add maximumScale or userScalable: false (accessibility).

Theme color / dark mode caveat:

  • The manifest only allows one theme_color. Use the Wulf blue #0075AD so the system UI tint matches the brand in both light and dark modes.
  • Optionally also add a viewport themeColor array with media: '(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.tsx
  • grep -E 'manifest:|rel="manifest"' app/layout.tsx
  • npx tsc --noEmit --pretty (must pass)
Task 1: Create public/manifest.json public/manifest.json - docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — exact field requirements) - .planning/REQUIREMENTS.md (PWA-01) - app/styles/brand.css (line 28: `--wulf-blue` is `#0075AD` — this is theme_color) - app/globals.css (lines 47-48: light theme `--background` is white; lines 82-83: dark theme background) - public/ directory listing — confirm `wulff-logo.png`, `favicon.png`, `branding/wulf-mark.png` exist Create `public/manifest.json` (new file) with exactly this JSON shape. Hand-write the file; do not use a generator.
{
  "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):

  • name and short_name both "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 from app/styles/brand.css line 28 (--wulf-blue). Matches the --primary token 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 serviceworker field (no SW in v1, spec §4 explicit).
  • Add display_override or prefer_related_applications (not needed; not in spec).
  • Add categories or lang (cosmetic; not in spec scope).
  • Reference next-pwa or 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.

Task 2: Add manifest reference and viewport export to app/layout.tsx app/layout.tsx - app/layout.tsx (current file — already exports `metadata: Metadata`, no `viewport` export yet) - docs/superpowers/specs/2026-05-03-mobile-shell-design.md (§4 — viewport-fit=cover wording) - .planning/REQUIREMENTS.md (PWA-02, PWA-03) - public/manifest.json (created in Task 1 — must exist before this task ships) Edit `app/layout.tsx` (do NOT create a new file). Two changes, both at the top of the file alongside the existing `metadata` export. The body of `RootLayout` is unchanged.

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. Emits viewport-fit=cover in 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' and initialScale: 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 manifest background_color); dark = #0A0A0A (close to the existing --background oklch 0.145 0 0 in app/globals.css line 83). This is OPTIONAL for PWA-03 (the manifest's theme_color already 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, or minimumScale — accessibility regression.

Do NOT:

  • Touch the RootLayout function body.
  • Touch the ThemeProvider, AppNavigation, CommandPalette, TaglineFooter, Toaster, or AuthProvider imports.
  • Add any <head> JSX (no hand-rolled <link rel="manifest"> tag — let Next emit it from metadata.manifest).
  • Touch the IBM_Plex_Sans / IBM_Plex_Mono font 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.tsx matches one line (or Metadata and Viewport both appear in a single named-import line from next).
    • grep -E 'manifest:\s*"/manifest\.json"' app/layout.tsx matches one line inside the metadata object.
    • grep -E '^export const viewport: Viewport = \{' app/layout.tsx matches exactly one line.
    • grep -E 'viewportFit:\s*"cover"' app/layout.tsx matches one line inside the viewport export.
    • grep -E 'width:\s*"device-width"' app/layout.tsx matches one line.
    • grep -E 'initialScale:\s*1' app/layout.tsx matches one line.
    • The metadata.icons object is unchanged (still contains apple: "/wulff-logo.png"): grep -E 'apple:\s*"/wulff-logo\.png"' app/layout.tsx matches.
    • The RootLayout default export is unchanged: grep -E 'export default function RootLayout' app/layout.tsx matches.
    • No 'use client' pragma added: ! grep -E "^'use client'" app/layout.tsx.
    • Type check passes: npx tsc --noEmit --pretty exits 0 (or, if other files have unrelated pre-existing errors, no error rows mention app/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.tsx exports both metadata (now with manifest: "/manifest.json") and viewport (with viewportFit: "cover", width: "device-width", initialScale: 1, and themeColor light/dark pair). Type check passes. The RootLayout body 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>

With dev server running (`npm run dev` → port 3100), all of the following must pass:
# 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.json exists 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.tsx references the manifest via metadata.manifest = "/manifest.json", which makes Next.js emit <link rel="manifest" href="/manifest.json" /> in the rendered HTML head (PWA-02).
  • app/layout.tsx exports viewport: Viewport with viewportFit: "cover" so the rendered <meta name="viewport"> tag contains viewport-fit=cover (PWA-03).
  • npx tsc --noEmit --pretty passes.
  • No service worker file or next-pwa dependency introduced. </success_criteria>
After completion, create `.planning/phases/01-pwa-scaffolding/01-01-SUMMARY.md` documenting: - Files created/modified (paths and one-line descriptions) - The exact `theme_color` and `background_color` values chosen (and why — Wulf brand blue + light shell background) - The viewport export shape (so Phase 2 knows it can rely on `viewport-fit=cover` being present) - Verification results (manifest curl, viewport grep, tsc result) - Any deviations from the plan and rationale