34 KiB
Phase 8: Mobile + PWA - Research
Researched: 2026-03-25 Domain: Responsive layout, PWA (manifest + service worker), push notifications, mobile UX patterns Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- Dual navigation: bottom tab bar (5 items) for main nav + hamburger/"More" menu for secondary items
- Bottom tab bar items: Dashboard, Employees, Chat, Usage, More
- "More" menu contains: Billing, API Keys, Users, Platform, Settings, Sign Out
- Breakpoint: 768px (md) — tablets and below get mobile layout, desktop keeps the sidebar
- RBAC still applies — operators don't see admin-only items in either nav or "More"
- Chat: conversation list → full-screen chat pattern (like WhatsApp/iMessage)
- Top bar in full-screen chat: back arrow + agent name + agent avatar
- Message input: fixed at bottom, pushes content up when virtual keyboard opens
- App icon: bold "K" monogram on gradient mesh background (matching login page aesthetic)
- Splash screen: same gradient + K branding
- Install prompt: smart banner on second visit — not intrusive
- Offline: app shell cached + recently viewed pages. Chat history viewable offline.
- Offline banner: shows "You're offline" indicator when disconnected
- Message queue: new messages queue locally until reconnection, then send automatically
- Push notifications: MUST HAVE — users get notified when AI Employee responds
Claude's Discretion
- "More" menu exact style (bottom sheet vs full-screen overlay vs side drawer)
- Service worker caching strategy (workbox, serwist, or manual)
- Push notification provider (Web Push API, Firebase Cloud Messaging, or OneSignal)
- Touch gesture handling (swipe-to-go-back, pull-to-refresh, etc.)
- Tablet-specific layout adjustments (if any beyond the breakpoint)
- PWA manifest theme color and background color
- How to handle the language switcher on mobile
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| MOB-01 | All portal pages render correctly and are usable on mobile (320px–480px) and tablet (768px–1024px) screens | Tailwind v4 md: responsive utilities + layout restructuring |
| MOB-02 | Sidebar collapses to bottom tab bar on mobile with smooth open/close animation | New MobileNav component using md:hidden / hidden md:flex split; bottom tab bar pattern |
| MOB-03 | Chat interface is fully functional on mobile — send messages, see streaming responses, scroll history | Mobile full-screen chat mode + Visual Viewport API for keyboard handling |
| MOB-04 | Portal installable as a PWA with app icon, splash screen, and service worker for offline shell caching | app/manifest.ts + @serwist/next + icon assets in public/ |
| MOB-05 | Push notifications for new messages when PWA is installed | web-push library + VAPID keys + push subscription stored in DB + service worker push handler |
| MOB-06 | All touch interactions feel native — no hover-dependent UI that breaks on touch devices | Tailwind v4 already scopes hover: inside @media (hover: hover) — verify no manual hover-dependent flows |
| </phase_requirements> |
Summary
Phase 8 converts the existing desktop-only portal (fixed 260px sidebar, two-column chat layout) into a fully responsive mobile experience and adds PWA installability with offline support and push notifications. The portal is built on Next.js 16 App Router with Tailwind v4, which provides nearly all the responsive and touch-safe primitives needed.
The responsive work is primarily additive CSS via Tailwind's md: breakpoint — hide the sidebar on mobile, add a bottom tab bar below it, restructure pages that use horizontal layouts into stacked vertical layouts. The chat page requires logic-level changes to implement the WhatsApp-style full-screen conversation toggle on mobile.
PWA infrastructure uses three coordinated pieces: app/manifest.ts (built into Next.js 16), Serwist (@serwist/next) for service worker and offline caching, and the native Web Push API with the web-push npm library for push notifications. The output: "standalone" config is already set in next.config.ts, making the app PWA-compatible at the infrastructure level.
Primary recommendation: Use @serwist/next for service worker and caching. Use web-push + VAPID for push notifications (no third-party dependency). Handle the iOS keyboard problem with the Visual Viewport API. Recommend bottom sheet for the "More" menu.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
@serwist/next |
^9.x | Service worker generation, offline caching, precaching | Official Next.js recommendation; Workbox fork maintained actively; Turbopack-incompatible (use --webpack flag) |
web-push |
^3.6.x | VAPID key generation, push notification sending from server | Official Web Push Protocol implementation; no third-party service needed |
idb |
^8.x | IndexedDB wrapper for offline message queue | Async-native, promise-based; avoids localStorage limits for queued messages |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
lucide-react |
already installed | Back arrow, hamburger, More icons for mobile nav | Already in project |
@base-ui/react |
already installed | Bottom sheet / drawer for "More" menu | Already in project; Dialog + Sheet variants available |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
@serwist/next |
@ducanh2912/next-pwa |
Both are next-pwa successors; Serwist has official Next.js docs recommendation |
web-push |
Firebase Cloud Messaging / OneSignal | web-push has no vendor lock-in; FCM/OneSignal add dashboard overhead; web-push is simpler for a single-tenant push flow |
| Visual Viewport API (JS) | dvh CSS units |
dvh is simpler but Safari iOS support for keyboard-aware dvh is inconsistent; Visual Viewport API is more reliable |
Installation:
npm install @serwist/next serwist web-push idb
npm install -D @types/web-push
Architecture Patterns
Recommended Project Structure (additions only)
packages/portal/
├── app/
│ ├── manifest.ts # PWA manifest (Next.js built-in)
│ ├── sw.ts # Serwist service worker source
│ └── (dashboard)/
│ └── layout.tsx # Add mobile nav alongside existing sidebar
├── components/
│ ├── mobile-nav.tsx # Bottom tab bar — new
│ ├── mobile-more-sheet.tsx # "More" bottom sheet — new
│ ├── mobile-chat-header.tsx # Back arrow + agent name header — new
│ ├── offline-banner.tsx # "You're offline" indicator — new
│ └── install-prompt.tsx # Second-visit install banner — new
├── lib/
│ ├── push-subscriptions.ts # Server: store/retrieve push subscriptions
│ ├── message-queue.ts # Client: IndexedDB queue for offline messages
│ └── use-offline.ts # Client hook: online/offline state
├── public/
│ ├── sw.js # Serwist output (generated, do not edit)
│ ├── icon-192.png # K monogram on gradient, 192×192
│ ├── icon-512.png # K monogram on gradient, 512×512
│ ├── icon-maskable-192.png # Maskable variant (safe-zone K)
│ └── apple-touch-icon.png # iOS home screen icon, 180×180
└── next.config.ts # Add withSerwist wrapper
Pattern 1: Layout Split (Desktop sidebar vs Mobile bottom bar)
What: The DashboardLayout renders the <Nav /> (sidebar) on md: and above, and <MobileNav /> (bottom tab bar) below. The main content area gets pb-16 md:pb-0 to avoid being hidden under the tab bar.
When to use: Every page inside (dashboard) automatically inherits this via the shared layout.
// Source: Based on existing app/(dashboard)/layout.tsx pattern
// packages/portal/app/(dashboard)/layout.tsx
<div className="flex min-h-screen bg-background">
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden md:flex">
<Nav />
</div>
{/* Main content */}
<main className="flex-1 overflow-auto pb-16 md:pb-0">
<div className="max-w-6xl mx-auto px-4 md:px-8 py-4 md:py-8">
{children}
</div>
</main>
{/* Mobile bottom tab bar — hidden on desktop */}
<MobileNav />
</div>
Pattern 2: Bottom Tab Bar Component
What: Fixed position: fixed bottom-0 bar with 5 icon tabs. Active tab highlighted. Safe area inset for iOS home indicator.
When to use: Renders only on < md screens via md:hidden.
// packages/portal/components/mobile-nav.tsx
"use client";
// Key CSS: fixed bottom-0, pb safe-area-inset-bottom, z-50
// Active state: primary color icon, subtle indicator dot or background pill
// RBAC: filter items same way Nav does — read session role
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-background border-t"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
{/* 5 tabs: Dashboard, Employees, Chat, Usage, More */}
</nav>
Pattern 3: iOS Safe Area for Fixed Elements
What: The iOS home indicator (gesture bar) sits at the bottom of the screen. Fixed elements need padding-bottom: env(safe-area-inset-bottom) to avoid being obscured.
When to use: Bottom tab bar, fixed chat input, any position: fixed bottom-0 element.
Required in app/layout.tsx:
// Source: Next.js Viewport API
export const viewport: Viewport = {
viewportFit: 'cover', // enables safe-area-inset-* CSS env vars
}
/* In globals.css or inline style */
padding-bottom: env(safe-area-inset-bottom, 0px);
Pattern 4: Mobile Chat — Full-Screen Toggle
What: On mobile, the chat page is either showing the conversation list OR a full-screen conversation. A React state boolean mobileShowChat controls which panel renders. Back arrow sets it to false.
When to use: Only for < md screens. Desktop keeps two-column layout unchanged.
// In chat/page.tsx — add mobile-aware render logic
// md: renders both panels side-by-side (existing behavior)
// <md: renders list OR full-screen chat based on mobileShowChat state
// Conversation list panel
<div className={cn("md:w-72 md:shrink-0", mobileShowChat ? "hidden" : "flex flex-col")}>
<ChatSidebar ... onSelect={(id) => { handleSelectConversation(id); setMobileShowChat(true); }} />
</div>
// Chat panel — full-screen on mobile
<div className={cn("flex-1", !mobileShowChat && "hidden md:flex")}>
<MobileChatHeader agentName={...} onBack={() => setMobileShowChat(false)} />
<ChatWindow ... />
</div>
Pattern 5: iOS Virtual Keyboard — Fixed Input
What: On iOS Safari, position: fixed elements don't move when the virtual keyboard appears. The Visual Viewport API lets you react to the keyboard opening and adjust the input position.
When to use: The chat input (<div className="shrink-0 border-t"> in ChatWindow) must stay visible when the keyboard opens on mobile.
// Source: MDN Visual Viewport API
// packages/portal/lib/use-visual-viewport.ts
export function useVisualViewport() {
const [offset, setOffset] = useState(0);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const handler = () => {
// Distance between layout viewport bottom and visual viewport bottom
const diff = window.innerHeight - vv.height - vv.offsetTop;
setOffset(Math.max(0, diff));
};
vv.addEventListener('resize', handler);
vv.addEventListener('scroll', handler);
return () => {
vv.removeEventListener('resize', handler);
vv.removeEventListener('scroll', handler);
};
}, []);
return offset;
}
Apply as style={{ paddingBottom: ${keyboardOffset}px }} on the chat container or use transform: translateY(-${offset}px) on the fixed input.
Pattern 6: PWA Manifest (app/manifest.ts)
What: Next.js 16 built-in manifest file convention. Place at app/manifest.ts and it's automatically served at /manifest.webmanifest.
// Source: Next.js official docs (node_modules/next/dist/docs)
// packages/portal/app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Konstruct',
short_name: 'Konstruct',
description: 'AI Workforce Platform',
start_url: '/dashboard',
display: 'standalone',
background_color: '#0f0f1a', // matches deep sidebar color
theme_color: '#0f0f1a',
orientation: 'portrait',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
],
}
}
Pattern 7: Serwist Service Worker
What: Serwist wraps next.config.ts and generates public/sw.js from app/sw.ts. Precaches Next.js build output. Uses StaleWhileRevalidate for pages, CacheFirst for static assets.
Critical: Serwist does NOT support Turbopack. Use next dev --experimental-https --webpack for local PWA dev/testing.
// Source: Serwist official docs + LogRocket Next.js 16 PWA article
// packages/portal/next.config.ts
import withSerwistInit from '@serwist/next'
const withSerwist = withSerwistInit({
swSrc: 'app/sw.ts',
swDest: 'public/sw.js',
disable: process.env.NODE_ENV === 'development',
})
export default withNextIntl(withSerwist(nextConfig))
// Note: compose withNextIntl and withSerwist
// packages/portal/app/sw.ts
import { defaultCache } from '@serwist/next/worker'
import { installSerwist } from 'serwist'
installSerwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
// Add push event listener here (see push notification pattern)
})
Pattern 8: Web Push Notifications
What: Three-part system: (1) client subscribes via PushManager, (2) subscription stored in portal DB, (3) gateway/orchestrator calls portal API or Server Action to send push when agent responds.
// Source: Next.js official PWA docs + web-push npm library
// Generate VAPID keys once: npx web-push generate-vapid-keys
// Store in .env: NEXT_PUBLIC_VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY
// Client-side subscription (in a "use client" component)
const registration = await navigator.serviceWorker.register('/sw.js')
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
})
// POST sub to /api/push/subscribe
// Server action to send notification
// app/actions/push.ts — 'use server'
import webpush from 'web-push'
webpush.setVapidDetails(
'mailto:admin@konstruct.ai',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
await webpush.sendNotification(subscription, JSON.stringify({
title: 'Mara just replied',
body: message.slice(0, 100),
icon: '/icon-192.png',
data: { conversationId },
}))
// Service worker push handler (inside app/sw.ts installSerwist block or separate listener)
self.addEventListener('push', (event) => {
const data = event.data?.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: '/badge-72.png',
data: data.data,
})
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const conversationId = event.notification.data?.conversationId
event.waitUntil(
clients.openWindow(`/chat?id=${conversationId}`)
)
})
Push notification trigger point: The gateway's WebSocket handler already publishes to Redis when a response is ready. That same event (or a new Celery task) calls the portal's push API to send notifications to subscribed users for that conversation.
Pattern 9: Offline Message Queue
What: When the WebSocket is disconnected, messages typed by the user are stored in IndexedDB. On reconnection, drain the queue.
// Source: Web.dev IndexedDB + Background Sync patterns
// packages/portal/lib/message-queue.ts
import { openDB } from 'idb'
const DB_NAME = 'konstruct-offline'
const STORE = 'message-queue'
export async function enqueueMessage(conversationId: string, text: string) {
const db = await openDB(DB_NAME, 1, {
upgrade(db) { db.createObjectStore(STORE, { autoIncrement: true }) }
})
await db.add(STORE, { conversationId, text, queuedAt: Date.now() })
}
export async function drainQueue(send: (convId: string, text: string) => void) {
const db = await openDB(DB_NAME, 1)
const all = await db.getAll(STORE)
for (const msg of all) {
send(msg.conversationId, msg.text)
}
await db.clear(STORE)
}
Pattern 10: Install Prompt (Second Visit)
What: Capture beforeinstallprompt event. Store visit count in localStorage. On second+ visit, show a dismissable banner. iOS shows manual instructions (no programmatic prompt available).
What to know: beforeinstallprompt does NOT fire on Safari/iOS. Show iOS-specific manual instructions ("Tap Share, then Add to Home Screen"). Detect iOS with navigator.userAgent check.
// packages/portal/components/install-prompt.tsx
// Track with localStorage: 'konstruct_visit_count'
// Show banner when count >= 2 AND !isStandalone AND prompt is deferred
// Dismiss: set 'konstruct_install_dismissed' in localStorage
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
Anti-Patterns to Avoid
- Using
hover:for critical interactions: Tailwind v4 already wrapshover:in@media (hover: hover)so styles only apply on pointer devices. But custom CSS hover selectors or JS mouseover handlers that gate functionality will break on touch. Keep interactions tap-first. vhunits for mobile full-height: iOS Safari's100vhdoesn't account for the browser chrome. Use100dvhormin-h-screenwithdvhfallback.- Mixing
withNextIntlandwithSerwistincorrectly: Both wrapnext.config.ts. Compose them:withNextIntl(withSerwist(nextConfig)). - Storing push subscriptions in memory only: The Next.js docs example stores the subscription in a module-level variable — this is for demo only. Production requires DB storage (new
push_subscriptionstable). - Running Serwist with Turbopack (
next dev): Serwist requires webpack. Usenext dev --experimental-https --webpackfor PWA feature testing. - Using
position: bottomwithout safe-area insets: Bottom tab bar and fixed chat input will be obscured by iOS home indicator withoutenv(safe-area-inset-bottom).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Service worker with precaching | Custom SW cache manifest | @serwist/next |
Precache hash management, stale-while-revalidate, cache versioning are complex; Serwist handles all of it |
| Push notification delivery | Custom crypto/VAPID implementation | web-push npm library |
VAPID requires elliptic curve crypto; web-push is the reference implementation |
| IndexedDB access | Raw IDBOpenRequest / IDBTransaction code | idb library |
idb wraps IndexedDB in promises; raw API is callback-based and error-prone |
| Icon generation | Manual PNG creation | Use a browser/canvas tool or sharp | PWA requires multiple icon sizes (192, 512, maskable); batch-generate from one SVG source |
Key insight: The hardest part of mobile PWA is not the CSS — it's the service worker + push notification wiring. Both have subtle gotchas (HTTPS requirements, iOS limitations, VAPID expiry) that @serwist/next and web-push handle correctly.
Common Pitfalls
Pitfall 1: iOS Virtual Keyboard Pushes Fixed Input Offscreen
What goes wrong: The chat message input (position: fixed bottom-0) stays fixed to the layout viewport bottom. When the iOS keyboard opens, the layout viewport doesn't resize — so the input slides under the keyboard.
Why it happens: iOS Safari resizes the visual viewport but not the layout viewport when the keyboard opens. position: fixed is relative to the layout viewport.
How to avoid: Use the Visual Viewport API. Listen to window.visualViewport.resize events. Apply transform: translateY(-${delta}px) to the fixed input where delta = window.innerHeight - visualViewport.height.
Warning signs: Chat input disappears when user taps the textarea on a real iOS device.
Pitfall 2: Serwist Breaks with Turbopack (Default next dev)
What goes wrong: next dev uses Turbopack by default in Next.js 16. Serwist's webpack plugin doesn't run, so public/sw.js is either absent or stale. PWA features silently fail.
Why it happens: Serwist requires webpack's build pipeline to inject self.__SW_MANIFEST.
How to avoid: Use next dev --experimental-https --webpack for all PWA testing. Add this as a separate npm script: "dev:pwa": "next dev --experimental-https --webpack".
Warning signs: /sw.js returns 404 or the service worker registers but precache is empty.
Pitfall 3: Viewport Units on iOS Safari
What goes wrong: h-screen (which maps to 100vh) doesn't account for the browser address bar on iOS Safari. Content at the bottom of a full-height screen gets clipped.
Why it happens: iOS Safari's address bar shrinks on scroll, making the true viewport taller than the initial 100vh.
How to avoid: Use min-h-svh (small viewport height) for minimum guaranteed space, or min-h-dvh (dynamic viewport height) which updates as the browser chrome changes. Tailwind v4 supports svh, dvh, lvh units.
Warning signs: Bottom content clipped on iPhone in portrait mode; scrolling reveals hidden content.
Pitfall 4: beforeinstallprompt Never Fires on iOS
What goes wrong: The Chrome/Android install prompt logic (capturing beforeinstallprompt) is coded for all browsers. On iOS Safari, the event never fires. No install banner appears.
Why it happens: iOS Safari does not implement beforeinstallprompt. Users must manually tap Share → Add to Home Screen.
How to avoid: Branch on iOS detection. For iOS: show a static instructional banner. For Android/Chrome: defer the prompt and show a custom banner.
Warning signs: Install feature works on Android Chrome but nothing shows on iOS.
Pitfall 5: Push Subscriptions Expire and Become Invalid
What goes wrong: A push subscription stored in the DB becomes invalid when the user clears browser data, uninstalls the PWA, or the subscription TTL expires. webpush.sendNotification() throws a 410 Gone error.
Why it happens: Push subscriptions are tied to the browser's push service registration, not to the user account.
How to avoid: Handle 410/404 errors from webpush.sendNotification() by deleting the stale subscription from the DB. Re-subscribe on next PWA load.
Warning signs: Push notifications suddenly stop for some users; webpush.sendNotification throws.
Pitfall 6: HTTPS Required for Service Workers
What goes wrong: Service workers (and therefore push notifications and PWA install) don't register on HTTP origins. The local dev server (next dev) runs on HTTP by default.
Why it happens: Browser security policy — service workers can only be installed on secure origins (HTTPS or localhost).
How to avoid: Use next dev --experimental-https for local development. In Docker Compose, ensure the portal is accessed via localhost (which is a secure origin by exception) or via Traefik HTTPS.
Warning signs: navigator.serviceWorker.register() silently fails or throws in browser console.
Pitfall 7: Missing viewportFit: 'cover' for Safe Areas
What goes wrong: iOS devices with notch/home indicator don't apply env(safe-area-inset-*) unless the page explicitly opts in.
Why it happens: Default viewport behavior clips the page to the "safe area" rectangle. viewportFit: 'cover' expands the page to fill the full screen and enables safe area variables.
How to avoid: Export viewport: Viewport from app/layout.tsx with viewportFit: 'cover'. Then use env(safe-area-inset-bottom) in CSS for the bottom tab bar.
Code Examples
Responsive Layout Toggle (Nav hide/show)
// Source: Tailwind v4 docs — responsive prefixes
// Hide sidebar on mobile, show on md+
<div className="hidden md:flex">
<Nav />
</div>
// Show mobile tab bar on mobile only
<MobileNav className="md:hidden" />
// Main content: add bottom padding on mobile to clear tab bar
<main className="flex-1 overflow-auto pb-16 md:pb-0">
Mobile Chat State Machine
// Source: WhatsApp-style navigation pattern
// In ChatPageInner — add to existing state
const [mobileShowChat, setMobileShowChat] = useState(false)
const isMobile = useMediaQuery('(max-width: 767px)') // or CSS-only approach
// When user selects a conversation on mobile:
const handleSelectConversation = useCallback((id: string) => {
setActiveConversationId(id)
if (isMobile) setMobileShowChat(true)
// ... existing router logic
}, [isMobile, router, searchParams])
dvh for Full-Height Containers
// Source: MDN CSS dvh unit
// Replace h-[calc(100vh-4rem)] with dvh-aware equivalent
// Tailwind v4 supports dvh natively
<div className="flex h-[calc(100dvh-4rem)] overflow-hidden">
Service Worker Registration in layout.tsx
// Source: Next.js PWA official guide
// In a client component (e.g., components/sw-register.tsx)
"use client"
import { useEffect } from 'react'
export function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
void navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
}
}, [])
return null
}
// Mount in app/layout.tsx body
Offline State Hook
// packages/portal/lib/use-offline.ts
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true
)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
next-pwa (shadowwalker) |
@serwist/next (Serwist fork of Workbox) |
2023–2024 | next-pwa unmaintained; Serwist actively maintained and in Next.js official docs |
100vh for mobile full-height |
100dvh (dynamic viewport height) |
2023 (broad browser support) | Fixes iOS Safari browser chrome clipping |
| Manual service worker code | @serwist/next with installSerwist() |
2024 | Eliminates manual cache manifest management |
| Firebase Cloud Messaging | Web Push API + web-push npm |
Ongoing | FCM free tier restrictions tightened; native Web Push works without Firebase |
@media (hover: hover) manual wrapping |
Tailwind v4 automatic | Tailwind v4.0 | Hover styles automatically only apply on hover-capable devices |
Deprecated/outdated:
next-pwa(shadowwalker): No updates since 2022; does not support Next.js 15/16 App Routernavigator.standalone(non-standard): Usewindow.matchMedia('(display-mode: standalone)')instead- localStorage for IndexedDB queue: Too small (5MB limit), synchronous, no structured data
Open Questions
-
Push notification trigger architecture
- What we know: Gateway/orchestrator publishes to Redis when agent responds; portal WebSocket subscribes and streams to client
- What's unclear: Should the orchestrator call the portal's push API directly (HTTP), or should a Celery task handle it, or should the portal subscribe to the same Redis channel and push from there?
- Recommendation: Simplest path — portal's WebSocket handler already receives the agent response event via Redis pub-sub. Add a push notification send to that same handler: if the client is not connected (no active WebSocket for that conversation), send push. The portal has access to
web-pushand the subscription DB.
-
Push subscription storage location
- What we know: Subscriptions are per-user/browser, contain endpoint + keys
- What's unclear: New DB table in portal DB or reuse gateway's PostgreSQL?
- Recommendation: New
push_subscriptionstable in the portal's PostgreSQL (accessed via gateway API). Fields:id,user_id,tenant_id,subscription_json,created_at,updated_at.
-
"K" icon asset production
- What we know: Must be PNG at 192×192, 512×512, maskable variant, and Apple touch icon (180×180). The gradient-mesh CSS animation from
globals.csscannot be used in a static PNG. - What's unclear: Who produces the icon files? Canvas rendering? SVG-to-PNG?
- Recommendation: Create an SVG of the K monogram on the gradient, export to required PNG sizes using
sharpor an online tool (realfavicongenerator.net). Maskable icon needs 10% padding on all sides (safe zone). This is a Wave 0 asset creation task.
- What we know: Must be PNG at 192×192, 512×512, maskable variant, and Apple touch icon (180×180). The gradient-mesh CSS animation from
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | ESLint + TypeScript strict (existing) |
| Config file | eslint.config.mjs, tsconfig.json |
| Quick run command | npm run lint (in packages/portal) |
| Full suite command | npm run build (validates TS + Next.js compile) |
No automated test suite exists for the portal (no Jest/Vitest/Playwright configured). Testing for this phase is primarily manual device testing and browser DevTools PWA audit.
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| MOB-01 | Pages render correctly at 320px–1024px | manual | Chrome DevTools device emulation | n/a |
| MOB-02 | Bottom tab bar visible on mobile, sidebar on desktop | manual | DevTools responsive toggle | n/a |
| MOB-03 | Chat send/receive works on mobile viewport | manual | Real device or DevTools | n/a |
| MOB-04 | PWA manifest valid, installable | manual | Lighthouse PWA audit in DevTools | n/a |
| MOB-05 | Push notification received on installed PWA | manual | next dev --experimental-https --webpack + real device |
n/a |
| MOB-06 | No hover-dependent broken interactions | manual | Touch device tap testing | n/a |
| TypeScript | No type errors in new components | automated | npm run build |
✅ existing |
| Lint | No lint violations | automated | npm run lint |
✅ existing |
Sampling Rate
- Per task commit:
npm run lintinpackages/portal - Per wave merge:
npm run buildinpackages/portal - Phase gate: Lighthouse PWA audit score ≥ 90 + manual device test sign-off before
/gsd:verify-work
Wave 0 Gaps
public/icon-192.png— K monogram icon for PWA manifest (192×192)public/icon-512.png— K monogram icon for PWA manifest (512×512)public/icon-maskable-192.png— Maskable variant with safe-zone paddingpublic/apple-touch-icon.png— iOS home screen icon (180×180)public/badge-72.png— Notification badge (72×72, monochrome)- VAPID keys generated and added to
.env:NEXT_PUBLIC_VAPID_PUBLIC_KEY+VAPID_PRIVATE_KEY npm install @serwist/next serwist web-push idb+npm install -D @types/web-push
Sources
Primary (HIGH confidence)
- Next.js 16.2.1 built-in docs at
node_modules/next/dist/docs/01-app/02-guides/progressive-web-apps.md— PWA manifest, push notifications, service worker, install prompt patterns - Next.js 16.2.1 built-in docs at
node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/01-metadata/manifest.md—app/manifest.tsfile convention - Existing portal codebase (
nav.tsx,chat-window.tsx,chat/page.tsx,layout.tsx) — integration points confirmed by direct read
Secondary (MEDIUM confidence)
- Next.js official PWA guide — confirmed matches built-in docs; version-stamped 2026-03-20
- Serwist + Next.js 16 (LogRocket) — Serwist setup steps, webpack flag requirement
- Aurora Scharff: Serwist + Next.js 16 icons — Turbopack incompatibility confirmed, icon generation patterns
- Tailwind v4 hover behavior (bordermedia.org) — hover scoped to
@media (hover: hover)in v4 - MDN VirtualKeyboard API — WebKit does not support VirtualKeyboard API; Visual Viewport API required for iOS
Tertiary (LOW confidence)
- web-push + Next.js Server Actions (Medium, Jan 2026) — push subscription pattern; not officially verified but cross-confirmed with Next.js official docs pattern
- Building Offline Apps with Serwist (Medium) — IndexedDB + message queue pattern
Metadata
Confidence breakdown:
- Standard stack: HIGH — Serwist and web-push confirmed by Next.js official docs; idb is the standard IndexedDB wrapper
- Architecture (responsive layout): HIGH — Tailwind v4 breakpoints and existing codebase patterns fully understood
- Architecture (PWA/service worker): HIGH — confirmed against bundled Next.js 16.2.1 docs
- Architecture (push notifications): MEDIUM — official docs cover the pattern; push notification delivery architecture to orchestrator is project-specific and requires design decision
- Pitfalls: HIGH — iOS keyboard, safe area, dvh, Turbopack incompatibility all verified against multiple authoritative sources
Research date: 2026-03-25 Valid until: 2026-06-25 (stable APIs; Serwist releases should be monitored)