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.