# Phase 26: PWA & Performance - Research

**Researched:** 2026-04-01
**Domain:** Progressive Web App (PWA), service workers, offline queuing, mobile responsive layout, push notifications, Vite performance
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
None — discuss phase was skipped. All implementation choices are at Claude's discretion.

### Claude's Discretion
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.

### Deferred Ideas (OUT OF SCOPE)
None — discuss phase skipped.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| PWA-01 | Service worker for offline capability: cached UI loads instantly, queues messages until back online | SW upgrade to cache-first + IndexedDB offline queue via `idb` |
| PWA-02 | Web App Manifest: installable on iOS, Android, macOS, and Windows as a standalone app | Manifest exists, already complete — no action needed |
| PWA-03 | Responsive layout: adapts to phone, tablet, and desktop screen sizes | MobileChatView + MobileNavBar; Tailwind breakpoints |
| PWA-04 | Mobile-optimized input: large touch targets, sticky input bar at bottom, keyboard-aware resize | `100dvh`, `env(safe-area-inset-bottom)`, 44px min touch target |
| PWA-05 | Pull-to-refresh on the mobile conversation list | Custom hook using touchstart/touchmove/touchend; no new dependency |
| PWA-06 | Push notifications (where supported): agent mentions, task completions, handoff requests | `web-push` npm on server (VAPID), SW push handler, subscription API |
| PWA-07 | App icon and splash screen with Nexus branding, theme-aware | Already implemented in `site.webmanifest` + `index.html` — only SW cache rename needed |
| PWA-08 | "Add to Home Screen" prompt on first mobile visit | `beforeinstallprompt` custom hook + `InstallPromptBanner` component |
| PERF-01 | Initial load under 2 seconds on broadband, under 5 seconds on 3G | Route-level lazy loading + vendor chunk splitting in `vite.config.ts` |
| PERF-05 | PWA cached load under 1 second | SW cache-first for all static assets; cache name `nexus-v1` replaces `paperclip-v2` |
</phase_requirements>

---

## Summary

Phase 26 upgrades an already-partially-wired PWA scaffold into a production-quality installable app. The existing `ui/public/sw.js` uses a network-first strategy (cache as offline fallback), `ui/public/site.webmanifest` has the correct standalone configuration, and `ui/index.html` already contains all Apple PWA meta tags and the dynamic theme-color inline script. Phase 26 must NOT recreate these — it must upgrade them.

The two biggest technical lifts are (1) rewriting the service worker from network-first to cache-first with an offline POST queue backed by IndexedDB, and (2) adding the mobile-first responsive layout (`MobileChatView`, `MobileNavBar`, pull-to-refresh). Performance budget work (PERF-01/PERF-05) is achievable by converting `App.tsx` pages to `React.lazy` with `Suspense` and adding `manualChunks` in `vite.config.ts` — the existing build already splits Mermaid and other heavy chunks, but the main bundle is 1.4 MB (all pages eager-loaded).

Push notifications (PWA-06) require server-side VAPID key management and a new `/api/push` route set, which is the only meaningful back-end work in this phase.

**Primary recommendation:** Hand-write the upgraded `sw.js` (cache-first, offline queue, VAPID push handler) rather than adopting `vite-plugin-pwa`. The codebase already has a manual SW registered in `main.tsx`; adding a plugin would require non-trivial migration and adds upstream dependency risk. Keep it simple.

---

## Existing PWA Infrastructure (Pre-Phase) — Do Not Recreate

| Asset | Path | Current State | Phase 26 Action |
|-------|------|---------------|-----------------|
| Service worker | `ui/public/sw.js` | Network-first, `paperclip-v2` cache | Rewrite to cache-first, rename cache to `nexus-v1` |
| Web manifest | `ui/public/site.webmanifest` | Complete: standalone display, Nexus name, two icon sizes | No change needed |
| Viewport meta | `ui/index.html` | `viewport-fit=cover`, `user-scalable=no`, `maximum-scale=1` | No change needed |
| Apple PWA meta | `ui/index.html` | `apple-mobile-web-app-capable`, status bar, title | No change needed |
| Theme-color meta | `ui/index.html` | Dynamic per-theme via inline script | No change needed |
| SW registration | `ui/src/main.tsx` | Registered on `load` event | No change needed |
| Icons | `ui/public/` | `android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`, favicons | No change needed |

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Native Service Worker API | — | Cache-first strategy, push events, offline queue flush | Already registered; no plugin needed |
| `idb` | 8.0.3 | IndexedDB wrapper for offline message queue | Tiny (~5KB), promise-based, official Google library; avoids raw IDB complexity |
| `web-push` (server) | 3.6.7 | VAPID key generation, send push to browser push service | De-facto Node.js standard for Web Push Protocol |
| React.lazy + Suspense | React 19 (already installed) | Route-level code splitting | Reduces initial parse by deferring non-critical pages |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `rollup-plugin-visualizer` | optional dev-only | Bundle analysis for PERF-01 regression prevention | Run once to identify bloat before and after splitting |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Manual sw.js | `vite-plugin-pwa` (1.2.0) | Plugin gives auto-precaching via Workbox but requires full migration of existing manual SW registration in `main.tsx`; adds ~20KB Workbox runtime. For this codebase, the manual approach keeps the upgrade incremental with no upstream refactoring. |
| `idb` | `localForage` / Dexie.js | idb is smaller and more modern; Dexie adds ~70KB for features we don't need. |
| Hand-rolled touch handler for pull-to-refresh | `react-pull-to-refresh` npm | The existing codebase already has `SwipeToArchive.tsx` as proof the project hand-rolls touch gestures. Consistent pattern; no new dependency. |

**Installation:**
```bash
# UI
pnpm --filter @paperclipai/ui add idb

# Server
pnpm --filter @paperclipai/server add web-push
pnpm --filter @paperclipai/server add --save-dev @types/web-push
```

**Version verification (confirmed 2026-04-01):**
- `idb`: 8.0.3 (latest)
- `web-push`: 3.6.7 (latest)

---

## Architecture Patterns

### Recommended File Structure (additions only)
```
ui/public/
└── sw.js                        # Rewrite to cache-first + push handler

ui/src/
├── components/
│   ├── InstallPromptBanner.tsx  # PWA-08: beforeinstallprompt UI
│   ├── OfflineBanner.tsx        # PWA-01: navigator.onLine UI
│   ├── NotificationPermissionPrompt.tsx  # PWA-06: permission request UI
│   ├── PullToRefresh.tsx        # PWA-05: touch gesture wrapper
│   ├── MobileChatView.tsx       # PWA-03/04: full-screen mobile chat layout
│   └── MobileNavBar.tsx         # PWA-03: bottom nav for mobile
├── hooks/
│   ├── useInstallPrompt.ts      # PWA-08: captures beforeinstallprompt event
│   ├── useOfflineQueue.ts       # PWA-01: IndexedDB queue + flush on reconnect
│   ├── usePushNotifications.ts  # PWA-06: subscribe, permission management
│   └── usePullToRefresh.ts      # PWA-05: touch gesture logic
└── api/
    └── push.ts                  # PWA-06: client API calls to /api/push

server/src/routes/
└── push.ts                      # PWA-06: subscribe, unsubscribe, VAPID public key

packages/db/src/schema/
└── push_subscriptions.ts        # PWA-06: store push endpoint + keys per user/device
```

### Pattern 1: Service Worker — Cache-First with Offline POST Queue

**What:** Static assets (JS/CSS/HTML) served from cache immediately. API GETs pass through to network; failures return gracefully. API POSTs (chat messages) that fail while offline are stored in the SW's message channel / the main thread's IndexedDB queue and retried on reconnect.

**When to use:** Always for this phase.

The offline message queue lives in the **main thread** (React hook `useOfflineQueue`) backed by IndexedDB via `idb`, not inside the service worker itself. This avoids SW↔main-thread complexity and keeps all React state in one place. The SW only needs to handle push events and cache management.

```javascript
// ui/public/sw.js — upgraded pattern
const CACHE_NAME = "nexus-v1"; // rename busts stale paperclip-v2 cache

const STATIC_EXTENSIONS = /\.(js|css|woff2?|png|svg|ico|webmanifest)$/;

self.addEventListener("install", (event) => {
  self.skipWaiting();
  // Pre-cache the app shell
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) =>
      cache.addAll(["/", "/index.html"])
    )
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
      )
    )
  );
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // API calls: network-only; POST failures handled by main-thread queue
  if (url.pathname.startsWith("/api")) return;

  // Navigation (HTML): cache-first with network fallback
  if (request.mode === "navigate") {
    event.respondWith(
      caches.match("/").then((cached) => cached || fetch(request))
    );
    return;
  }

  // Static assets: cache-first
  if (STATIC_EXTENSIONS.test(url.pathname)) {
    event.respondWith(
      caches.match(request).then(
        (cached) =>
          cached ||
          fetch(request).then((response) => {
            const clone = response.clone();
            caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
            return response;
          })
      )
    );
    return;
  }
});

// Push notification handler
self.addEventListener("push", (event) => {
  if (!event.data) return;
  const { title, body, icon, data } = event.data.json();
  event.waitUntil(
    self.registration.showNotification(title, {
      body,
      icon: icon || "/android-chrome-192x192.png",
      badge: "/favicon-32x32.png",
      data,
    })
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data?.url || "/"));
});
```

### Pattern 2: useOfflineQueue Hook

**What:** React hook storing unsent POST payloads in IndexedDB. Listens for `online` event and flushes queue via the existing `chatApi`.

**When to use:** Wrap the `handleSend` in ChatPanel (and MobileChatView). Only queue messages, not file uploads or streaming re-sends.

```typescript
// Source: idb docs + standard online/offline event pattern
import { openDB } from "idb";

const DB_NAME = "nexus-offline";
const STORE = "message_queue";

export function useOfflineQueue() {
  const flush = useCallback(async () => {
    const db = await openDB(DB_NAME, 1, {
      upgrade(db) { db.createObjectStore(STORE, { autoIncrement: true }); },
    });
    const all = await db.getAll(STORE);
    const keys = await db.getAllKeys(STORE);
    for (let i = 0; i < all.length; i++) {
      try {
        await chatApi.postMessage(all[i].conversationId, all[i].payload);
        await db.delete(STORE, keys[i]);
      } catch { break; } // stop on first failure; retry next time
    }
  }, []);

  useEffect(() => {
    window.addEventListener("online", flush);
    return () => window.removeEventListener("online", flush);
  }, [flush]);

  const enqueue = useCallback(async (conversationId: string, payload: object) => {
    const db = await openDB(DB_NAME, 1);
    await db.add(STORE, { conversationId, payload, queuedAt: Date.now() });
  }, []);

  return { enqueue, flush };
}
```

### Pattern 3: useInstallPrompt Hook

**What:** Captures the `beforeinstallprompt` event and exposes a `prompt()` function for the banner CTA.

**When to use:** Called once at app root level, result passed down via context or props to `InstallPromptBanner`.

```typescript
// Source: MDN BeforeInstallPromptEvent docs
export function useInstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
    };
    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  const isInstalled = window.matchMedia("(display-mode: standalone)").matches;

  const promptInstall = async () => {
    if (!deferredPrompt) return;
    deferredPrompt.prompt();
    await deferredPrompt.userChoice;
    setDeferredPrompt(null);
  };

  return { canInstall: !!deferredPrompt && !isInstalled, promptInstall };
}
```

### Pattern 4: Route-Level Code Splitting for PERF-01

**What:** Convert all `App.tsx` page imports to `React.lazy` + `Suspense`. The existing build already splits Mermaid diagrams correctly, but the main entry chunk is 1.4 MB because all 50+ pages are eagerly imported.

**When to use:** In `ui/src/App.tsx`.

```typescript
// Before (eager, adds every page to main bundle):
import { Dashboard } from "./pages/Dashboard";
import { Issues } from "./pages/Issues";
// ... 50+ more

// After (lazy per route):
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Issues = lazy(() => import("./pages/Issues"));
// Wrap routes in Suspense:
<Suspense fallback={<div className="flex items-center justify-center h-full"><Skeleton className="h-8 w-48" /></div>}>
  <Routes>...</Routes>
</Suspense>
```

Additionally, add `manualChunks` to `vite.config.ts` to extract known heavy vendors:

```typescript
// vite.config.ts — build.rollupOptions.output.manualChunks
manualChunks: {
  "vendor-react": ["react", "react-dom"],
  "vendor-router": ["react-router-dom"],
  "vendor-query": ["@tanstack/react-query"],
  "vendor-markdown": ["react-markdown", "remark-gfm", "rehype-highlight"],
  "vendor-mdx": ["@mdxeditor/editor"],
}
```

### Pattern 5: PullToRefresh Component

**What:** Wraps the conversation list, detects vertical swipe-down touch gesture past a threshold, calls `refetch()`, and shows a spinner during refresh.

**When to use:** Directly mirrors the existing `SwipeToArchive.tsx` pattern — same three touch events (touchstart/touchmove/touchend), same ref-based approach. No new dependency needed.

```typescript
// Touch handler skeleton (mirrors SwipeToArchive.tsx convention)
const PTR_THRESHOLD = 64; // px — per UI-SPEC
const PTR_MAX = 96;       // px — per UI-SPEC

const handleTouchStart = (e: TouchEvent) => {
  if (containerRef.current?.scrollTop !== 0) return; // only at top of list
  startYRef.current = e.touches[0]!.clientY;
};
const handleTouchMove = (e: TouchEvent) => {
  if (!startYRef.current) return;
  const dy = e.touches[0]!.clientY - startYRef.current;
  if (dy > 0) setPullDistance(Math.min(dy, PTR_MAX));
};
const handleTouchEnd = () => {
  if (pullDistance >= PTR_THRESHOLD) {
    navigator.vibrate?.(10); // haptic feedback per UI-SPEC
    onRefresh();
  }
  setPullDistance(0);
  startYRef.current = null;
};
```

### Pattern 6: MobileChatView Layout

**What:** Full-screen mobile chat view using `100dvh` (dynamic viewport height) to handle virtual keyboard appearance/disappearance. Header (48px) + message list (fills remainder) + sticky input (56px + safe-area-inset).

**When to use:** Rendered by ChatPanel when `window.innerWidth < 768` (or Tailwind `md` breakpoint check via a `useMediaQuery` hook).

```tsx
// Height calculation from UI-SPEC
<div className="h-[calc(100dvh-48px-56px-env(safe-area-inset-bottom))]">
  {/* message list */}
</div>
<div className="sticky bottom-0 min-h-[56px] pb-[env(safe-area-inset-bottom)] border-t border-border bg-background">
  {/* ChatInput */}
</div>
```

**Key constraint:** Use `100dvh` not `100vh`. On iOS Safari, `100vh` is the viewport height before the browser chrome appears, which causes the input to hide behind it. `100dvh` is the actual current visible height.

### Pattern 7: Push Notifications — Server Side

**What:** Server stores VAPID public+private key pair (generated once, stored in config/env), exposes endpoints for push subscription management, and sends notifications when agent events fire.

```typescript
// server: one-time VAPID key generation (run during setup)
import webPush from "web-push";
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
// Store in .env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT

// server: send notification
webPush.setVapidDetails(
  process.env.VAPID_SUBJECT!, // mailto:admin@nexus.local
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);
await webPush.sendNotification(subscription, JSON.stringify({
  title: "Nexus — Task completed",
  body: `${agentName} completed "${taskTitle}"`,
  data: { url: `/issues/${issueId}` }
}));
```

New server DB table: `push_subscriptions` (`id`, `endpoint`, `p256dh`, `auth`, `userId`, `deviceLabel`, `createdAt`).

New server routes:
- `GET /api/push/vapid-public-key` — returns public key to SW
- `POST /api/push/subscribe` — stores subscription
- `DELETE /api/push/subscribe` — removes subscription

### Anti-Patterns to Avoid

- **Re-implementing manifest:** `site.webmanifest` is complete. Do not recreate or add a generated manifest via `vite-plugin-pwa` — it will conflict with the existing file.
- **Using `100vh` for mobile layouts:** Always use `100dvh`. `100vh` on iOS Safari includes the browser address bar height, causing the sticky input to hide behind it.
- **Caching `/api/*` responses in the SW:** API responses must always be network-only to prevent stale data. Message queuing lives in the main thread (React hook + IndexedDB), not inside the service worker.
- **Installing vite-plugin-pwa:** Would require Workbox runtime (~20KB), a new config mechanism, and removal of the manual SW registration in `main.tsx`. Too invasive for what this phase needs.
- **Showing install prompt immediately on page load:** Browser will ignore `prompt()` unless the user has interacted. Wait for an engagement signal per UI-SPEC (user has visited at least one conversation).
- **Firing push notification subscription without permission check:** Always check `Notification.permission` before calling `pushManager.subscribe`. If `denied`, do not call — it will throw.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| IndexedDB offline queue | Custom IDB wrapper | `idb` 8.0.3 | Handles versioning, transactions, key paths; raw IDB API is notoriously complex to get right |
| Server-side Web Push | Custom VAPID signing | `web-push` npm | VAPID signing and push service compatibility across Chrome/Firefox/Safari requires spec-compliant crypto; `web-push` handles this correctly |
| Touch gesture (pull-to-refresh) | External library | Extend the existing `SwipeToArchive.tsx` pattern | Already proven in the codebase; same three touch events; no dependency overhead |

**Key insight:** The custom SW is already a project convention — `sw.js` is a hand-written file in `public/`. The upgrade continues that pattern rather than adopting a new plugin abstraction.

---

## Common Pitfalls

### Pitfall 1: `paperclip-v2` → `nexus-v1` Cache Name Bust
**What goes wrong:** The existing SW uses cache name `paperclip-v2`. If Phase 26 writes a new SW but keeps the same cache name, the old cached responses remain and the old SW's activate handler (which deletes all caches) will delete the new phase's cache on first update.
**Why it happens:** SW activation deletes all caches it doesn't recognize; if the name is the same, the old assets persist.
**How to avoid:** Rename cache to `nexus-v1` in the new SW. The new activate handler deletes `paperclip-v2` (and any other non-`nexus-v1` caches).
**Warning signs:** Cached UI shows old version after deploy.

### Pitfall 2: iOS Safari `beforeinstallprompt` Not Fired
**What goes wrong:** iOS Safari does not fire `beforeinstallprompt`. The install prompt flow must use Apple's alternative: `apple-mobile-web-app-capable` meta tag (already present) allows "Add to Home Screen" manually from the Share sheet. There is no programmatic install prompt on iOS.
**Why it happens:** Apple has not implemented the `BeforeInstallPromptEvent` API.
**How to avoid:** The `InstallPromptBanner` should still display on iOS with instruction text ("Open the Share menu and tap 'Add to Home Screen'") when `useInstallPrompt().canInstall` is false but the user is on mobile Safari (detect via `navigator.userAgent`). This is an accepted UX compromise documented in the UI-SPEC's "Add to Home Screen" copy.
**Warning signs:** Banner never shows on iPhone, users can't find install path.

### Pitfall 3: Virtual Keyboard Pushing Layout Up (`100vh` vs `100dvh`)
**What goes wrong:** On iOS, tapping the chat input causes the virtual keyboard to appear. If the message list height is calculated with `100vh`, the sticky input gets hidden behind the keyboard.
**Why it happens:** `100vh` is the viewport height when the page loaded (before keyboard). `100dvh` updates dynamically.
**How to avoid:** Use `h-[calc(100dvh-48px-56px-env(safe-area-inset-bottom))]` for the message list. Current browser support: ~95%+ of devices updated in the last 2 years. (Source: MDN + web.dev 2024.)
**Warning signs:** Input bar is hidden when keyboard opens on mobile.

### Pitfall 4: SW Update Race — Old SW Still Running
**What goes wrong:** When deploying the new SW (Phase 26 upgrade), users with the old SW still open in another tab will not get the new SW until they close all tabs.
**Why it happens:** SW update lifecycle: install succeeds, but `activate` waits for old SW clients to close.
**How to avoid:** The new SW already calls `self.skipWaiting()` in `install` and `self.clients.claim()` in `activate`. This forces the new SW to take over immediately. Verify both are present.
**Warning signs:** Old network-first behavior persists after deploy; cached load is still slow.

### Pitfall 5: `manualChunks` Circular Dependency Errors
**What goes wrong:** When adding `manualChunks` to `vite.config.ts`, Rollup may error on circular imports between manually chunked packages and pages.
**Why it happens:** If a page chunk imports from both `vendor-react` and something that `vendor-react` re-exports, Rollup may detect a cycle.
**How to avoid:** Add chunks one at a time and run `pnpm --filter @paperclipai/ui build` after each addition to verify. Start with `vendor-react` (most impactful, cleanest boundary).
**Warning signs:** Build fails with `Circular dependency` or `Invalid chunk`.

### Pitfall 6: Push Subscription Endpoint Changes on Browser Reinstall
**What goes wrong:** When a user reinstalls the app or clears browser data, their push subscription endpoint changes. Old endpoints stored in the DB will receive `410 Gone` from the push service.
**Why it happens:** Each browser subscription is a unique endpoint URL generated by the browser vendor's push service.
**How to avoid:** In the server's `sendNotification` error handler, delete any subscription that returns `410 Gone` or `404 Not Found` from the push service.
**Warning signs:** Push notifications silently fail for reinstalled apps.

### Pitfall 7: `React.lazy` Suspense Fallback During Navigation
**What goes wrong:** Adding lazy loading to all routes without a Suspense boundary causes React to throw `"A component suspended while responding to synchronous input"`.
**Why it happens:** Lazy components must be wrapped in `Suspense` or React throws on first render.
**How to avoid:** Wrap the entire `<Routes>` block (or each lazy route) in a `Suspense` with a minimal skeleton fallback. Use `<Skeleton>` from `ui/src/components/ui/skeleton.tsx` (already installed).
**Warning signs:** White flash or unhandled error on route navigation.

---

## Code Examples

### idb Offline Queue — openDB Pattern
```typescript
// Source: idb docs https://github.com/jakearchibald/idb
import { openDB, type IDBPDatabase } from "idb";

async function getDb(): Promise<IDBPDatabase> {
  return openDB("nexus-offline", 1, {
    upgrade(db) {
      db.createObjectStore("message_queue", { autoIncrement: true });
    },
  });
}
```

### VAPID Push Subscribe — Client Side
```typescript
// Source: MDN Push API docs
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
await fetch("/api/push/subscribe", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(subscription.toJSON()),
});

// urlBase64ToUint8Array utility (standard pattern)
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
```

### Offline Banner — navigator.onLine Pattern
```typescript
// Source: MDN online/offline events
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
  const on = () => setIsOnline(true);
  const off = () => setIsOnline(false);
  window.addEventListener("online", on);
  window.addEventListener("offline", off);
  return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); };
}, []);
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `100vh` for mobile full-height | `100dvh` (dynamic viewport height) | CSS spec ratified 2022, Safari 16+ (2022) | Input bar no longer hidden by keyboard |
| Network-first SW | Cache-first with stale-while-revalidate for assets | Industry standard since Workbox v6 (2021) | PWA cached load < 1s |
| Eager page imports in App.tsx | `React.lazy` + route-level `Suspense` | React 16.6+ feature, Vite always supported | Main bundle shrinks from ~1.4MB to ~200KB initial |
| `100%` height relative to parent | `h-screen` / `dvh` + flex column layout | Tailwind v3+ with `dvh` support | Correct height on mobile |

**Deprecated/outdated:**
- `paperclip-v2` cache name: replaced by `nexus-v1` to bust stale cache on SW upgrade.
- Network-first service worker: replaced by cache-first for static assets.

---

## Open Questions

1. **VAPID subject for push notifications**
   - What we know: `web-push` requires a `mailto:` or HTTPS URL as the VAPID subject for identification.
   - What's unclear: Whether Nexus has a stable instance URL or should use `mailto:admin@nexus.local`.
   - Recommendation: Use `mailto:admin@nexus.local` as the default; make it a configurable env var `VAPID_SUBJECT`.

2. **Push notification trigger points in the server**
   - What we know: PWA-06 requires notifications for agent mentions, task completions, and handoff requests. Server events for these exist (chat routes, agent streaming routes, handoff routes).
   - What's unclear: Phase 26 is scoped to wiring up the push infrastructure; actually emitting push notifications from all agent event types may require touching agent-streaming routes beyond this phase.
   - Recommendation: Implement push sending for handoff completions (already in `chat.ts`) and task-created badges (already emitted in Phase 23). Stub the others with a `sendPushToAll(companyId, payload)` helper that future phases call.

3. **`BeforeInstallPromptEvent` TypeScript type availability**
   - What we know: This is a non-standard Chrome-only event. TypeScript's `lib.dom.d.ts` does not include it.
   - What's unclear: Whether `@types/wicg-beforeinstallprompt` is needed or a local type declaration suffices.
   - Recommendation: Add a local declaration `declare global { interface Window { BeforeInstallPromptEvent: ... } }` in `ui/src/types/pwa.d.ts` rather than adding a new type package.

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | server `web-push` | ✓ | v20.20.2 | — |
| pnpm | package install | ✓ | 9.15.4 | — |
| `idb` npm package | `useOfflineQueue` | ✗ (not installed) | 8.0.3 latest | — (must install) |
| `web-push` npm package | push notification server | ✗ (not installed) | 3.6.7 latest | — (must install) |
| IndexedDB browser API | offline queue | ✓ | Universal (Safari 10+, Chrome 24+) | localStorage for < 5 messages (degraded) |
| Push API + Service Worker | push notifications | ✓ Chrome/Firefox/Safari 16.4+ | — | Graceful degradation: feature-detect, no error if absent |
| `navigator.vibrate` | pull-to-refresh haptic | ~80% of Android; absent on iOS | — | Silent no-op via `navigator.vibrate?.()` |

**Missing dependencies requiring install before execution:**
- `idb` in `@paperclipai/ui`
- `web-push` + `@types/web-push` in `@paperclipai/server`

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 3.0.5 |
| Config file | `ui/vitest.config.ts` |
| Quick run command | `pnpm --filter @paperclipai/ui test --run` |
| Full suite command | `pnpm --filter @paperclipai/ui test --run` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PWA-01 | `useOfflineQueue` enqueues when offline, flushes on `online` event | unit | `pnpm --filter @paperclipai/ui test --run useOfflineQueue` | ❌ Wave 0 |
| PWA-05 | `PullToRefresh` calls `onRefresh` after 64px drag threshold | unit (jsdom) | `pnpm --filter @paperclipai/ui test --run PullToRefresh` | ❌ Wave 0 |
| PWA-08 | `useInstallPrompt` captures `beforeinstallprompt` and calls `prompt()` | unit | `pnpm --filter @paperclipai/ui test --run useInstallPrompt` | ❌ Wave 0 |
| PERF-01 | `App.tsx` all pages use `lazy()` — no eager page imports | unit (import analysis) | `pnpm --filter @paperclipai/ui test --run App` | ❌ Wave 0 |
| PWA-03 | `MobileNavBar` renders correct tabs | unit (jsdom) | `pnpm --filter @paperclipai/ui test --run MobileNavBar` | ❌ Wave 0 |
| PWA-06 | `usePushNotifications` subscribes when permission granted, no-op when denied | unit | `pnpm --filter @paperclipai/ui test --run usePushNotifications` | ❌ Wave 0 |

Note: Vitest config uses `environment: "node"` by default. Tests that need DOM (jsdom) must add the `// @vitest-environment jsdom` pragma at the top — consistent with existing `SwipeToArchive.test.tsx` pattern.

### Sampling Rate
- **Per task commit:** `pnpm --filter @paperclipai/ui test --run`
- **Per wave merge:** `pnpm --filter @paperclipai/ui test --run`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `ui/src/hooks/useOfflineQueue.test.ts` — covers PWA-01
- [ ] `ui/src/components/PullToRefresh.test.tsx` — covers PWA-05 (use jsdom pragma, mirrors SwipeToArchive.test.tsx)
- [ ] `ui/src/hooks/useInstallPrompt.test.ts` — covers PWA-08
- [ ] `ui/src/hooks/usePushNotifications.test.ts` — covers PWA-06
- [ ] `ui/src/components/MobileNavBar.test.tsx` — covers PWA-03

---

## Project Constraints (from CLAUDE.md)

CLAUDE.md does not exist in `/opt/nexus`. Constraints derived from codebase conventions observed across Phases 21–25:

- Use object-syntax `(table) => ({})` for Drizzle index callbacks (not inline)
- Avoid `exec` for shell commands; use `execFile` with array args
- Use `@/lib/router` Link abstraction, not `react-router-dom` Link directly
- Custom `ToastContext` (`useToast`/`pushToast`), not `sonner`
- Use `it.todo()` (not `it.skip()`) for Wave 0 test stubs
- Touch gesture components (SwipeToArchive precedent): use `useRef` for start coords, `useState` for animated offset, native DOM touch events not React synthetic events
- Fire-and-forget side effects (git commits, placeholder updates): do not block responses
- `localStorage` key namespace: `nexus:*` (e.g., `nexus:chat-panel-open`); Phase 26 uses `nexus.installPromptDismissed` and `nexus.notifPromptDismissed` per UI-SPEC

---

## Sources

### Primary (HIGH confidence)
- MDN Web Docs — BeforeInstallPromptEvent, Push API, Service Worker API, online/offline events, dvh units
- `idb` GitHub/npm — version 8.0.3 confirmed via `npm view idb version`
- `web-push` GitHub/npm — version 3.6.7 confirmed via `npm view web-push version`
- Existing codebase — `ui/public/sw.js`, `ui/public/site.webmanifest`, `ui/index.html`, `ui/src/main.tsx`, `ui/src/components/SwipeToArchive.tsx`, `ui/src/components/ChatPanel.tsx`, `ui/src/App.tsx`, bundle output in `ui/dist/assets/`

### Secondary (MEDIUM confidence)
- [web.dev — Service Worker Caching Strategies](https://developer.chrome.com/docs/workbox/caching-strategies-overview) — cache-first for static assets
- [web.dev — Installation prompt](https://web.dev/learn/pwa/installation-prompt) — beforeinstallprompt pattern
- [MDN — VirtualKeyboard API](https://developer.mozilla.org/en-US/docs/Web/API/VirtualKeyboard_API) — dvh handling
- [LogRocket — Pull-to-refresh in React](https://blog.logrocket.com/implementing-pull-to-refresh-react-tailwind-css/) — touch gesture pattern (2024)

### Tertiary (LOW confidence)
- WebSearch results on Vite code splitting best practices 2025 — recommend verifying bundle sizes post-split with Lighthouse

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — versions confirmed from npm registry; existing codebase confirmed as baseline
- Architecture: HIGH — directly derived from existing `sw.js`, `SwipeToArchive.tsx`, and `ChatPanel.tsx` patterns
- Pitfalls: HIGH (iOS `beforeinstallprompt`, `100dvh`) / MEDIUM (`manualChunks` circular deps — common but codebase-specific)
- Performance budget: MEDIUM — main bundle size confirmed (1.4 MB); lazy-loading improvement estimate based on industry patterns, not measured on this specific app

**Research date:** 2026-04-01
**Valid until:** 2026-05-01 (stable APIs; Vite/React version pinned)
