22 KiB
| phase | verified | status | score | re_verification | gaps | human_verification | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 26-pwa-performance | 2026-04-01T00:00:00Z | gaps_found | 8/10 must-haves verified | false |
|
|
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)