---
phase: 26-pwa-performance
verified: 2026-04-01T00:00:00Z
status: gaps_found
score: 8/10 must-haves verified
re_verification: false
gaps:
  - truth: "Vite build produces separate vendor chunks for heavy libraries"
    status: partial
    reason: "vendor-react chunk is 1 byte (newline only) — React and react-dom are NOT extracted from the main entry bundle. The manualChunks config is present in vite.config.ts but @vitejs/plugin-react injects React via automatic JSX runtime in a way that bypasses Rollup's manualChunks splitting. vendor-router, vendor-query, and vendor-markdown ARE correctly extracted. The main entry bundle is 2.1 MB."
    artifacts:
      - path: "ui/dist/assets/vendor-react-l0sNRNKZ.js"
        issue: "1 byte (newline only) — react and react-dom are bundled into the 2.1 MB index-QG5IJOTw.js main entry instead"
      - path: "ui/vite.config.ts"
        issue: "manualChunks config correct, but Vite/React plugin injects React outside Rollup splitting path"
    missing:
      - "Use modulePreload or dynamic import shim approach for react/react-dom splitting, or accept that React is in the main bundle and verify the 2.1 MB chunk still meets < 2s broadband load target"
  - truth: "PWA-02 and PWA-08 requirement tracking is consistent with codebase state"
    status: failed
    reason: "REQUIREMENTS.md shows PWA-02 (Web App Manifest) and PWA-08 (Add to Home Screen prompt) as unchecked/Pending even though both are fully implemented. The webmanifest is complete and linked in index.html. InstallPromptBanner is wired into ChatPanel and fully functional. The tracking file was not updated after plan completion."
    artifacts:
      - path: ".planning/REQUIREMENTS.md"
        issue: "PWA-02 and PWA-08 checkboxes are unchecked ([ ]) and status column shows 'Pending' — both are actually implemented"
    missing:
      - "Update REQUIREMENTS.md: change '- [ ] **PWA-02**' to '- [x] **PWA-02**' and '| PWA-02 | Phase 26 | Pending |' to '| PWA-02 | Phase 26 | Complete |'"
      - "Update REQUIREMENTS.md: change '- [ ] **PWA-08**' to '- [x] **PWA-08**' and '| PWA-08 | Phase 26 | Pending |' to '| PWA-08 | Phase 26 | Complete |'"
human_verification:
  - test: "Verify < 2s broadband load with 2.1 MB main bundle"
    expected: "Page becomes interactive in under 2 seconds on broadband (20+ Mbps); PWA cached load under 1 second"
    why_human: "Actual load time depends on server, CDN, and network conditions. The 2.1 MB uncompressed main bundle (likely ~400-600 KB gzipped) should meet the target on broadband but cannot be confirmed programmatically without a real browser performance trace."
  - test: "Verify PWA installability on a real Android or iOS device"
    expected: "Browser displays 'Add to Home Screen' / install prompt. After installation, app opens as a standalone window with no browser chrome, correct Nexus icon, and theme-aware background."
    why_human: "PWA installability requires a real browser + HTTPS + manifest validation. Cannot verify programmatically from codebase inspection alone."
  - test: "Verify offline message queuing end-to-end"
    expected: "With network disabled, typing and sending a message queues it to IndexedDB. On reconnect, the message sends automatically and disappears from the queue."
    why_human: "Requires browser interaction to toggle network state and verify IndexedDB persistence + auto-flush."
  - test: "Verify push notifications end-to-end (requires VAPID keys configured)"
    expected: "After granting notification permission, a server-sent push notification appears as a native notification and clicking it opens the correct URL."
    why_human: "Requires VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY env vars, a real browser with SW registration, and a server running to POST a test push."
---

# Phase 26: PWA & Performance Verification Report

**Phase Goal:** Nexus is installable as a standalone app on any device, loads under 2 seconds, and works offline — delivering the full chat experience on phone, tablet, and desktop
**Verified:** 2026-04-01
**Status:** gaps_found (2 gaps — 1 build output issue, 1 tracking inconsistency)
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Service worker uses cache-first for static assets and navigation with nexus-v1 cache | VERIFIED | `sw.js` lines 1, 40-57: `CACHE_NAME = "nexus-v1"`, `caches.match("/")` before `fetch(request)` for navigation, `caches.match(request)` before fetch for static extensions |
| 2 | Old paperclip-v2 cache is deleted on SW activation | VERIFIED | `sw.js`: activate event deletes all caches not equal to `"nexus-v1"` — `grep -c "paperclip" ui/public/sw.js` = 0 |
| 3 | Service worker handles push and notificationclick events | VERIFIED | `sw.js` lines 69-93: `self.addEventListener("push", ...)` and `self.addEventListener("notificationclick", ...)` both present |
| 4 | All page components in App.tsx are loaded via React.lazy | VERIFIED | `grep -c "lazy(" ui/src/App.tsx` = 37; `Suspense` wraps `<Routes>` with `Skeleton` fallback; `lazy, Suspense` imported from react |
| 5 | Vite build produces separate vendor chunks for heavy libraries | PARTIAL | vendor-router (50 KB), vendor-query (47 KB), vendor-markdown (165 KB) are extracted. vendor-react is 1 byte (empty) — React and react-dom remain in the 2.1 MB main entry chunk |
| 6 | On screens < 768px, chat renders as a full-screen MobileChatView | VERIFIED | `ChatPanel.tsx` lines 34-276: `const isDesktop = useMediaQuery("(min-width: 768px)")`, `if (!isDesktop) return <MobileChatView />` |
| 7 | Mobile chat has 48px header, back button, sticky input with safe-area padding | VERIFIED | `MobileChatView.tsx`: `h-[100dvh]` container, `aria-label="Back to conversations"` on back button, `sticky bottom-0 ... pb-[env(safe-area-inset-bottom)]` on input bar |
| 8 | Pulling down on conversation list triggers refresh after 64px threshold | VERIFIED | `usePullToRefresh.ts` line 23: `threshold = 64`; `ChatConversationList.tsx` wraps ScrollArea in `<PullToRefresh>`; `navigator.vibrate?.(10)` for haptic feedback |
| 9 | Offline banner appears with queued message count; messages queue to IndexedDB | VERIFIED | `OfflineBanner.tsx` uses `useOnlineStatus`; `useOfflineQueue.ts` uses `openDB("nexus-offline", ...)` with `message_queue` store; `ChatPanel.tsx` calls `enqueue()` when `!isOnline` |
| 10 | PWA install prompt shows with iOS fallback; push notifications end-to-end | VERIFIED | `InstallPromptBanner.tsx`: shows when `canInstall\|\|isIOS`, iOS branch shows Share menu text, 7-day localStorage cooldown. `pushService.ts` + `push.ts` routes + `usePushNotifications.ts` all wired end-to-end |

**Score:** 9/10 truths verified (1 partial on vendor-react splitting)

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `ui/public/sw.js` | Cache-first SW with push handler | VERIFIED | nexus-v1 cache, cache-first for static+nav, push+notificationclick handlers present |
| `ui/src/types/pwa.d.ts` | BeforeInstallPromptEvent type | VERIFIED | `BeforeInstallPromptEvent` interface + `WindowEventMap` augmentation |
| `ui/src/App.tsx` | Lazy-loaded page routes with Suspense | VERIFIED | 37 `lazy(` calls, Suspense wraps Routes, Skeleton fallback |
| `ui/vite.config.ts` | Manual chunk splitting for vendors | VERIFIED (partial) | `manualChunks` config present; vendor-router/query/markdown extracted; vendor-react empty |
| `ui/src/components/MobileChatView.tsx` | Full-screen mobile chat layout | VERIFIED | 283 lines; `h-[100dvh]`, back button, PullToRefresh list view, sticky input |
| `ui/src/components/PullToRefresh.tsx` | Touch gesture wrapper | VERIFIED | 81 lines; Loader2 spinner with "Pull to refresh" / "Release to refresh" text |
| `ui/src/hooks/usePullToRefresh.ts` | Touch gesture logic | VERIFIED | 86 lines; 64px threshold, haptic feedback via `navigator.vibrate?.(10)` |
| `ui/src/hooks/useMediaQuery.ts` | Responsive breakpoint hook | VERIFIED | 29 lines; `window.matchMedia` + `addEventListener("change")`; SSR-safe |
| `ui/src/hooks/useInstallPrompt.ts` | Captures beforeinstallprompt event | VERIFIED | 47 lines; captures event, iOS detection via userAgent, promptInstall() |
| `ui/src/hooks/useOfflineQueue.ts` | IndexedDB queue with auto-flush | VERIFIED | 96 lines; `openDB("nexus-offline")`, `message_queue` store, flush on `online` event |
| `ui/src/hooks/useOnlineStatus.ts` | Reactive navigator.onLine state | VERIFIED | 26 lines; `navigator.onLine` initial state, `online`/`offline` listeners |
| `ui/src/components/InstallPromptBanner.tsx` | PWA install prompt UI | VERIFIED | 74 lines; 7-day cooldown, iOS text variant, "Add to Home Screen" CTA |
| `ui/src/components/OfflineBanner.tsx` | Offline status banner | VERIFIED | 46 lines; amber light/dark theming, `WifiOff` icon, queue count display |
| `packages/db/src/schema/push_subscriptions.ts` | Push subscription DB table | VERIFIED | `pgTable`, pg-core imports, `endpoint`/`p256dh`/`auth` columns, endpoint index |
| `packages/db/src/migrations/0055_create_push_subscriptions.sql` | SQL migration | VERIFIED | `CREATE TABLE IF NOT EXISTS "push_subscriptions"` + `CREATE INDEX` |
| `server/src/services/pushService.ts` | VAPID config + sendPush helper | VERIFIED | `initVapid`, `getVapidPublicKey`, `saveSubscription`, `removeSubscription`, `sendPushToAll` with 410/404 stale cleanup |
| `server/src/routes/push.ts` | Push API routes | VERIFIED | GET `/vapid-public-key`, POST `/subscribe`, DELETE `/subscribe` |
| `ui/src/hooks/usePushNotifications.ts` | Push subscription management | VERIFIED | `pushManager.subscribe`, `urlBase64ToUint8Array`, `isSupported` guard |
| `ui/src/components/NotificationPermissionPrompt.tsx` | Permission request UI | VERIFIED | "Stay in the loop" heading, `agentResponseCount >= 3` gate, localStorage dismiss |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `ui/public/sw.js` | `ui/src/main.tsx` | SW registration on load event | VERIFIED | `main.tsx` line 27: `navigator.serviceWorker.register("/sw.js")` |
| `ui/src/App.tsx` | `ui/src/pages/*` | `React.lazy(() => import('./pages/...'))` | VERIFIED | 37 lazy imports; named-export pages use `.then(m => ({ default: m.X }))` pattern |
| `ui/vite.config.ts` | node_modules | `manualChunks` vendor splitting | PARTIAL | router/query/markdown chunks created; vendor-react is 1 byte (React not extracted) |
| `ui/src/components/ChatPanel.tsx` | `ui/src/components/MobileChatView.tsx` | Conditional render on `useMediaQuery` | VERIFIED | `ChatPanel.tsx` lines 15+34+275-276: import, `isDesktop = useMediaQuery("(min-width: 768px)")`, `if (!isDesktop) return <MobileChatView />` |
| `ui/src/components/ChatConversationList.tsx` | `ui/src/components/PullToRefresh.tsx` | PullToRefresh wrapper around conversation list | VERIFIED | `ChatConversationList.tsx` line 6+159: import + `<PullToRefresh onRefresh={...} enabled={isMobile}>` |
| `ui/src/hooks/useOfflineQueue.ts` | `idb` | `openDB` for IndexedDB access | VERIFIED | Line 1: `import { openDB } from "idb"`, line 15: `openDB(DB_NAME, 1, ...)` |
| `ui/src/components/InstallPromptBanner.tsx` | `ui/src/hooks/useInstallPrompt.ts` | `canInstall` + `promptInstall` from hook | VERIFIED | Line 3: `import { useInstallPrompt }`, line 30: destructured and used |
| `ui/src/components/OfflineBanner.tsx` | `ui/src/hooks/useOnlineStatus.ts` | `isOnline` state for show/hide | VERIFIED | Wired via internal `useOnlineStatus()` call |
| `ui/src/components/ChatPanel.tsx` | `OfflineBanner` + `InstallPromptBanner` | Rendered at top of ChatPanel JSX | VERIFIED | Lines 16-17: imports; line 286: `<OfflineBanner queuedCount={queuedCount} />`, line 439: `<InstallPromptBanner />` |
| `ui/src/hooks/usePushNotifications.ts` | `server/src/routes/push.ts` | POST `/api/push/subscribe` | VERIFIED | `usePushNotifications.ts` line ~56+: calls `pushApi.subscribe(sub.toJSON())` which POSTs to `/api/push/subscribe` |
| `server/src/services/pushService.ts` | `web-push` | `webPush.sendNotification()` | VERIFIED | Line 76: `await webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, ...)` |
| `server/src/routes/push.ts` | `packages/db/src/schema/push_subscriptions.ts` | Drizzle insert/delete on push_subscriptions | VERIFIED | `push.ts` imports and queries `pushSubscriptions` table via `saveSubscription`/`removeSubscription` |
| `server/src/app.ts` | `server/src/routes/push.ts` | Route mounting at `/api/push` | VERIFIED | `app.ts` line 155: `api.use("/push", pushRoutes(db))`, line 302: `initVapid()` |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `OfflineBanner.tsx` | `queuedCount` | `useOfflineQueue.ts` → IndexedDB `message_queue` store → `queuedCount` state | Yes — IndexedDB store count | FLOWING |
| `useOfflineQueue.ts` | flush → `chatApi.postMessage` | `chatApi.ts` line 56+: `postMessage` method makes POST request | Yes — real API call | FLOWING |
| `MobileChatView.tsx` | `messages`, `streamingMessage` | Via ChatPanel context (`useChatMessages`, `useStreamingChat`) | Yes — same hooks as desktop | FLOWING |
| `ChatConversationList.tsx` | conversation list | `useChatConversations` hook (React Query) | Yes — real DB-backed API | FLOWING |
| `NotificationPermissionPrompt.tsx` | `agentResponseCount` | `ChatPanel.tsx` line 53: `useMemo` filtering `messages` array by `role === "assistant"` | Yes — derived from live messages | FLOWING |
| `pushService.ts → sendPushToAll` | subscription rows | `db.select(...).from(pushSubscriptions)` Drizzle query | Yes — PostgreSQL DB query | FLOWING |

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| SW cache name is nexus-v1 | `grep "nexus-v1" ui/public/sw.js` | Found on line 1 | PASS |
| No paperclip references in SW | `grep -c "paperclip" ui/public/sw.js` | 0 | PASS |
| App.tsx has 37+ lazy imports | `grep -c "lazy(" ui/src/App.tsx` | 37 | PASS |
| vendor-router chunk extracted | `ls -la ui/dist/assets/vendor-router*.js` | 50 KB | PASS |
| vendor-react chunk extracted | `ls -la ui/dist/assets/vendor-react*.js` | 1 byte (empty) | FAIL |
| SW registered in main.tsx | `grep "serviceWorker.register" ui/src/main.tsx` | Found line 27 | PASS |
| Push routes mounted in app.ts | `grep "pushRoutes\|/push" server/src/app.ts` | Lines 34+155 | PASS |
| Stale 410/404 cleanup in pushService | `grep "410\|404" server/src/services/pushService.ts` | Lines 81-84 | PASS |
| PullToRefresh 64px threshold | `grep "threshold = 64" ui/src/hooks/usePullToRefresh.ts` | Line 23 | PASS |
| Safe-area padding on ChatInput | `grep "safe-area-inset-bottom" ui/src/components/ChatInput.tsx` | Line 179 | PASS |

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| PWA-01 | 26-00, 26-03 | Service worker offline capability + message queue | SATISFIED | `sw.js` cache-first SW; `useOfflineQueue` IndexedDB queue in ChatPanel |
| PWA-02 | 26-03 | Web App Manifest installable on all platforms | SATISFIED (tracking gap) | `site.webmanifest` complete with `display:standalone`, maskable icon, theme_color; linked in `index.html` with Apple PWA meta tags. REQUIREMENTS.md checkbox incorrectly shows unchecked. |
| PWA-03 | 26-02 | Responsive layout adapts to phone/tablet/desktop | SATISFIED | `MobileChatView.tsx` for mobile; desktop ChatPanel unchanged; `useMediaQuery` breakpoint |
| PWA-04 | 26-02 | Mobile-optimized input: touch targets, sticky bar, keyboard-aware | SATISFIED | `ChatInput.tsx`: `min-h-[44px] min-w-[44px]` send button, `pb-[env(safe-area-inset-bottom)]`; `MobileChatView.tsx`: sticky input bar |
| PWA-05 | 26-02 | Pull-to-refresh on mobile conversation list | SATISFIED | `PullToRefresh.tsx` + `usePullToRefresh.ts` wired into `ChatConversationList.tsx` |
| PWA-06 | 26-04 | Push notifications for agent mentions/completions | SATISFIED | Full stack: DB schema + migration + server service + routes + client hook + permission prompt |
| PWA-07 | 26-00 | App icon, splash screen, Nexus branding | SATISFIED | `site.webmanifest` has 192px + 512px (maskable) icons; `index.html` has `apple-touch-icon` + `apple-mobile-web-app-*` meta tags; theme-aware `theme_color` in manifest and JS script in index.html |
| PWA-08 | 26-03 | Add to Home Screen prompt | SATISFIED (tracking gap) | `InstallPromptBanner.tsx` wired into `ChatPanel.tsx`; fires on `beforeinstallprompt` (Chrome/Android first eligible visit) or shows iOS Share instructions; 7-day dismiss cooldown. REQUIREMENTS.md checkbox incorrectly shows unchecked. |
| PERF-01 | 26-01 | Initial load < 2s broadband, < 5s on 3G | PARTIAL | Route splitting working (37 lazy page chunks). vendor-react not extracted — React in 2.1 MB main bundle. vendor-router/query/markdown correctly split. Needs human verification of actual load times. |
| PERF-05 | 26-00, 26-01 | PWA cached load < 1s | SATISFIED (needs human confirm) | Cache-first SW pre-caches `/` and `/index.html`; static assets cached on first load; SW serves from cache on revisit. Actual timing requires browser measurement. |

**Orphaned requirements:** None — all 10 phase 26 requirements appear in at least one plan's `requirements:` field.

**Tracking inconsistency:** PWA-02 and PWA-08 are implemented but REQUIREMENTS.md shows them as "Pending". This is a documentation gap, not an implementation gap.

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `ui/dist/assets/vendor-react-l0sNRNKZ.js` | 1 | 1-byte file (newline only) — React not split into vendor chunk | Warning | React and react-dom (~140 KB gzipped) remain in 2.1 MB main entry bundle. Browser cannot cache React separately from app code. Reduces cache stability benefit for PERF-05. |
| `.planning/REQUIREMENTS.md` | checkbox rows | PWA-02 and PWA-08 marked `[ ]` Pending | Info | Tracking inconsistency only — both are implemented. No code impact. |

### Human Verification Required

#### 1. Confirm < 2s load time with 2.1 MB main bundle

**Test:** Open Chrome DevTools, set network throttling to "Fast 3G", navigate to the Nexus app. Open the Performance tab and measure Time to Interactive (TTI).
**Expected:** TTI under 5 seconds on Fast 3G; under 2 seconds on broadband (no throttling).
**Why human:** Actual load performance depends on server, CDN, and real browser behavior. The 2.1 MB uncompressed main bundle (likely ~400-600 KB gzipped) should meet the target but cannot be confirmed without a live browser performance trace.

#### 2. PWA installability on Android / iOS device

**Test:** On an Android device (Chrome), visit the app over HTTPS. Observe whether the "Add to Home Screen" prompt or mini-infobar appears. On iOS (Safari), open the Share menu and confirm "Add to Home Screen" is visible.
**Expected:** Prompt appears on Android; Share menu option visible on iOS. After installing, app opens as standalone (no URL bar).
**Why human:** PWA installability requires: HTTPS, service worker active, manifest with all required fields, and browser eligibility checks. These cannot be verified without a real device + live server.

#### 3. Offline message queue end-to-end

**Test:** Open the app in a conversation. Open DevTools > Network > Offline mode. Type a message and send. Confirm toast "Message queued" appears and the OfflineBanner shows. Re-enable network. Confirm message sends and disappears from queue.
**Expected:** Message enqueued to IndexedDB when offline; auto-flushed and delivered on reconnect; OfflineBanner auto-dismisses 3 seconds after reconnect.
**Why human:** Requires browser network toggle interaction and IndexedDB observation.

#### 4. Push notifications end-to-end (requires VAPID keys)

**Test:** Configure `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` env vars (generate with `npx web-push generate-vapid-keys`). Start the server. In a browser, grant notification permission via the NotificationPermissionPrompt (appears after 3 agent responses). Use a test script calling `sendPushToAll(db, null, { title: "Test", body: "Hello" })`. Verify a native browser notification appears.
**Expected:** Native notification appears; clicking it opens the correct URL.
**Why human:** Requires VAPID key configuration, a running server, and a real browser with SW registration.

---

## Gaps Summary

**2 gaps identified:**

**Gap 1 — vendor-react chunk empty (Warning):** The `manualChunks` configuration correctly specifies `"vendor-react": ["react", "react-dom"]` in `vite.config.ts`, and three other vendor chunks (router, query, markdown) were successfully extracted. However, the vendor-react chunk in the build output is 1 byte (a newline). This is a known interaction between `@vitejs/plugin-react` and Rollup's `manualChunks` — the automatic JSX runtime injected by the Vite React plugin routes React through a different import path that bypasses the chunk assignment. React and react-dom remain bundled in the 2.1 MB main entry file. This means: (a) browsers cannot cache React separately from app code; (b) the main entry is larger than it would be with successful splitting. Three other vendor chunks ARE correctly extracted and cached separately. The lazy page splitting (37 pages as separate chunks) IS working correctly and is the primary load-time optimization. The impact on PERF-01 is uncertain and needs human measurement.

**Gap 2 — REQUIREMENTS.md tracking inconsistency (Info):** PWA-02 (Web App Manifest) and PWA-08 (Add to Home Screen prompt) are marked as "Pending" / unchecked in REQUIREMENTS.md. Both are actually implemented: the webmanifest file is complete with all required fields (`display:standalone`, maskable icon, theme_color), properly linked in `index.html` with Apple PWA meta tags. The InstallPromptBanner is wired into ChatPanel, handles both Chrome/Android (beforeinstallprompt) and iOS (Share menu instructions), and includes a 7-day localStorage dismiss cooldown. Plan 03 claims both requirements in its `requirements:` field. The REQUIREMENTS.md was not updated after plan completion. This is a documentation gap only — no code changes required, only REQUIREMENTS.md checkbox updates.

---

_Verified: 2026-04-01_
_Verifier: Claude (gsd-verifier)_
