34 KiB
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:
# 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.
// 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.
// 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.
// 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.
// 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:
// 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.
// 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).
// 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.
// 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 SWPOST /api/push/subscribe— stores subscriptionDELETE /api/push/subscribe— removes subscription
Anti-Patterns to Avoid
- Re-implementing manifest:
site.webmanifestis complete. Do not recreate or add a generated manifest viavite-plugin-pwa— it will conflict with the existing file. - Using
100vhfor mobile layouts: Always use100dvh.100vhon 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.permissionbefore callingpushManager.subscribe. Ifdenied, 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
// 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
// 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
// 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-v2cache name: replaced bynexus-v1to bust stale cache on SW upgrade.- Network-first service worker: replaced by cache-first for static assets.
Open Questions
-
VAPID subject for push notifications
- What we know:
web-pushrequires amailto: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.localas the default; make it a configurable env varVAPID_SUBJECT.
- What we know:
-
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 asendPushToAll(companyId, payload)helper that future phases call.
-
BeforeInstallPromptEventTypeScript type availability- What we know: This is a non-standard Chrome-only event. TypeScript's
lib.dom.d.tsdoes not include it. - What's unclear: Whether
@types/wicg-beforeinstallpromptis needed or a local type declaration suffices. - Recommendation: Add a local declaration
declare global { interface Window { BeforeInstallPromptEvent: ... } }inui/src/types/pwa.d.tsrather than adding a new type package.
- What we know: This is a non-standard Chrome-only event. TypeScript's
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:
idbin@paperclipai/uiweb-push+@types/web-pushin@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-01ui/src/components/PullToRefresh.test.tsx— covers PWA-05 (use jsdom pragma, mirrors SwipeToArchive.test.tsx)ui/src/hooks/useInstallPrompt.test.ts— covers PWA-08ui/src/hooks/usePushNotifications.test.ts— covers PWA-06ui/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
execfor shell commands; useexecFilewith array args - Use
@/lib/routerLink abstraction, notreact-router-domLink directly - Custom
ToastContext(useToast/pushToast), notsonner - Use
it.todo()(notit.skip()) for Wave 0 test stubs - Touch gesture components (SwipeToArchive precedent): use
useReffor start coords,useStatefor animated offset, native DOM touch events not React synthetic events - Fire-and-forget side effects (git commits, placeholder updates): do not block responses
localStoragekey namespace:nexus:*(e.g.,nexus:chat-panel-open); Phase 26 usesnexus.installPromptDismissedandnexus.notifPromptDismissedper UI-SPEC
Sources
Primary (HIGH confidence)
- MDN Web Docs — BeforeInstallPromptEvent, Push API, Service Worker API, online/offline events, dvh units
idbGitHub/npm — version 8.0.3 confirmed vianpm view idb versionweb-pushGitHub/npm — version 3.6.7 confirmed vianpm 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 inui/dist/assets/
Secondary (MEDIUM confidence)
- web.dev — Service Worker Caching Strategies — cache-first for static assets
- web.dev — Installation prompt — beforeinstallprompt pattern
- MDN — VirtualKeyboard API — dvh handling
- LogRocket — Pull-to-refresh in React — 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, andChatPanel.tsxpatterns - Pitfalls: HIGH (iOS
beforeinstallprompt,100dvh) / MEDIUM (manualChunkscircular 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)