From 795378e1f83ffea77dbc433891be74e0836a32ce Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 2 Apr 2026 18:56:41 +0200 Subject: [PATCH] docs: add PWA implementation plan 10-task plan covering dependencies, icon generation, vite-plugin-pwa config, meta tags, SW registration, safe areas, install prompt hook, banner component, and end-to-end verification. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/superpowers/plans/2026-04-02-pwa.md | 891 +++++++++++++++++++++++ 1 file changed, 891 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-pwa.md diff --git a/docs/superpowers/plans/2026-04-02-pwa.md b/docs/superpowers/plans/2026-04-02-pwa.md new file mode 100644 index 0000000..1b3c044 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-pwa.md @@ -0,0 +1,891 @@ +# PWA 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 apps/main installable as a native-feeling PWA with fast repeat loads and mobile polish. + +**Architecture:** Add `vite-plugin-pwa` to the existing Vite build. The plugin auto-generates a service worker (Workbox) and web app manifest. A custom install prompt hook + banner component handles the "Add to Home Screen" UX. Safe area CSS and viewport meta tags polish the standalone experience. + +**Tech Stack:** vite-plugin-pwa, Workbox (auto-generated), sharp (icon generation), React hooks + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `apps/main/scripts/generate-pwa-icons.ts` | One-time script to generate PWA icon set from source images | +| Create | `apps/main/public/pwa-icons/` | Generated icon files (committed to repo) | +| Create | `apps/main/src/hooks/useInstallPrompt.ts` | React hook capturing `beforeinstallprompt` event | +| Create | `apps/main/src/hooks/useInstallPrompt.test.ts` | Tests for install prompt hook | +| Create | `apps/main/src/components/InstallBanner.tsx` | Dismissible install prompt banner | +| Create | `apps/main/src/components/InstallBanner.test.tsx` | Tests for install banner | +| Modify | `apps/main/package.json` | Add `vite-plugin-pwa` and `sharp` dependencies | +| Modify | `apps/main/vite.config.ts` | Add VitePWA plugin config | +| Modify | `apps/main/index.html` | Add PWA meta tags, update viewport | +| Modify | `apps/main/src/main.tsx` | Register service worker | +| Modify | `apps/main/src/main.css` | Add safe area CSS for standalone mode | +| Modify | `apps/main/src/App.tsx` | Add InstallBanner to app shell | + +--- + +### Task 1: Install dependencies + +**Files:** +- Modify: `apps/main/package.json` + +- [ ] **Step 1: Install vite-plugin-pwa** + +```bash +cd apps/main && pnpm add -D vite-plugin-pwa +``` + +- [ ] **Step 2: Install sharp for icon generation** + +```bash +cd apps/main && pnpm add -D sharp @types/sharp +``` + +- [ ] **Step 3: Verify installation** + +```bash +cd apps/main && pnpm list vite-plugin-pwa sharp +``` + +Expected: Both packages listed with versions. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/package.json pnpm-lock.yaml +git commit -m "feat(pwa): add vite-plugin-pwa and sharp dependencies" +``` + +--- + +### Task 2: Generate PWA icon set + +**Files:** +- Create: `apps/main/scripts/generate-pwa-icons.ts` +- Create: `apps/main/public/pwa-icons/*.png` + +- [ ] **Step 1: Create the icon generation script** + +Create `apps/main/scripts/generate-pwa-icons.ts`: + +```typescript +import sharp from "sharp"; +import path from "node:path"; +import fs from "node:fs"; + +const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public"); +const OUTPUT_DIR = path.join(PUBLIC_DIR, "pwa-icons"); + +const ICON_SIZES = [ + { size: 16, name: "favicon-16x16.png" }, + { size: 32, name: "favicon-32x32.png" }, + { size: 180, name: "apple-touch-icon-180x180.png" }, + { size: 192, name: "pwa-192x192.png" }, + { size: 512, name: "pwa-512x512.png" }, + { size: 512, name: "pwa-512x512-maskable.png", maskable: true }, +]; + +// Maskable icons need 10% safe zone padding (per spec). +// We add a colored background and shrink the source to 80% to stay within the safe zone. +const MASKABLE_PADDING_RATIO = 0.1; +const MASKABLE_BG_COLOR = "#ffffff"; + +async function generateIcons(sourceFile: string) { + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + for (const icon of ICON_SIZES) { + const outputPath = path.join(OUTPUT_DIR, icon.name); + + if (icon.maskable) { + // Create maskable icon with padding and background + const innerSize = Math.round(icon.size * (1 - MASKABLE_PADDING_RATIO * 2)); + const offset = Math.round(icon.size * MASKABLE_PADDING_RATIO); + + const resizedIcon = await sharp(sourceFile) + .resize(innerSize, innerSize, { fit: "contain" }) + .toBuffer(); + + await sharp({ + create: { + width: icon.size, + height: icon.size, + channels: 4, + background: MASKABLE_BG_COLOR, + }, + }) + .composite([{ input: resizedIcon, left: offset, top: offset }]) + .png() + .toFile(outputPath); + } else { + await sharp(sourceFile) + .resize(icon.size, icon.size, { fit: "contain" }) + .png() + .toFile(outputPath); + } + + console.log(`Generated: ${icon.name} (${icon.size}x${icon.size})`); + } +} + +// Determine source: pass "staging" as first arg for staging icons +const variant = process.argv[2]; +const sourceFile = + variant === "staging" + ? path.join(PUBLIC_DIR, "staging_icon.jpg") + : path.join(PUBLIC_DIR, "icon.jpg"); + +if (!fs.existsSync(sourceFile)) { + console.error(`Source icon not found: ${sourceFile}`); + process.exit(1); +} + +console.log(`Generating PWA icons from: ${sourceFile}`); +generateIcons(sourceFile).then(() => console.log("Done!")); +``` + +- [ ] **Step 2: Run the script for production icons** + +```bash +cd apps/main && npx tsx scripts/generate-pwa-icons.ts +``` + +Expected: 6 PNG files created in `apps/main/public/pwa-icons/`. + +- [ ] **Step 3: Verify the generated icons exist and have reasonable sizes** + +```bash +ls -la apps/main/public/pwa-icons/ +``` + +Expected: 6 files — favicon-16x16.png, favicon-32x32.png, apple-touch-icon-180x180.png, pwa-192x192.png, pwa-512x512.png, pwa-512x512-maskable.png. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/scripts/generate-pwa-icons.ts apps/main/public/pwa-icons/ +git commit -m "feat(pwa): add icon generation script and generated PWA icons" +``` + +--- + +### Task 3: Configure vite-plugin-pwa + +**Files:** +- Modify: `apps/main/vite.config.ts` + +- [ ] **Step 1: Add VitePWA plugin to vite config** + +In `apps/main/vite.config.ts`, add the import at the top: + +```typescript +import { VitePWA } from "vite-plugin-pwa"; +``` + +Then add the plugin to the `plugins` array (before the cloudflare conditional). Note that `mode` is already available from the `defineConfig` callback parameter: + +```typescript +VitePWA({ + registerType: "autoUpdate", + includeAssets: [ + "public/icon.jpg", + "public/logo_dark.png", + "public/logo_white.png", + ], + manifest: { + name: mode === "staging" ? "XTablo (Staging)" : "XTablo", + short_name: "XTablo", + description: "Collaborative project management for construction teams", + start_url: "/", + display: "standalone", + orientation: "any", + theme_color: "#1e1b2e", + background_color: "#1e1b2e", + icons: [ + { + src: "pwa-icons/pwa-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "pwa-icons/pwa-512x512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "pwa-icons/pwa-512x512-maskable.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }, + workbox: { + globPatterns: ["**/*.{js,css,html,ico,png,jpg,svg,woff,woff2}"], + // Do not precache source maps + globIgnores: ["**/*.map"], + }, +}), +``` + +Note: `theme_color` and `background_color` are set to `#1e1b2e` which is the dark navbar background color from the CSS. This provides a consistent splash screen feel on Android. Pick dark because standalone apps typically launch in the user's last-used theme, and dark provides a cleaner splash. + +- [ ] **Step 2: Build to verify the plugin works** + +```bash +cd apps/main && pnpm build +``` + +Expected: Build succeeds. The `dist/` folder should now contain `manifest.webmanifest`, `sw.js`, and `workbox-*.js`. + +- [ ] **Step 3: Verify manifest was generated** + +```bash +cat apps/main/dist/manifest.webmanifest | head -30 +``` + +Expected: JSON with name "XTablo", icons array, display "standalone". + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/vite.config.ts +git commit -m "feat(pwa): configure vite-plugin-pwa with manifest and workbox precaching" +``` + +--- + +### Task 4: Update HTML meta tags and viewport + +**Files:** +- Modify: `apps/main/index.html` + +- [ ] **Step 1: Update index.html with PWA meta tags** + +Replace the current `` content in `apps/main/index.html`: + +```html + + + + + + + + + + + + XTablo + + +
+ + + +``` + +Changes from original: +- Favicon now points to generated PNG icons instead of `icon.jpg` +- Added `viewport-fit=cover` for notched devices +- Added `theme-color` meta tag +- Added Apple PWA meta tags +- The `` tag is injected automatically by vite-plugin-pwa — do NOT add it manually + +- [ ] **Step 2: Build and verify meta tags in output** + +```bash +cd apps/main && pnpm build && head -20 dist/index.html +``` + +Expected: The built HTML includes all meta tags plus an auto-injected ``. + +- [ ] **Step 3: Commit** + +```bash +git add apps/main/index.html +git commit -m "feat(pwa): add PWA meta tags and update viewport for standalone mode" +``` + +--- + +### Task 5: Register service worker in main.tsx + +**Files:** +- Modify: `apps/main/src/main.tsx` + +- [ ] **Step 1: Add SW registration** + +Add the following import and call at the end of `apps/main/src/main.tsx`, after the `createRoot().render()` call: + +```typescript +import { registerSW } from "virtual:pwa-register"; + +// Auto-update service worker — checks for updates on page load +registerSW({ immediate: true }); +``` + +- [ ] **Step 2: Add type declaration for the virtual module** + +The `virtual:pwa-register` module needs a type declaration. `vite-plugin-pwa` ships its own types. Add to `apps/main/tsconfig.json` (or `tsconfig.app.json`, whichever controls the app source) in `compilerOptions.types`: + +```json +"types": ["vite-plugin-pwa/client"] +``` + +If there's no `types` array yet, check the existing tsconfig structure and add it appropriately. + +- [ ] **Step 3: Verify typecheck passes** + +```bash +cd apps/main && pnpm typecheck +``` + +Expected: No errors related to `virtual:pwa-register`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/main.tsx apps/main/tsconfig.json apps/main/tsconfig.app.json +git commit -m "feat(pwa): register service worker with auto-update" +``` + +--- + +### Task 6: Add safe area CSS for standalone mode + +**Files:** +- Modify: `apps/main/src/main.css` + +- [ ] **Step 1: Add safe area styles** + +Add the following at the end of the `@layer base` block in `apps/main/src/main.css` (after the existing `body` rule, still inside `@layer base`): + +```css +@media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} +``` + +This only applies when the app is running in standalone (installed PWA) mode, not in a regular browser tab. + +- [ ] **Step 2: Build to verify no CSS errors** + +```bash +cd apps/main && pnpm build +``` + +Expected: Build succeeds with no CSS errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/main/src/main.css +git commit -m "feat(pwa): add safe area insets for standalone mode" +``` + +--- + +### Task 7: Create useInstallPrompt hook + +**Files:** +- Create: `apps/main/src/hooks/useInstallPrompt.ts` +- Create: `apps/main/src/hooks/useInstallPrompt.test.ts` + +- [ ] **Step 1: Write the test file** + +Create `apps/main/src/hooks/useInstallPrompt.test.ts`: + +```typescript +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useInstallPrompt } from "./useInstallPrompt"; + +const DISMISSED_KEY = "pwa-install-dismissed"; + +describe("useInstallPrompt", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts with canInstall false and isStandalone false", () => { + const { result } = renderHook(() => useInstallPrompt()); + expect(result.current.canInstall).toBe(false); + expect(result.current.isStandalone).toBe(false); + expect(result.current.isDismissed).toBe(false); + }); + + it("captures beforeinstallprompt event and sets canInstall to true", () => { + const { result } = renderHook(() => useInstallPrompt()); + + const event = new Event("beforeinstallprompt"); + Object.assign(event, { preventDefault: vi.fn(), prompt: vi.fn() }); + + act(() => { + window.dispatchEvent(event); + }); + + expect(result.current.canInstall).toBe(true); + }); + + it("dismiss persists to localStorage and sets isDismissed", () => { + const { result } = renderHook(() => useInstallPrompt()); + + act(() => { + result.current.dismiss(); + }); + + expect(result.current.isDismissed).toBe(true); + expect(localStorage.getItem(DISMISSED_KEY)).toBe("true"); + }); + + it("reads dismissed state from localStorage on mount", () => { + localStorage.setItem(DISMISSED_KEY, "true"); + const { result } = renderHook(() => useInstallPrompt()); + expect(result.current.isDismissed).toBe(true); + }); + + it("detects iOS Safari", () => { + // Mock iOS Safari user agent + vi.spyOn(navigator, "userAgent", "get").mockReturnValue( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + ); + const { result } = renderHook(() => useInstallPrompt()); + expect(result.current.isIOS).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts +``` + +Expected: FAIL — module `./useInstallPrompt` not found. + +- [ ] **Step 3: Implement the hook** + +Create `apps/main/src/hooks/useInstallPrompt.ts`: + +```typescript +import { useCallback, useEffect, useRef, useState } from "react"; + +const DISMISSED_KEY = "pwa-install-dismissed"; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export function useInstallPrompt() { + const deferredPrompt = useRef(null); + const [canInstall, setCanInstall] = useState(false); + const [isDismissed, setIsDismissed] = useState( + () => localStorage.getItem(DISMISSED_KEY) === "true" + ); + + const isStandalone = + typeof window !== "undefined" && + window.matchMedia("(display-mode: standalone)").matches; + + const isIOS = + typeof navigator !== "undefined" && + /iPad|iPhone|iPod/.test(navigator.userAgent); + + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + deferredPrompt.current = e as BeforeInstallPromptEvent; + setCanInstall(true); + }; + window.addEventListener("beforeinstallprompt", handler); + return () => window.removeEventListener("beforeinstallprompt", handler); + }, []); + + const promptInstall = useCallback(async () => { + if (!deferredPrompt.current) return; + const result = await deferredPrompt.current.prompt(); + if (result.outcome === "accepted") { + deferredPrompt.current = null; + setCanInstall(false); + } + }, []); + + const dismiss = useCallback(() => { + setIsDismissed(true); + localStorage.setItem(DISMISSED_KEY, "true"); + }, []); + + return { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd apps/main && pnpm vitest run src/hooks/useInstallPrompt.test.ts +``` + +Expected: All 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/main/src/hooks/useInstallPrompt.ts apps/main/src/hooks/useInstallPrompt.test.ts +git commit -m "feat(pwa): add useInstallPrompt hook with tests" +``` + +--- + +### Task 8: Create InstallBanner component + +**Files:** +- Create: `apps/main/src/components/InstallBanner.tsx` +- Create: `apps/main/src/components/InstallBanner.test.tsx` + +- [ ] **Step 1: Write the test file** + +Create `apps/main/src/components/InstallBanner.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the hook so we can control its return values +const mockPromptInstall = vi.fn(); +const mockDismiss = vi.fn(); + +vi.mock("../hooks/useInstallPrompt", () => ({ + useInstallPrompt: vi.fn(() => ({ + canInstall: false, + isStandalone: false, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + })), +})); + +import { useInstallPrompt } from "../hooks/useInstallPrompt"; +import { InstallBanner } from "./InstallBanner"; + +const mockUseInstallPrompt = vi.mocked(useInstallPrompt); + +describe("InstallBanner", () => { + beforeEach(() => { + mockPromptInstall.mockClear(); + mockDismiss.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders nothing when canInstall is false and not iOS", () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: false, + isStandalone: false, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when already in standalone mode", () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: true, + isStandalone: true, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when dismissed", () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: true, + isStandalone: false, + isIOS: false, + isDismissed: true, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders install banner when canInstall is true", () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: true, + isStandalone: false, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + render(); + expect(screen.getByText(/install/i)).toBeInTheDocument(); + }); + + it("calls promptInstall when install button is clicked", async () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: true, + isStandalone: false, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + render(); + await userEvent.click(screen.getByRole("button", { name: /install/i })); + expect(mockPromptInstall).toHaveBeenCalledOnce(); + }); + + it("calls dismiss when close button is clicked", async () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: true, + isStandalone: false, + isIOS: false, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + render(); + await userEvent.click(screen.getByRole("button", { name: /dismiss|close/i })); + expect(mockDismiss).toHaveBeenCalledOnce(); + }); + + it("renders iOS instructions when isIOS is true and not dismissed", () => { + mockUseInstallPrompt.mockReturnValue({ + canInstall: false, + isStandalone: false, + isIOS: true, + isDismissed: false, + promptInstall: mockPromptInstall, + dismiss: mockDismiss, + }); + render(); + expect(screen.getByText(/share/i)).toBeInTheDocument(); + expect(screen.getByText(/add to home screen/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx +``` + +Expected: FAIL — module `./InstallBanner` not found. + +- [ ] **Step 3: Implement the component** + +Create `apps/main/src/components/InstallBanner.tsx`: + +```tsx +import { X, Download, Share } from "lucide-react"; +import { useInstallPrompt } from "../hooks/useInstallPrompt"; + +export function InstallBanner() { + const { canInstall, isStandalone, isIOS, isDismissed, promptInstall, dismiss } = + useInstallPrompt(); + + // Don't show if already installed, dismissed, or no install option available + if (isStandalone || isDismissed) return null; + if (!canInstall && !isIOS) return null; + + return ( +
+ {isIOS ? ( + <> + +

+ Install XTablo: tap{" "} + Share then{" "} + Add to Home Screen +

+ + ) : ( + <> + +

+ Install XTablo for a faster, native experience +

+ + + )} + +
+ ); +} +``` + +The banner renders as a slim top bar using existing design tokens (bg-card, border-border, text-foreground, etc.) so it matches the app's theme automatically. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd apps/main && pnpm vitest run src/components/InstallBanner.test.tsx +``` + +Expected: All 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/main/src/components/InstallBanner.tsx apps/main/src/components/InstallBanner.test.tsx +git commit -m "feat(pwa): add InstallBanner component with tests" +``` + +--- + +### Task 9: Wire InstallBanner into the app shell + +**Files:** +- Modify: `apps/main/src/App.tsx` + +- [ ] **Step 1: Add InstallBanner to the App component** + +In `apps/main/src/App.tsx`, import the component: + +```typescript +import { InstallBanner } from "./components/InstallBanner"; +``` + +Then add `` as the first child inside the main `
`, before ``: + +```tsx +
+ + + {showBanner && ( + + )} +
+``` + +The banner appears at the very top of the page, above all content including the sidebar and top bar. It self-hides based on the hook state (dismissed, standalone, no install available). + +- [ ] **Step 2: Build and typecheck** + +```bash +cd apps/main && pnpm typecheck && pnpm build +``` + +Expected: Both pass with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/main/src/App.tsx +git commit -m "feat(pwa): wire InstallBanner into app shell" +``` + +--- + +### Task 10: Verify full PWA build end-to-end + +**Files:** None (verification only) + +- [ ] **Step 1: Clean build** + +```bash +cd apps/main && pnpm clean && pnpm build +``` + +Expected: Build succeeds. + +- [ ] **Step 2: Verify all PWA assets in dist/** + +```bash +ls apps/main/dist/manifest.webmanifest apps/main/dist/sw.js apps/main/dist/pwa-icons/ +``` + +Expected: `manifest.webmanifest` and `sw.js` exist. `pwa-icons/` folder contains all 6 generated icons. + +- [ ] **Step 3: Verify manifest content** + +```bash +cat apps/main/dist/manifest.webmanifest +``` + +Expected: JSON with `name: "XTablo"`, `display: "standalone"`, `theme_color: "#1e1b2e"`, 3 icons (192, 512, 512 maskable). + +- [ ] **Step 4: Run all tests** + +```bash +cd apps/main && pnpm test +``` + +Expected: All tests pass, including the new useInstallPrompt and InstallBanner tests. + +- [ ] **Step 5: Run linter** + +```bash +cd apps/main && pnpm lint +``` + +Expected: No new linting errors. Fix any issues before proceeding. + +- [ ] **Step 6: Preview locally (manual verification)** + +```bash +cd apps/main && pnpm preview +``` + +Open `http://localhost:4173` in Chrome. Open DevTools → Application tab: +- **Manifest** section should show "XTablo" with correct icons +- **Service Workers** section should show an active SW +- **Lighthouse** PWA audit should pass core checks (installable, has manifest, has SW) + +This step is manual — verify visually, then stop the preview server. + +- [ ] **Step 7: Final commit if any lint fixes were needed** + +```bash +git add -A +git commit -m "fix(pwa): lint fixes" +``` + +Only run this if Step 5 required fixes. Skip if lint was clean.