# 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:**
```bash
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.

```tsx
// 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`.

```tsx
// 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`:**
```tsx
// Source: Next.js Viewport API
export const viewport: Viewport = {
  viewportFit: 'cover', // enables safe-area-inset-* CSS env vars
}
```

```css
/* 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.

```tsx
// 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.

```typescript
// 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`.

```typescript
// 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.

```typescript
// 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
```

```typescript
// 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.

```typescript
// 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 },
}))
```

```javascript
// 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.

```typescript
// 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.

```typescript
// 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 wraps `hover:` 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.
- **`vh` units for mobile full-height:** iOS Safari's `100vh` doesn't account for the browser chrome. Use `100dvh` or `min-h-screen` with `dvh` fallback.
- **Mixing `withNextIntl` and `withSerwist` incorrectly:** Both wrap `next.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_subscriptions` table).
- **Running Serwist with Turbopack (`next dev`):** Serwist requires webpack. Use `next dev --experimental-https --webpack` for PWA feature testing.
- **Using `position: bottom` without safe-area insets:** Bottom tab bar and fixed chat input will be obscured by iOS home indicator without `env(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)
```tsx
// 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
```tsx
// 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
```tsx
// 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
```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
```typescript
// 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 Router
- `navigator.standalone` (non-standard): Use `window.matchMedia('(display-mode: standalone)')` instead
- localStorage for IndexedDB queue: Too small (5MB limit), synchronous, no structured data

---

## Open Questions

1. **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-push` and the subscription DB.

2. **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_subscriptions` table in the portal's PostgreSQL (accessed via gateway API). Fields: `id`, `user_id`, `tenant_id`, `subscription_json`, `created_at`, `updated_at`.

3. **"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.css` cannot 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 `sharp` or an online tool (realfavicongenerator.net). Maskable icon needs 10% padding on all sides (safe zone). This is a Wave 0 asset creation task.

---

## 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 lint` in `packages/portal`
- **Per wave merge:** `npm run build` in `packages/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 padding
- [ ] `public/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.ts` file 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](https://nextjs.org/docs/app/guides/progressive-web-apps) — confirmed matches built-in docs; version-stamped 2026-03-20
- [Serwist + Next.js 16 (LogRocket)](https://blog.logrocket.com/nextjs-16-pwa-offline-support/) — Serwist setup steps, webpack flag requirement
- [Aurora Scharff: Serwist + Next.js 16 icons](https://aurorascharff.no/posts/dynamically-generating-pwa-app-icons-nextjs-16-serwist/) — Turbopack incompatibility confirmed, icon generation patterns
- [Tailwind v4 hover behavior (bordermedia.org)](https://bordermedia.org/blog/tailwind-css-4-hover-on-touch-device) — hover scoped to `@media (hover: hover)` in v4
- [MDN VirtualKeyboard API](https://developer.mozilla.org/en-US/docs/Web/API/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)](https://medium.com/@amirjld/implementing-push-notifications-in-next-js-using-web-push-and-server-actions-f4b95d68091f) — push subscription pattern; not officially verified but cross-confirmed with Next.js official docs pattern
- [Building Offline Apps with Serwist (Medium)](https://sukechris.medium.com/building-offline-apps-with-next-js-and-serwist-a395ed4ae6ba) — 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)
