---
name: frontend-pwa
description: >
  Turns a generated CLIENT SmartStack app (an @atlashub/smartstack consumer)
  into an installable, offline-capable PWA: faithful socle service worker
  (tenant/language-isolated API cache), VitePWA injectManifest wiring, web
  manifest + meta tags, placeholder icons, the registerSW → package update
  channel bridge, initOutbox() and the offline-write outbox aggregation.
  Fail-closed on non-client web roots and on packages without the PWA channel.
phase: development/frontend
cli: cli/scaffold-pwa
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Frontend PWA — service worker, manifest, update flow, offline-write outbox

Two colocated CLIs:

| CLI | Role |
|---|---|
| `cli/scaffold-pwa` | One-shot PWA enablement of a client web app: emits the service worker (+ its cacheKey/swMessages modules) + icons + OutboxStatusChip and idempotently patches vite.config.ts / main.tsx / App.tsx / index.html / package.json / vite-env.d.ts. Safe to re-run (every patcher is a fixpoint; `@customised` always wins). |
| `cli/aggregate-outbox` | Aggregates every per-entity offline-write outbox module (`src/features/**/outbox/*Outbox.ts`, emitted by scaffold-api-client for `offline: 'write'` entities) into `src/extensions/outbox.generated.ts`. ALWAYS emits the file — even empty — so main.tsx's unconditional import can never dangle. |

## Emitted / patched files (scaffold-pwa, paths relative to the web root)

| File | Strategy | Content |
|---|---|---|
| `src/pwa/sw.ts` | overwrite (honours `@customised`) | Faithful copy of the socle service worker (workbox injectManifest, socle a6b9f2a5+): precache, SPA navigation fallback, tenant+language+user-keyed NetworkFirst API cache (key derivation imported from `./cacheKey`), `PURGE_API_CACHE` handler (drops the whole API cache on login/logout), prompt-only update, forward-ready Web Push handlers. Only deviation: the push fallback title = manifest name. |
| `src/pwa/cacheKey.ts` | overwrite (honours `@customised`) | Faithful copy of the socle cache-key module: `API_CACHE_NAME`, `PURGE_API_CACHE_MESSAGE`, `buildApiCacheKey(url, headers)` folding `X-Tenant-Slug`/`Accept-Language`/`X-User-Id` into `__ss_tenant`/`__ss_lang`/`__ss_user`. Carries the dimensional sacred markers DEV-PWA-003 greps on the sw.ts+cacheKey.ts concatenation. |
| `src/pwa/swMessages.ts` | overwrite (honours `@customised`) | Faithful copy of the socle SW-messaging helper: `purgeApiCache()` posts `PURGE_API_CACHE` to the controller (no-op without SW, never throws). The app-side login/logout/terminal-401 CALLERS ship in the `@atlashub/smartstack` package — the scaffolder emits the module so client code and extensions purge through the same helper. |
| `public/icons/pwa-192.png` | skip-if-exists | 192² solid-themeColor placeholder (deterministic PNG). |
| `public/icons/pwa-512.png` | skip-if-exists | 512² placeholder. |
| `public/icons/pwa-512-maskable.png` | skip-if-exists | 512² placeholder — a solid fill satisfies the maskable safe zone. |
| `public/icons/apple-touch-icon.png` | skip-if-exists | 180² placeholder (iOS home screen). |
| `src/components/pwa/OutboxStatusChip.tsx` | skip-if-exists (dev-owned) | Compact offline-write queue chip built on the package's `useOutboxStatus()` handle. Props: `{ resourceKey?: string; labels?: { pending; failed; conflict } }` — exactly what scaffold-component mounts on offline-write list/detail headers. Scopes records by `type === resourceKey \|\| type.startsWith(resourceKey + '.')`, renders nothing at zero, retry-all affordance. |
| `vite.config.ts` | patch | `VitePWA({ strategies: 'injectManifest', srcDir: 'src/pwa', filename: 'sw.ts', registerType: 'prompt', injectRegister: false, manifest: {…}, injectManifest: { globPatterns, `maximumFileSizeToCacheInBytes` = `precacheMaxMiB` (default 2.5 MiB) }, devOptions: { enabled: false } })` — socle-faithful, why-comments included. ⚠ A chunk above the cap is EXCLUDED from the precache (Workbox build WARNING only): first-offline-visit coverage needs entry chunks UNDER the cap — split with `manualChunks`, don't raise it (DEV-PWA-012 audits dist/ vs the cap). The generated sw.ts ships a CacheFirst same-origin asset net as the runtime safety net (assets served offline after first use). |
| `src/main.tsx` | patch | `import './extensions/outbox.generated'` (after the LAST `./extensions/*` import) + the `registerSW` → `setServiceWorkerUpdateState`/`setServiceWorkerUpdater` bridge + `initOutbox()` before `createRoot`, and `mobile: { enabled[, breakpoint] }` in the provider config. The config anchor handles **both** shapes: the inline `config={{ … }}` JSX literal `ss init` emits (the standard app) and a hoisted `const config = { … }` (hand-edited apps) — `findProviderConfigAnchor` prefers the inline one because it is bound to the provider rather than name-matched. |
| `src/App.tsx` | patch | `<DynamicRouter />` → `<><DynamicRouter /><UpdateBanner /><InstallBanner /></>` + import additions. |
| `index.html` | patch (per-tag) | `viewport-fit=cover`, light/dark `theme-color`, `mobile-web-app-capable`, `apple-mobile-web-app-*`, `apple-touch-icon` link. |
| `package.json` | patch | Adds missing devDependencies `vite-plugin-pwa ^1.3.0` + `workbox-* ^7.4.1` (socle pins). Never downgrades an existing entry. `idb` is a dependency of the PACKAGE itself — never added to the client. |
| `src/vite-env.d.ts` | patch | `/// <reference types="vite-plugin-pwa/client" />` (types the `virtual:pwa-register` import). |

aggregate-outbox emits ONE file: `src/extensions/outbox.generated.ts` (one
import + one call per `register<Entity>Outbox`, alphabetical by path; hard
error on duplicate `*_RESOURCE` keys across files).

## ⚠ BLOCKING rules

1. **Client mode ONLY** — validate runs `detectFrontendMode(webRoot)` and refuses
   anything but `'client'`. `'source'` = the SmartStack.app monorepo: the socle
   manages its own PWA (`web/smartstack-web/vite.config.ts`) — **never scaffold
   there**. `'unknown'` refuses with the detector evidence rather than guessing.
2. **Package gate** — the installed `@atlashub/smartstack` must be
   `>= MIN_SMARTSTACK_PWA_VERSION` (`types.ts`) **or** pass the capability probe
   — ALL of `setServiceWorkerUpdater`, `initOutbox` (offline channel),
   `MobileShell` and `useMobileNavContext` (mobile shell) greppable in
   `node_modules/@atlashub/smartstack/dist/`. The shell exports are part of the
   probe because `mobile: { enabled: true }` is inert against a package that
   predates them — a silent false start. Probe success wins over the floor
   (supports `file:` pre-release installs). Neither → hard error.
   ⚠ The version floor is **pipeline-allocated**: the constant still points at
   the outbox-era release and carries a `TODO(release)` to be pinned on the
   release shipping the shell. Until then the probe is the authority — never
   invent a version to "fix" the constant.
3. **NEVER add VitePWA to a library build config** (`vite.lib.config.ts` style):
   packages ship no service worker. The patcher only ever touches the app's
   `vite.config.ts`.
4. **The tenant/language/user isolation graph is SACRED** (DEV-PWA-003/005),
   spanning `src/pwa/sw.ts` + `src/pwa/cacheKey.ts`: `cacheKeyWillBeUsed` →
   `buildApiCacheKey` folding `X-Tenant-Slug`/`Accept-Language`/`X-User-Id`
   into `__ss_tenant`/`__ss_lang`/`__ss_user`, the `PURGE_API_CACHE`
   login/logout purge, the `[/^\/api\//, /^\/hubs\//]` navigation denylist,
   the `/api/auth/` exclusion, cache `smartstack-api`, NetworkFirst 4 s,
   ExpirationPlugin(200, 24 h). A URL-only cache key leaks one tenant's (or
   one account's) data into the next after a switch on a shared machine. Do
   not edit the generated files — re-run the CLI (or mark `@customised` and
   own every future socle fix).
5. **Update flow is PROMPT-ONLY** — `registerType: 'prompt'`, activation only on
   the user-accepted `SKIP_WAITING` message (UpdateBanner). Never `autoUpdate`:
   a silent reload could destroy an in-progress offline entry.
6. **`initOutbox()` runs textually AFTER the `./extensions/outbox.generated`
   import** (DEV-PWA-008) — specs must be registered before the boot drain
   replays queued mutations. aggregate-outbox always emits the file (even
   empty), so the import is unconditional.

## Spec fields — scaffold-pwa

| Field | Type | Required | Notes |
|---|---|---|---|
| `projectPath` | string | yes | Project root (absolute). |
| `appCode` | string | yes | kebab-case app code — default web root `web/{appCode}-web`, default manifest name (humanised). |
| `webRoot` | string | no | Web app dir relative to projectPath. Default: `findSmartStackStructure().web` → `web/{appCode}-web`. |
| `manifest.name` | string | no | Manifest name. Default: humanised appCode. Also the sw push fallback title. |
| `manifest.shortName` | string(≤12) | no | Home-screen label. Default: name truncated to 12. |
| `manifest.description` | string | no | Default: `{name} — SmartStack progressive web app`. |
| `manifest.themeColor` | `#rrggbb` | no | Default: the client's own brand token in `src/index.css`, probed in order `--color-accent-600` → `--color-accent-500` → `--sl-color-primary-500`, else `#4f46e5`. The accent ramp comes first because `scaffold-theme` emits it on EVERY app while the Shoelace token exists only under `useShoelace: true`; 600 before 500 because the value paints the OS status bar, where the darker shade holds contrast. Also the placeholder icon fill + light theme-color meta. |
| `manifest.backgroundColor` | `#rrggbb` | no | Default `#111827`. Also the dark theme-color meta. |
| `manifest.lang` | string | no | Manifest `lang` (omitted when absent). |
| `mobile.enabled` | boolean | no (default true) | Inserts `mobile: { enabled: … }` into the provider config. ⚠ The package already defaults to enabled — this makes the choice explicit; pass `false` to keep the desktop layout on narrow viewports. |
| `mobile.breakpoint` | int > 0 | no | Viewport width under which the mobile shell renders (socle default 768). **Only reachable through this block** — the historical anchor bug meant a requested breakpoint silently never landed. |
| `icons` | `placeholder` \| `skip` | no (default placeholder) | `skip` = the client ships its own icons. |
| `precacheMaxMiB` | number | no (default 2.5, max 49) | Per-file precache cap in MiB (`maximumFileSizeToCacheInBytes`). Chunks above it are EXCLUDED from the precache — prefer `manualChunks` splitting over raising it (iOS Cache API quota ≈ 50 MiB; DEV-PWA-012 audits the built chunks against it). |
| `force` | boolean | no (default false) | Overwrite dev-owned skip-if-exists files (icons, chip). |

Spec fields — aggregate-outbox: `{ projectPath, webRoot? }` (same web-root
resolution; no client-mode gate — it only reads outbox modules and rewrites one
generated file).

## The mobile shell is INHERITED — and ON BY DEFAULT

`@atlashub/smartstack` renders the entire mobile experience around
`<DynamicRouter />`: `MobileShell` and the "descente par paliers" navigation
(`MobileHomePage` Applications → `MobileLevelPage` Modules → Sections →
the resource page), `MobileHeader` with hierarchical back + breadcrumbs, the
transverse bottom bar (Applications / Tâches / Activité / Compte), the tenant
chip + sheet, transverse search, the offline banner + `OutboxSheet`, and the
"Reprendre" cards. **None of it is scaffolded into the client app** — the app
gets it at `npm install`. The palier hierarchy is the app's own DB menu
(`/api/navigation/menu`); there is no mobile-specific navigation model to author.

⚠ **Do not describe the `mobile` block as an on-switch.**
`MobileShellConfig.enabled` defaults to `true` (breakpoint 768), so an app with
no `mobile` key already renders the shell. What the block buys is (a) an
explicit, greppable declaration and (b) the ONLY access to `breakpoint`,
`shellComponent` (white-label shell override) and `bottomNav.maxItems`.
Whether a given PAGE appears inside the shell is a separate contract: its
`PageMobileMeta` (`pwa.support` → `scaffold-routes` → the third argument of
`PageRegistry.register`). No metadata = desktop-only = hidden from the mobile menu.

**Historical anchor bug.** The block was never written on a standard app: the
patcher only anchored on `const config = {`, while `ss init` emits the config
inline as JSX (`config={{ … }}`). The visible consequence was a requested
`mobile.breakpoint` silently staying at 768. Both anchors are handled now
(`findProviderConfigAnchor`). **No upgrade migration ships for this**: adding
`enabled: true` to an existing app is a semantic no-op against the package
default, and rewriting every client's `main.tsx` (plus a `.bak`) for that is
churn. Apps that need a custom breakpoint simply re-run this CLI.

## Invocation

```bash
# 1) PWA enablement (idempotent — safe to re-run):
npx --prefer-offline tsx skills/development/frontend/pwa/cli/scaffold-pwa/index.ts \
  --spec '{"projectPath":"/path/to/project","appCode":"crm","manifest":{"name":"Crm App"},"mobile":{"enabled":true,"breakpoint":768}}'

# 2) Offline-write outbox aggregation (re-run after every scaffold-api-client pass):
npx --prefer-offline tsx skills/development/frontend/pwa/cli/aggregate-outbox/index.ts \
  --spec '{"projectPath":"/path/to/project"}'
```

Both support `--dry_run` (lists the would-be files, writes nothing).

After scaffolding: `npm install` (new devDependencies) then `npm run build` —
the service worker only exists on a real build (`devOptions.enabled: false` by
design; the SW breaks HMR loops in dev). Replace the placeholder icons under
`public/icons/` with branded artwork (same names + sizes).

## After generation — MANDATORY

```
@.claude/skills/development/audit-dev-pwa/SKILL.md
Audit the PWA wiring I just generated (DEV-PWA-001..012): sw.ts tenant/language
isolation markers, prompt-only update flow, outbox.generated import before the
init call, VitePWA config shape, manifest/meta completeness, registry layout,
precache cap vs built chunks.
```

## When NOT to use

- **SmartStack.app source repo** — never. The socle manages its own PWA; the
  validate gate refuses `mode: 'source'` for exactly this reason.
- **Offline-write conflict-review UX** (a queue screen to inspect/edit/discard
  conflicting records) — v2. v1 ships the chip + `retry/discard/retryAll` only.
- **Web Push backend** (subscription storage, VAPID, send pipeline) — the sw
  handlers are forward-ready but the backend lands in P2; nothing to scaffold.
- **`.mobile.tsx` page variants** (`pwa.support: 'full'` +
  `PageMobileMeta.mobileComponent`) — v2; `lib/pwa-meta.ts` rejects `'full'`
  today. Use `'adapted'` pages.
- **A library/package build** — never add VitePWA to a lib config.
