Compare commits
4 Commits
238e7dd888
...
5b6cd348fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b6cd348fa | |||
| d9b022bd4c | |||
| 467a994d9f | |||
| fafbcf742b |
@@ -142,7 +142,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8
|
||||
| 5. Employee Design | 4/4 | Complete | 2026-03-25 |
|
||||
| 6. Web Chat | 3/3 | Complete | 2026-03-25 |
|
||||
| 7. Multilanguage | 4/4 | Complete | 2026-03-25 |
|
||||
| 8. Mobile + PWA | 0/0 | Not started | - |
|
||||
| 8. Mobile + PWA | 0/4 | In progress | - |
|
||||
|
||||
---
|
||||
|
||||
@@ -174,17 +174,20 @@ Plans:
|
||||
**Depends on**: Phase 7
|
||||
**Requirements**: MOB-01, MOB-02, MOB-03, MOB-04, MOB-05, MOB-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. All portal pages render correctly and are usable on mobile screens (320px–480px) and tablets (768px–1024px)
|
||||
2. The sidebar collapses to a hamburger menu on mobile with smooth open/close animation
|
||||
1. All portal pages render correctly and are usable on mobile screens (320px-480px) and tablets (768px-1024px)
|
||||
2. The sidebar collapses to a bottom tab bar on mobile with smooth open/close animation
|
||||
3. The chat interface is fully functional on mobile — send messages, see streaming responses, scroll history
|
||||
4. The portal can be installed as a PWA from Chrome/Safari with app icon, splash screen, and offline shell
|
||||
5. Push notifications work for new messages when the PWA is installed (or at minimum, the service worker caches the app shell for instant load)
|
||||
6. All touch interactions (swipe, tap, long-press) feel native — no hover-dependent UI that breaks on touch
|
||||
**Plans**: 0 plans
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /gsd:plan-phase 8 to break down)
|
||||
- [ ] 08-01-PLAN.md — PWA infrastructure (manifest, service worker, icons, offline banner) + responsive layout (bottom tab bar, More sheet, layout split)
|
||||
- [ ] 08-02-PLAN.md — Mobile chat (full-screen WhatsApp-style flow, Visual Viewport keyboard handling, touch-safe interactions)
|
||||
- [ ] 08-03-PLAN.md — Push notifications (VAPID, push subscription DB, service worker push handler, offline message queue, install prompt)
|
||||
- [ ] 08-04-PLAN.md — Human verification: mobile responsive layout, PWA install, push notifications, touch interactions
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-23*
|
||||
*Coverage: 25/25 v1 requirements + 6 RBAC requirements + 5 Employee Design requirements + 5 Web Chat requirements + 6 Multilanguage requirements mapped*
|
||||
*Coverage: 25/25 v1 requirements + 6 RBAC requirements + 5 Employee Design requirements + 5 Web Chat requirements + 6 Multilanguage requirements + 6 Mobile+PWA requirements mapped*
|
||||
|
||||
351
.planning/phases/08-mobile-pwa/08-01-PLAN.md
Normal file
351
.planning/phases/08-mobile-pwa/08-01-PLAN.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/portal/package.json
|
||||
- packages/portal/next.config.ts
|
||||
- packages/portal/app/manifest.ts
|
||||
- packages/portal/app/sw.ts
|
||||
- packages/portal/app/layout.tsx
|
||||
- packages/portal/app/(dashboard)/layout.tsx
|
||||
- packages/portal/components/mobile-nav.tsx
|
||||
- packages/portal/components/mobile-more-sheet.tsx
|
||||
- packages/portal/components/sw-register.tsx
|
||||
- packages/portal/components/offline-banner.tsx
|
||||
- packages/portal/lib/use-offline.ts
|
||||
- packages/portal/public/icon-192.png
|
||||
- packages/portal/public/icon-512.png
|
||||
- packages/portal/public/icon-maskable-192.png
|
||||
- packages/portal/public/apple-touch-icon.png
|
||||
- packages/portal/public/badge-72.png
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-01
|
||||
- MOB-02
|
||||
- MOB-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Desktop sidebar is hidden on screens < 768px; bottom tab bar appears instead"
|
||||
- "Bottom tab bar has 5 items: Dashboard, Employees, Chat, Usage, More"
|
||||
- "More sheet opens with Billing, API Keys, Users, Platform, Settings, Sign Out (RBAC-filtered)"
|
||||
- "Main content has bottom padding on mobile to clear the tab bar"
|
||||
- "Portal is installable as a PWA with manifest, icons, and service worker"
|
||||
- "Offline banner appears when network is lost"
|
||||
- "All existing pages remain functional on desktop (no regression)"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/mobile-nav.tsx"
|
||||
provides: "Bottom tab bar navigation for mobile"
|
||||
exports: ["MobileNav"]
|
||||
- path: "packages/portal/components/mobile-more-sheet.tsx"
|
||||
provides: "Bottom sheet for secondary nav items"
|
||||
exports: ["MobileMoreSheet"]
|
||||
- path: "packages/portal/app/manifest.ts"
|
||||
provides: "PWA manifest with K monogram icons"
|
||||
exports: ["default"]
|
||||
- path: "packages/portal/app/sw.ts"
|
||||
provides: "Service worker with Serwist precaching"
|
||||
- path: "packages/portal/components/sw-register.tsx"
|
||||
provides: "Service worker registration client component"
|
||||
exports: ["ServiceWorkerRegistration"]
|
||||
- path: "packages/portal/components/offline-banner.tsx"
|
||||
provides: "Offline status indicator"
|
||||
exports: ["OfflineBanner"]
|
||||
key_links:
|
||||
- from: "packages/portal/app/(dashboard)/layout.tsx"
|
||||
to: "packages/portal/components/mobile-nav.tsx"
|
||||
via: "conditional render with hidden md:flex / md:hidden"
|
||||
pattern: "MobileNav.*md:hidden"
|
||||
- from: "packages/portal/next.config.ts"
|
||||
to: "packages/portal/app/sw.ts"
|
||||
via: "withSerwist wrapper generates public/sw.js from app/sw.ts"
|
||||
pattern: "withSerwist"
|
||||
- from: "packages/portal/app/layout.tsx"
|
||||
to: "packages/portal/components/sw-register.tsx"
|
||||
via: "mounted in body for service worker registration"
|
||||
pattern: "ServiceWorkerRegistration"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Responsive mobile layout foundation and PWA infrastructure. Desktop sidebar becomes a bottom tab bar on mobile, PWA manifest and service worker enable installability, and offline detection provides status feedback.
|
||||
|
||||
Purpose: This is the structural foundation that all other mobile plans build on. The layout split (sidebar vs tab bar) affects every page, and PWA infrastructure (manifest + service worker) is required before push notifications or offline caching can work.
|
||||
|
||||
Output: Mobile-responsive dashboard layout, bottom tab bar with More sheet, PWA manifest with K monogram icons, Serwist service worker, offline banner component.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From packages/portal/components/nav.tsx:
|
||||
```typescript
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
allowedRoles?: string[];
|
||||
};
|
||||
|
||||
// Nav items array (lines 43-53):
|
||||
// Dashboard, Tenants (platform_admin), Employees, Chat, Usage,
|
||||
// Billing (admin+), API Keys (admin+), Users (admin+), Platform (platform_admin)
|
||||
// Role filtering: visibleItems = navItems.filter(item => !item.allowedRoles || item.allowedRoles.includes(role))
|
||||
```
|
||||
|
||||
From packages/portal/app/(dashboard)/layout.tsx:
|
||||
```typescript
|
||||
// Current layout: flex min-h-screen, <Nav /> sidebar + <main> content
|
||||
// Wraps with SessionProvider, QueryClientProvider, SessionSync, ImpersonationBanner
|
||||
```
|
||||
|
||||
From packages/portal/app/layout.tsx:
|
||||
```typescript
|
||||
// Root layout: Server Component with next-intl provider
|
||||
// Exports metadata: Metadata
|
||||
// No viewport export yet (needs viewportFit: 'cover' for safe areas)
|
||||
```
|
||||
|
||||
From packages/portal/next.config.ts:
|
||||
```typescript
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
const nextConfig: NextConfig = { output: "standalone" };
|
||||
export default withNextIntl(nextConfig);
|
||||
// Must compose: withNextIntl(withSerwist(nextConfig))
|
||||
```
|
||||
|
||||
From packages/portal/proxy.ts:
|
||||
```typescript
|
||||
const CUSTOMER_OPERATOR_RESTRICTED = ["/billing", "/settings/api-keys", "/users", "/admin", "/agents/new"];
|
||||
const PLATFORM_ADMIN_ONLY = ["/admin"];
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install PWA dependencies, create icons, manifest, service worker, and offline utilities</name>
|
||||
<files>
|
||||
packages/portal/package.json,
|
||||
packages/portal/next.config.ts,
|
||||
packages/portal/app/manifest.ts,
|
||||
packages/portal/app/sw.ts,
|
||||
packages/portal/app/layout.tsx,
|
||||
packages/portal/components/sw-register.tsx,
|
||||
packages/portal/components/offline-banner.tsx,
|
||||
packages/portal/lib/use-offline.ts,
|
||||
packages/portal/public/icon-192.png,
|
||||
packages/portal/public/icon-512.png,
|
||||
packages/portal/public/icon-maskable-192.png,
|
||||
packages/portal/public/apple-touch-icon.png,
|
||||
packages/portal/public/badge-72.png
|
||||
</files>
|
||||
<action>
|
||||
1. Install PWA dependencies in packages/portal:
|
||||
```bash
|
||||
npm install @serwist/next serwist web-push idb
|
||||
npm install -D @types/web-push
|
||||
```
|
||||
|
||||
2. Generate PWA icon assets. Create a Node.js script using `sharp` (install as devDep if needed) or canvas to generate the K monogram icons. The "K" should be bold white text on a dark gradient background (#0f0f1a to #1a1a2e with a subtle purple/blue accent). Generate:
|
||||
- public/icon-192.png (192x192) — K monogram on gradient
|
||||
- public/icon-512.png (512x512) — K monogram on gradient
|
||||
- public/icon-maskable-192.png (192x192 with 10% safe-zone padding)
|
||||
- public/apple-touch-icon.png (180x180)
|
||||
- public/badge-72.png (72x72, monochrome white K on transparent)
|
||||
If sharp is complex, create simple SVGs and convert, or use canvas. The icons must exist as real PNG files.
|
||||
|
||||
3. Create `app/manifest.ts` using Next.js built-in manifest file convention:
|
||||
```typescript
|
||||
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',
|
||||
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' },
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Create `app/sw.ts` — Serwist service worker source:
|
||||
```typescript
|
||||
import { defaultCache } from '@serwist/next/worker'
|
||||
import { installSerwist } from 'serwist'
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
installSerwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: defaultCache,
|
||||
})
|
||||
```
|
||||
NOTE: Push event listeners will be added in Plan 03. Keep this minimal for now.
|
||||
|
||||
5. Update `next.config.ts` — wrap with Serwist:
|
||||
```typescript
|
||||
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))
|
||||
```
|
||||
Compose order: withNextIntl wraps withSerwist wraps nextConfig.
|
||||
|
||||
6. Create `components/sw-register.tsx` — client component that registers the service worker:
|
||||
```typescript
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
7. Update `app/layout.tsx`:
|
||||
- Add `Viewport` export with `viewportFit: 'cover'` to enable safe-area-inset CSS env vars on iOS
|
||||
- Mount `<ServiceWorkerRegistration />` in the body (outside NextIntlClientProvider is fine since it uses no translations)
|
||||
- Add `<OfflineBanner />` in body (inside NextIntlClientProvider since it needs translations)
|
||||
|
||||
8. Create `lib/use-offline.ts` — hook for online/offline state:
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
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) }
|
||||
}, [])
|
||||
return isOnline
|
||||
}
|
||||
```
|
||||
|
||||
9. Create `components/offline-banner.tsx` — fixed banner at top when offline:
|
||||
```typescript
|
||||
"use client"
|
||||
// Shows "You're offline" with a subtle amber/red bar at the top of the viewport
|
||||
// Uses useOnlineStatus hook. Renders null when online.
|
||||
// Position: fixed top-0 z-[60] full width, above everything
|
||||
// Add i18n key: common.offlineBanner
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run lint</automated>
|
||||
</verify>
|
||||
<done>
|
||||
PWA dependencies installed. Icon PNGs exist in public/. manifest.ts serves at /manifest.webmanifest. sw.ts compiles. Service worker registration component mounted in root layout. Viewport export includes viewportFit cover. Offline banner shows when navigator.onLine is false. Lint passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Mobile bottom tab bar, More sheet, and responsive dashboard layout</name>
|
||||
<files>
|
||||
packages/portal/app/(dashboard)/layout.tsx,
|
||||
packages/portal/components/mobile-nav.tsx,
|
||||
packages/portal/components/mobile-more-sheet.tsx,
|
||||
packages/portal/messages/en.json,
|
||||
packages/portal/messages/es.json,
|
||||
packages/portal/messages/pt.json
|
||||
</files>
|
||||
<action>
|
||||
1. Create `components/mobile-nav.tsx` — bottom tab bar:
|
||||
- "use client" component
|
||||
- Position: `fixed bottom-0 left-0 right-0 z-50 bg-background border-t`
|
||||
- Add `paddingBottom: env(safe-area-inset-bottom)` via inline style for iOS home indicator
|
||||
- CSS class: `md:hidden` — only visible below 768px
|
||||
- 5 tab items: Dashboard (LayoutDashboard icon, href="/dashboard"), Employees (Users icon, href="/agents"), Chat (MessageSquare icon, href="/chat"), Usage (BarChart2 icon, href="/usage"), More (MoreHorizontal or Ellipsis icon, opens sheet)
|
||||
- Active tab: highlighted with primary color icon + subtle indicator dot/pill below icon
|
||||
- Use `usePathname()` to determine active state
|
||||
- Icons should be the primary visual element with very small text labels below (per user: "solid icons, subtle active indicator, no text labels (or very small ones)")
|
||||
- RBAC: Read session role. The 4 navigation items (Dashboard, Employees, Chat, Usage) are visible to all roles. "More" is always visible. RBAC filtering happens inside the More sheet.
|
||||
- Tab bar height: ~60px plus safe area. Content padding `pb-16 md:pb-0` on main.
|
||||
|
||||
2. Create `components/mobile-more-sheet.tsx` — bottom sheet for secondary items:
|
||||
- "use client" component
|
||||
- Use @base-ui/react Dialog as a bottom sheet (position: fixed bottom, slides up with animation)
|
||||
- Contains nav links: Billing, API Keys, Users, Platform, Settings, Sign Out
|
||||
- Apply RBAC: filter items by role (same logic as Nav — Billing/API Keys/Users visible to platform_admin and customer_admin only; Platform visible to platform_admin only)
|
||||
- Include LanguageSwitcher component at the bottom of the sheet
|
||||
- Include Sign Out button at the bottom
|
||||
- Props: `open: boolean, onOpenChange: (open: boolean) => void`
|
||||
- Style: rounded-t-2xl, bg-background, max-h-[70vh], draggable handle at top
|
||||
- Each item: icon + label, full-width tap target, closes sheet on navigation
|
||||
|
||||
3. Update `app/(dashboard)/layout.tsx`:
|
||||
- Wrap existing `<Nav />` in `<div className="hidden md:flex">` — sidebar hidden on mobile
|
||||
- Add `<MobileNav />` after the main content area (it renders with md:hidden internally)
|
||||
- Add `pb-16 md:pb-0` to the `<main>` element to clear the tab bar on mobile
|
||||
- Reduce padding on mobile: change `px-8 py-8` to `px-4 md:px-8 py-4 md:py-8`
|
||||
- Keep all existing providers (SessionProvider, QueryClientProvider, etc.) unchanged
|
||||
|
||||
4. Add i18n keys for mobile nav in all three locale files (en.json, es.json, pt.json):
|
||||
- mobileNav.dashboard, mobileNav.employees, mobileNav.chat, mobileNav.usage, mobileNav.more
|
||||
- mobileNav.billing, mobileNav.apiKeys, mobileNav.users, mobileNav.platform, mobileNav.settings, mobileNav.signOut
|
||||
- common.offlineBanner: "You're offline — changes will sync when you reconnect"
|
||||
NOTE: Nav already has these keys under "nav.*" — reuse the same translation keys from the nav namespace where possible to avoid duplication. Only add new keys if the mobile label differs.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Bottom tab bar renders on screens below 768px with 5 items. Desktop sidebar is hidden on mobile. More sheet opens from the More tab with RBAC-filtered secondary items including language switcher and sign out. Main content has bottom padding on mobile. All pages render without layout breakage on both mobile and desktop. Build passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal (TypeScript + Next.js compilation)
|
||||
- `npm run lint` passes in packages/portal
|
||||
- PWA manifest accessible (app/manifest.ts exports valid MetadataRoute.Manifest)
|
||||
- Icon files exist: public/icon-192.png, public/icon-512.png, public/icon-maskable-192.png, public/apple-touch-icon.png, public/badge-72.png
|
||||
- Service worker source compiles (app/sw.ts)
|
||||
- Desktop layout unchanged — sidebar visible at md+ breakpoint
|
||||
- Mobile layout shows bottom tab bar, sidebar hidden
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
All portal pages render with the bottom tab bar on mobile (< 768px) and the sidebar on desktop (>= 768px). PWA manifest and service worker infrastructure are in place. Offline banner appears when disconnected.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-01-SUMMARY.md`
|
||||
</output>
|
||||
239
.planning/phases/08-mobile-pwa/08-02-PLAN.md
Normal file
239
.planning/phases/08-mobile-pwa/08-02-PLAN.md
Normal file
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/portal/app/(dashboard)/chat/page.tsx
|
||||
- packages/portal/components/chat-window.tsx
|
||||
- packages/portal/components/chat-sidebar.tsx
|
||||
- packages/portal/components/mobile-chat-header.tsx
|
||||
- packages/portal/lib/use-visual-viewport.ts
|
||||
- packages/portal/lib/use-chat-socket.ts
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-03
|
||||
- MOB-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On mobile, tapping a conversation shows full-screen chat with back arrow header"
|
||||
- "Back arrow returns to conversation list on mobile"
|
||||
- "Desktop two-column chat layout is unchanged"
|
||||
- "Chat input stays visible when iOS virtual keyboard opens"
|
||||
- "Message input is fixed at bottom, does not scroll away"
|
||||
- "Streaming responses (word-by-word tokens) work on mobile"
|
||||
- "No hover-dependent interactions break on touch devices"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/mobile-chat-header.tsx"
|
||||
provides: "Back arrow + agent name header for mobile full-screen chat"
|
||||
exports: ["MobileChatHeader"]
|
||||
- path: "packages/portal/lib/use-visual-viewport.ts"
|
||||
provides: "Visual Viewport API hook for iOS keyboard offset"
|
||||
exports: ["useVisualViewport"]
|
||||
key_links:
|
||||
- from: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
to: "packages/portal/components/mobile-chat-header.tsx"
|
||||
via: "rendered when mobileShowChat is true on < md screens"
|
||||
pattern: "MobileChatHeader"
|
||||
- from: "packages/portal/components/chat-window.tsx"
|
||||
to: "packages/portal/lib/use-visual-viewport.ts"
|
||||
via: "keyboard offset applied to input container"
|
||||
pattern: "useVisualViewport"
|
||||
- from: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
to: "mobileShowChat state"
|
||||
via: "toggles between conversation list and full-screen chat on mobile"
|
||||
pattern: "mobileShowChat"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Mobile-optimized chat experience with WhatsApp-style full-screen conversation flow, iOS keyboard handling, and touch-safe interactions.
|
||||
|
||||
Purpose: Chat is the primary user interaction on mobile. The two-column desktop layout doesn't work on small screens. This plan implements the conversation list -> full-screen chat pattern (like WhatsApp/iMessage) and handles the iOS virtual keyboard problem that breaks fixed inputs.
|
||||
|
||||
Output: Full-screen mobile chat with back navigation, Visual Viewport keyboard handling, touch-safe interaction patterns.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From packages/portal/app/(dashboard)/chat/page.tsx:
|
||||
```typescript
|
||||
// ChatPageInner renders:
|
||||
// - <div className="w-72 shrink-0"> with <ChatSidebar ... />
|
||||
// - <div className="flex-1"> with <ChatWindow ... />
|
||||
// State: activeConversationId, showAgentPicker
|
||||
// handleSelectConversation sets activeConversationId + updates URL
|
||||
// Container: <div className="flex h-[calc(100vh-4rem)] overflow-hidden">
|
||||
```
|
||||
|
||||
From packages/portal/components/chat-window.tsx:
|
||||
```typescript
|
||||
export interface ChatWindowProps {
|
||||
conversationId: string | null;
|
||||
authHeaders: ChatSocketAuthHeaders;
|
||||
}
|
||||
// ActiveConversation renders:
|
||||
// - Connection status banner
|
||||
// - Message list: <div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
// - Input area: <div className="shrink-0 border-t px-4 py-3">
|
||||
// Container: <div className="flex flex-col h-full">
|
||||
```
|
||||
|
||||
From packages/portal/components/chat-sidebar.tsx:
|
||||
```typescript
|
||||
export interface ChatSidebarProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
From packages/portal/lib/use-chat-socket.ts:
|
||||
```typescript
|
||||
export type ChatSocketAuthHeaders = {
|
||||
userId: string;
|
||||
role: string;
|
||||
tenantId: string | null;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Mobile full-screen chat toggle and Visual Viewport keyboard hook</name>
|
||||
<files>
|
||||
packages/portal/app/(dashboard)/chat/page.tsx,
|
||||
packages/portal/components/chat-window.tsx,
|
||||
packages/portal/components/chat-sidebar.tsx,
|
||||
packages/portal/components/mobile-chat-header.tsx,
|
||||
packages/portal/lib/use-visual-viewport.ts
|
||||
</files>
|
||||
<action>
|
||||
1. Create `lib/use-visual-viewport.ts` — hook to handle iOS keyboard offset:
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
export function useVisualViewport() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
const handler = () => {
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
2. Create `components/mobile-chat-header.tsx`:
|
||||
- "use client" component
|
||||
- Props: `agentName: string, onBack: () => void`
|
||||
- Renders: `md:hidden` — only visible on mobile
|
||||
- Layout: flex row with ArrowLeft icon button (onBack), agent avatar circle (first letter of agentName), agent name text
|
||||
- Style: sticky top-0 z-10 bg-background border-b, h-14, items centered
|
||||
- Back arrow: large tap target (min 44x44px), uses lucide ArrowLeft
|
||||
|
||||
3. Update `app/(dashboard)/chat/page.tsx` — add mobile full-screen toggle:
|
||||
- Add state: `const [mobileShowChat, setMobileShowChat] = useState(false)`
|
||||
- Modify `handleSelectConversation` to also call `setMobileShowChat(true)` (always, not just on mobile — CSS handles visibility)
|
||||
- Update container: change `h-[calc(100vh-4rem)]` to `h-[calc(100dvh-4rem)] md:h-[calc(100vh-4rem)]` (dvh for mobile to handle iOS browser chrome)
|
||||
- Chat sidebar panel: wrap with conditional classes:
|
||||
```tsx
|
||||
<div className={cn(
|
||||
"md:w-72 md:shrink-0 md:block",
|
||||
mobileShowChat ? "hidden" : "flex flex-col w-full"
|
||||
)}>
|
||||
```
|
||||
- Chat window panel: wrap with conditional classes:
|
||||
```tsx
|
||||
<div className={cn(
|
||||
"flex-1 md:block",
|
||||
!mobileShowChat ? "hidden" : "flex flex-col w-full"
|
||||
)}>
|
||||
{mobileShowChat && (
|
||||
<MobileChatHeader
|
||||
agentName={activeConversationAgentName}
|
||||
onBack={() => setMobileShowChat(false)}
|
||||
/>
|
||||
)}
|
||||
<ChatWindow ... />
|
||||
</div>
|
||||
```
|
||||
- Extract agent name from conversations array for the active conversation: `const activeConversationAgentName = conversations.find(c => c.id === activeConversationId)?.agent_name ?? 'AI Employee'`
|
||||
- When URL has `?id=xxx` on mount and on mobile, set mobileShowChat to true:
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (urlConversationId) setMobileShowChat(true)
|
||||
}, [urlConversationId])
|
||||
```
|
||||
- On mobile, the "New Chat" agent picker should also set mobileShowChat true after conversation creation (already handled by handleSelectConversation calling setMobileShowChat(true))
|
||||
|
||||
4. Update `components/chat-window.tsx` — keyboard-safe input on mobile:
|
||||
- Import and use `useVisualViewport` in ActiveConversation
|
||||
- Apply keyboard offset to the input container:
|
||||
```tsx
|
||||
const keyboardOffset = useVisualViewport()
|
||||
// On the input area div:
|
||||
<div className="shrink-0 border-t px-4 py-3"
|
||||
style={{ paddingBottom: `calc(${keyboardOffset}px + env(safe-area-inset-bottom, 0px))` }}>
|
||||
```
|
||||
- When keyboardOffset > 0 (keyboard is open), auto-scroll to bottom of message list
|
||||
- Change the EmptyState container from `h-full` to responsive: works both when full-screen and when sharing space
|
||||
|
||||
5. Update `components/chat-sidebar.tsx` — touch-optimized tap targets:
|
||||
- Ensure conversation buttons have minimum 44px height (current py-3 likely sufficient, verify)
|
||||
- The "New Conversation" button should have at least 44x44 tap target on mobile
|
||||
- Replace any `hover:bg-accent` with `hover:bg-accent active:bg-accent` so touch devices get immediate feedback via the active pseudo-class (Tailwind v4 wraps hover in @media(hover:hover) already, but active provides touch feedback)
|
||||
|
||||
6. Add i18n key: `chat.backToConversations` in en/es/pt.json for the back button aria-label
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
On mobile (< 768px): chat page shows conversation list full-width. Tapping a conversation shows full-screen chat with back arrow header. Back arrow returns to list. iOS keyboard pushes the input up instead of hiding it. Desktop two-column layout unchanged. Build passes. All chat functionality (send, streaming, typing indicator) works in both layouts.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal
|
||||
- Chat page renders conversation list on mobile by default
|
||||
- Selecting a conversation shows full-screen chat with MobileChatHeader on mobile
|
||||
- Back button returns to conversation list
|
||||
- Desktop layout unchanged (two columns)
|
||||
- Chat input stays visible when keyboard opens (Visual Viewport hook active)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Mobile chat follows the WhatsApp-style pattern: conversation list full-screen, then full-screen chat with back arrow. Input is keyboard-safe on iOS. Touch interactions have immediate feedback. Desktop layout is unmodified.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-02-SUMMARY.md`
|
||||
</output>
|
||||
341
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
341
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "08-01"
|
||||
- "08-02"
|
||||
files_modified:
|
||||
- packages/portal/app/sw.ts
|
||||
- packages/portal/components/install-prompt.tsx
|
||||
- packages/portal/components/push-permission.tsx
|
||||
- packages/portal/lib/message-queue.ts
|
||||
- packages/portal/lib/use-chat-socket.ts
|
||||
- packages/portal/app/(dashboard)/layout.tsx
|
||||
- packages/portal/app/actions/push.ts
|
||||
- packages/gateway/routers/push.py
|
||||
- packages/gateway/main.py
|
||||
- packages/shared/models/push.py
|
||||
- migrations/versions/010_push_subscriptions.py
|
||||
- packages/portal/messages/en.json
|
||||
- packages/portal/messages/es.json
|
||||
- packages/portal/messages/pt.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- MOB-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can grant push notification permission from the portal"
|
||||
- "Push subscription is stored in the database (not just memory)"
|
||||
- "When an AI Employee responds and the user's WebSocket is not connected, a push notification is sent"
|
||||
- "Tapping a push notification opens the relevant conversation in the portal"
|
||||
- "PWA install prompt appears on second visit (not first) and is dismissable"
|
||||
- "Offline message queue stores unsent messages in IndexedDB and drains on reconnection"
|
||||
- "Stale push subscriptions (410 Gone) are deleted from the database"
|
||||
artifacts:
|
||||
- path: "packages/portal/components/install-prompt.tsx"
|
||||
provides: "Smart install banner for PWA (second visit)"
|
||||
exports: ["InstallPrompt"]
|
||||
- path: "packages/portal/components/push-permission.tsx"
|
||||
provides: "Push notification opt-in UI"
|
||||
exports: ["PushPermission"]
|
||||
- path: "packages/portal/lib/message-queue.ts"
|
||||
provides: "IndexedDB offline message queue"
|
||||
exports: ["enqueueMessage", "drainQueue"]
|
||||
- path: "packages/portal/app/actions/push.ts"
|
||||
provides: "Server actions for push subscription management and sending"
|
||||
- path: "packages/gateway/routers/push.py"
|
||||
provides: "Push subscription CRUD API endpoints"
|
||||
- path: "migrations/versions/010_push_subscriptions.py"
|
||||
provides: "push_subscriptions table migration"
|
||||
key_links:
|
||||
- from: "packages/portal/app/sw.ts"
|
||||
to: "push event handler"
|
||||
via: "self.addEventListener('push', ...) shows notification"
|
||||
pattern: "addEventListener.*push"
|
||||
- from: "packages/portal/app/sw.ts"
|
||||
to: "notificationclick handler"
|
||||
via: "self.addEventListener('notificationclick', ...) opens conversation"
|
||||
pattern: "notificationclick"
|
||||
- from: "packages/gateway/routers/push.py"
|
||||
to: "packages/shared/models/push.py"
|
||||
via: "stores PushSubscription in DB"
|
||||
pattern: "push_subscriptions"
|
||||
- from: "packages/portal/lib/use-chat-socket.ts"
|
||||
to: "packages/portal/lib/message-queue.ts"
|
||||
via: "enqueue when offline, drain on reconnect"
|
||||
pattern: "enqueueMessage|drainQueue"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Push notifications, offline message queue, and PWA install prompt. Users receive notifications when AI Employees respond, messages queue offline and auto-send on reconnection, and the install banner appears on the second visit.
|
||||
|
||||
Purpose: Push notifications make the platform feel alive on mobile — users know immediately when their AI Employee responds. Offline message queue prevents lost messages. The install prompt drives PWA adoption.
|
||||
|
||||
Output: Working push notification pipeline (client subscription -> DB storage -> server-side send -> service worker display), IndexedDB message queue with auto-drain, second-visit install banner.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
|
||||
@.planning/phases/08-mobile-pwa/08-01-SUMMARY.md
|
||||
@.planning/phases/08-mobile-pwa/08-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from prior plans and existing code -->
|
||||
|
||||
From packages/portal/app/sw.ts (created in Plan 01):
|
||||
```typescript
|
||||
// Serwist service worker with installSerwist()
|
||||
// Push event listener to be added here
|
||||
```
|
||||
|
||||
From packages/portal/lib/use-chat-socket.ts (modified in Plan 02 for mobile chat):
|
||||
```typescript
|
||||
export interface UseChatSocketOptions {
|
||||
conversationId: string;
|
||||
authHeaders: ChatSocketAuthHeaders;
|
||||
onMessage: (text: string) => void;
|
||||
onTyping: (typing: boolean) => void;
|
||||
onChunk: (token: string) => void;
|
||||
onDone: (fullText: string) => void;
|
||||
}
|
||||
export function useChatSocket(options: UseChatSocketOptions): { send: (text: string) => void; isConnected: boolean }
|
||||
// WebSocket connects to gateway at WS_URL/ws/chat
|
||||
// Uses refs for callbacks to avoid reconnection on handler changes
|
||||
// NOTE: Plan 02 may have modified this file for mobile chat — read current state before editing
|
||||
```
|
||||
|
||||
From packages/gateway/main.py:
|
||||
```python
|
||||
# FastAPI app with routers mounted for portal, billing, channels, llm_keys, usage, webhook
|
||||
# New push router needs to be mounted here
|
||||
```
|
||||
|
||||
From packages/shared/models/:
|
||||
```python
|
||||
# Pydantic models and SQLAlchemy ORM models
|
||||
# New PushSubscription model goes here
|
||||
```
|
||||
|
||||
From migrations/versions/:
|
||||
```python
|
||||
# Alembic migrations — latest is 009_*
|
||||
# Next migration: 010_push_subscriptions
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Push notification backend — DB migration, API endpoints, VAPID setup</name>
|
||||
<files>
|
||||
packages/shared/models/push.py,
|
||||
migrations/versions/010_push_subscriptions.py,
|
||||
packages/gateway/routers/push.py,
|
||||
packages/gateway/main.py,
|
||||
packages/portal/messages/en.json,
|
||||
packages/portal/messages/es.json,
|
||||
packages/portal/messages/pt.json
|
||||
</files>
|
||||
<action>
|
||||
1. Generate VAPID keys:
|
||||
```bash
|
||||
cd packages/portal && npx web-push generate-vapid-keys
|
||||
```
|
||||
Add to `.env.example` and `.env`:
|
||||
- `NEXT_PUBLIC_VAPID_PUBLIC_KEY=...`
|
||||
- `VAPID_PRIVATE_KEY=...`
|
||||
|
||||
2. Create `packages/shared/models/push.py`:
|
||||
- SQLAlchemy ORM model `PushSubscription`:
|
||||
- id: UUID primary key (server_default=gen_random_uuid())
|
||||
- user_id: UUID, NOT NULL, FK to portal_users.id
|
||||
- tenant_id: UUID, nullable (for scoping notifications)
|
||||
- endpoint: TEXT, NOT NULL (push service URL)
|
||||
- p256dh: TEXT, NOT NULL (encryption key)
|
||||
- auth: TEXT, NOT NULL (auth secret)
|
||||
- created_at: TIMESTAMP WITH TIME ZONE, server_default=now()
|
||||
- updated_at: TIMESTAMP WITH TIME ZONE, server_default=now(), onupdate=now()
|
||||
- Unique constraint on (user_id, endpoint) — one subscription per browser per user
|
||||
- RLS policy: users can only read/write their own subscriptions
|
||||
- Pydantic schema: PushSubscriptionCreate(endpoint, p256dh, auth), PushSubscriptionOut(id, endpoint, created_at)
|
||||
|
||||
3. Create `migrations/versions/010_push_subscriptions.py`:
|
||||
- Alembic migration creating the push_subscriptions table
|
||||
- Add RLS: ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY
|
||||
- RLS policy: user_id = current_setting('app.current_user')::uuid (or use the same tenant-based RLS pattern as other tables)
|
||||
- Actually — push subscriptions are per-user not per-tenant, so the API should filter by user_id in the query, not rely on tenant RLS. Add a simple policy or skip RLS for this table and filter in the application layer (since it's portal-user-scoped, not tenant-scoped).
|
||||
|
||||
4. Create `packages/gateway/routers/push.py`:
|
||||
- POST /portal/push/subscribe — stores push subscription for authenticated user
|
||||
- Body: { endpoint, keys: { p256dh, auth } }
|
||||
- Upserts by (user_id, endpoint)
|
||||
- Returns 201
|
||||
- DELETE /portal/push/unsubscribe — removes subscription by endpoint
|
||||
- Body: { endpoint }
|
||||
- Returns 204
|
||||
- POST /portal/push/send — internal endpoint (called by orchestrator/gateway when agent responds)
|
||||
- Body: { user_id, title, body, conversation_id }
|
||||
- Looks up all push subscriptions for user_id
|
||||
- Sends via web-push library (Python equivalent: pywebpush)
|
||||
- Handles 410 Gone by deleting stale subscriptions
|
||||
- Returns 200
|
||||
- For the push send: install `pywebpush` in gateway's dependencies. Actually, since the push send needs to happen from the backend (Python), use `pywebpush` not the Node `web-push`. Add `pywebpush` to gateway's pyproject.toml.
|
||||
- Alternatively: the push send can happen from the portal (Node.js) via a Server Action. The gateway can call the portal's API or the portal can subscribe to the same Redis pub-sub channel.
|
||||
- SIMPLEST APPROACH per research: The gateway WebSocket handler already checks if a client is connected. When the orchestrator task publishes the response to Redis, the gateway WS handler receives it. If no active WebSocket session exists for that user+conversation, trigger a push notification. The push send itself should use pywebpush from the gateway since that's where the event originates.
|
||||
|
||||
5. Mount push router in `packages/gateway/main.py`:
|
||||
```python
|
||||
from routers.push import router as push_router
|
||||
app.include_router(push_router)
|
||||
```
|
||||
|
||||
6. Add `pywebpush` to gateway's pyproject.toml dependencies.
|
||||
|
||||
7. Wire push notification trigger into the gateway's WebSocket response handler:
|
||||
- In the existing WebSocket handler (where it publishes agent responses to the client), add logic:
|
||||
- After receiving agent response from Redis pub-sub, check if the WebSocket for that user is still connected
|
||||
- If NOT connected, call the push send logic (or fire a Celery task) with the user_id, conversation_id, agent response preview
|
||||
- Use VAPID_PRIVATE_KEY and NEXT_PUBLIC_VAPID_PUBLIC_KEY from environment
|
||||
|
||||
8. Add i18n keys for push notifications:
|
||||
- push.enableNotifications, push.enableNotificationsDescription, push.enabled, push.denied, push.notSupported
|
||||
- install.title, install.description, install.installButton, install.dismissButton, install.iosInstructions
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct && python -m py_compile packages/gateway/routers/push.py && python -m py_compile packages/shared/models/push.py</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Push subscriptions table exists in migration. Gateway has push API endpoints (subscribe, unsubscribe, send). pywebpush integrated for server-side notification delivery. Push trigger wired into WebSocket response handler — sends notification when user is not connected. VAPID keys in env.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Push subscription client, service worker push handler, install prompt, offline queue</name>
|
||||
<files>
|
||||
packages/portal/app/sw.ts,
|
||||
packages/portal/components/install-prompt.tsx,
|
||||
packages/portal/components/push-permission.tsx,
|
||||
packages/portal/lib/message-queue.ts,
|
||||
packages/portal/lib/use-chat-socket.ts,
|
||||
packages/portal/app/(dashboard)/layout.tsx
|
||||
</files>
|
||||
<action>
|
||||
IMPORTANT: Plan 08-02 modifies use-chat-socket.ts for mobile chat. Read the current file state before making changes — do not overwrite 08-02's modifications.
|
||||
|
||||
1. Update `app/sw.ts` — add push event handlers:
|
||||
```typescript
|
||||
// After installSerwist(...)
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json()
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
data: data.data, // { conversationId }
|
||||
vibrate: [100, 50, 100],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const conversationId = event.notification.data?.conversationId
|
||||
const url = conversationId ? `/chat?id=${conversationId}` : '/chat'
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Focus existing window if open
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes('/chat') && 'focus' in client) {
|
||||
client.navigate(url)
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
// Open new window
|
||||
return clients.openWindow(url)
|
||||
})
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
2. Create `components/push-permission.tsx`:
|
||||
- "use client" component
|
||||
- Shows a card/banner prompting the user to enable push notifications
|
||||
- On click: requests Notification.permission, subscribes via PushManager with VAPID public key, POSTs subscription to /portal/push/subscribe
|
||||
- States: 'default' (show prompt), 'granted' (show "enabled" badge), 'denied' (show "blocked" message with instructions)
|
||||
- Handles browsers that don't support push: show "not supported" message
|
||||
- Uses `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` for applicationServerKey
|
||||
- Utility: `urlBase64ToUint8Array(base64String)` helper for converting VAPID key
|
||||
- Place this component in the More sheet or in a settings section — not intrusive
|
||||
|
||||
3. Create `components/install-prompt.tsx`:
|
||||
- "use client" component
|
||||
- Captures `beforeinstallprompt` event on mount, stores in ref
|
||||
- Tracks visit count via localStorage ('konstruct_visit_count')
|
||||
- Shows install banner only when: visit count >= 2 AND not already in standalone mode AND prompt is available (Android/Chrome) OR is iOS
|
||||
- For iOS: detect via userAgent, show instructions "Tap Share icon, then Add to Home Screen"
|
||||
- Dismiss button: sets localStorage 'konstruct_install_dismissed' = 'true'
|
||||
- Check `window.matchMedia('(display-mode: standalone)').matches` — if already installed, never show
|
||||
- Style: fixed bottom-20 (above tab bar) left/right margin, rounded card with app icon, text, install button, dismiss X
|
||||
- On install click: call deferredPrompt.prompt(), await userChoice
|
||||
|
||||
4. Create `lib/message-queue.ts` — IndexedDB offline queue using `idb`:
|
||||
```typescript
|
||||
import { openDB } from 'idb'
|
||||
const DB_NAME = 'konstruct-offline'
|
||||
const STORE = 'message-queue'
|
||||
export async function enqueueMessage(conversationId: string, text: string) { ... }
|
||||
export async function drainQueue(send: (convId: string, text: string) => void) { ... }
|
||||
```
|
||||
|
||||
5. Update `lib/use-chat-socket.ts` — integrate offline queue:
|
||||
- Read the file first to see 08-02's changes, then add offline queue integration on top
|
||||
- Import enqueueMessage and drainQueue from message-queue
|
||||
- In the `send` function: if WebSocket is not connected (isConnected is false), call `enqueueMessage(conversationId, text)` instead of sending via WebSocket
|
||||
- On reconnection (when WebSocket opens): call `drainQueue((convId, text) => ws.send(...))` to send queued messages
|
||||
- Add `useOnlineStatus` check — when transitioning from offline to online, trigger reconnection
|
||||
|
||||
6. Mount `<InstallPrompt />` and `<PushPermission />` in `app/(dashboard)/layout.tsx`:
|
||||
- InstallPrompt: rendered at the bottom of the layout (above MobileNav)
|
||||
- PushPermission: rendered inside the More sheet or as a one-time prompt after first login
|
||||
- Actually, a simpler approach: add a "Notifications" toggle in the More sheet that triggers push permission. The PushPermission component can be a button within the MobileMoreSheet.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Service worker handles push events and shows notifications with conversation deep-link. Push permission UI available in the portal. Install prompt appears on second visit and is dismissable. Offline message queue stores messages in IndexedDB and auto-drains on reconnection. Build passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `npm run build` passes in packages/portal
|
||||
- Python files compile without errors
|
||||
- Service worker source (app/sw.ts) includes push and notificationclick handlers
|
||||
- Push subscription API endpoints registered on gateway
|
||||
- Migration 010 creates push_subscriptions table
|
||||
- Install prompt component handles both Android (beforeinstallprompt) and iOS (manual instructions)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Push notifications are delivered when the user's PWA is not in the foreground. Tapping a notification opens the conversation. Offline messages queue in IndexedDB and send on reconnection. Install prompt shows on second visit, not first.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-03-SUMMARY.md`
|
||||
</output>
|
||||
143
.planning/phases/08-mobile-pwa/08-04-PLAN.md
Normal file
143
.planning/phases/08-mobile-pwa/08-04-PLAN.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
phase: 08-mobile-pwa
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "08-01"
|
||||
- "08-02"
|
||||
- "08-03"
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- MOB-01
|
||||
- MOB-02
|
||||
- MOB-03
|
||||
- MOB-04
|
||||
- MOB-05
|
||||
- MOB-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All portal pages render correctly on mobile (320px) and tablet (768px) viewports"
|
||||
- "Bottom tab bar navigation works with RBAC filtering"
|
||||
- "Chat full-screen flow works end-to-end on mobile"
|
||||
- "PWA is installable and shows correct icon/splash"
|
||||
- "Push notifications received on installed PWA"
|
||||
- "No hover-stuck interactions on touch devices"
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Human verification of all mobile and PWA features across device viewports and interaction modes.
|
||||
|
||||
Purpose: Mobile UX cannot be fully validated by automated tests. This checkpoint confirms that the responsive layout, touch interactions, PWA install, and push notifications work correctly on real device viewports.
|
||||
|
||||
Output: Human-verified sign-off that all MOB requirements are met.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
|
||||
@.planning/phases/08-mobile-pwa/08-01-SUMMARY.md
|
||||
@.planning/phases/08-mobile-pwa/08-02-SUMMARY.md
|
||||
@.planning/phases/08-mobile-pwa/08-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: Verify all mobile and PWA features</name>
|
||||
<files></files>
|
||||
<action>
|
||||
Present the verification checklist to the user. All implementation was completed in Plans 01-03. This task verifies the results.
|
||||
|
||||
What was built:
|
||||
- Bottom tab bar navigation (5 items) with More bottom sheet on mobile
|
||||
- Desktop sidebar preserved on screens >= 768px
|
||||
- Full-screen WhatsApp-style chat flow on mobile
|
||||
- iOS virtual keyboard handling for chat input
|
||||
- PWA manifest with K monogram icons
|
||||
- Service worker with offline caching
|
||||
- Push notifications for AI Employee responses
|
||||
- Offline message queue with auto-drain
|
||||
- Second-visit install prompt
|
||||
- Offline status banner
|
||||
|
||||
How to verify:
|
||||
|
||||
**Setup:** Start the portal with `npm run dev` (or `npm run dev:pwa` with `--experimental-https --webpack` for full PWA testing).
|
||||
|
||||
**MOB-01 — Responsive pages (all viewports):**
|
||||
1. Open Chrome DevTools, toggle device toolbar
|
||||
2. Test at 320px width (iPhone SE): Dashboard, Employees, Chat, Usage, Billing pages
|
||||
3. Test at 768px width (iPad): same pages
|
||||
4. Test at 1024px width (iPad landscape): same pages
|
||||
5. Verify: no horizontal scrolling, no overlapping elements, readable text
|
||||
|
||||
**MOB-02 — Mobile navigation:**
|
||||
1. At 320px width: verify bottom tab bar with 5 icons (Dashboard, Employees, Chat, Usage, More)
|
||||
2. Tap each tab — correct page loads, active indicator shows
|
||||
3. Tap "More" — bottom sheet slides up with Billing, API Keys, Users, etc.
|
||||
4. Test with operator role: verify restricted items hidden in More sheet
|
||||
5. At 768px+: verify sidebar appears, no tab bar
|
||||
|
||||
**MOB-03 — Mobile chat:**
|
||||
1. At 320px: navigate to Chat
|
||||
2. Verify conversation list shows full-width
|
||||
3. Tap a conversation: verify full-screen chat with back arrow + agent name header
|
||||
4. Send a message — verify it appears
|
||||
5. Wait for AI response — verify streaming tokens appear word-by-word
|
||||
6. Tap back arrow — verify return to conversation list
|
||||
7. Start a new conversation — verify agent picker works on mobile
|
||||
|
||||
**MOB-04 — PWA install:**
|
||||
1. Run with `npm run dev:pwa` (--experimental-https --webpack)
|
||||
2. Open Chrome DevTools > Application > Manifest: verify manifest loads with correct name, icons
|
||||
3. Application > Service Workers: verify SW registered
|
||||
4. Run Lighthouse PWA audit: target score >= 90
|
||||
5. If on Android Chrome: verify install prompt appears on second visit
|
||||
|
||||
**MOB-05 — Push notifications:**
|
||||
1. Enable notifications when prompted
|
||||
2. Open a chat conversation, send a message, get a response (verify WebSocket works)
|
||||
3. Close the browser tab / switch away
|
||||
4. Trigger another AI response (e.g., via a second browser window or API call)
|
||||
5. Verify push notification appears on device
|
||||
6. Tap notification — verify it opens the correct conversation
|
||||
|
||||
**MOB-06 — Touch interactions:**
|
||||
1. At 320px, tap all buttons and links — verify immediate visual feedback (no hover-stuck states)
|
||||
2. Verify no tooltips or dropdowns that require hover to trigger
|
||||
3. Verify all tap targets are >= 44px minimum dimension
|
||||
|
||||
Resume signal: Type "approved" to complete Phase 8, or describe issues to address.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90. No hover-stuck interactions on touch. Phase 8 complete.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
All MOB requirements verified by human testing on mobile viewports.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Human confirms all six MOB requirements pass on mobile viewports. Lighthouse PWA audit score >= 90.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/08-mobile-pwa/08-04-SUMMARY.md`
|
||||
</output>
|
||||
682
.planning/phases/08-mobile-pwa/08-RESEARCH.md
Normal file
682
.planning/phases/08-mobile-pwa/08-RESEARCH.md
Normal file
@@ -0,0 +1,682 @@
|
||||
# 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)
|
||||
85
.planning/phases/08-mobile-pwa/08-VALIDATION.md
Normal file
85
.planning/phases/08-mobile-pwa/08-VALIDATION.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
phase: 8
|
||||
slug: mobile-pwa
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-26
|
||||
---
|
||||
|
||||
# Phase 8 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | ESLint + TypeScript strict + Next.js build (existing) |
|
||||
| **Config file** | `eslint.config.mjs`, `tsconfig.json` |
|
||||
| **Quick run command** | `cd packages/portal && npm run lint` |
|
||||
| **Full suite command** | `cd packages/portal && npm run build` |
|
||||
| **Estimated runtime** | ~45 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `npm run lint` in packages/portal
|
||||
- **After every plan wave:** Run `npm run build` in packages/portal
|
||||
- **Before `/gsd:verify-work`:** Full build must pass + Lighthouse PWA audit score >= 90
|
||||
- **Max feedback latency:** 45 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 08-xx | 01 | 0 | MOB-04 | build | `npx next build` | ✅ | ⬜ pending |
|
||||
| 08-xx | 02 | 1 | MOB-01,02 | build | `npx next build` | ✅ | ⬜ pending |
|
||||
| 08-xx | 03 | 1 | MOB-03 | build | `npx next build` | ✅ | ⬜ pending |
|
||||
| 08-xx | 04 | 2 | MOB-04,05 | build | `npx next build` | ✅ | ⬜ pending |
|
||||
| 08-xx | 05 | 2 | MOB-06 | build | `npx next build` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `public/icon-192.png` — K monogram icon (192x192)
|
||||
- [ ] `public/icon-512.png` — K monogram icon (512x512)
|
||||
- [ ] `public/icon-maskable-192.png` — Maskable variant with safe-zone padding
|
||||
- [ ] `public/apple-touch-icon.png` — iOS home screen icon (180x180)
|
||||
- [ ] `public/badge-72.png` — Notification badge (72x72, monochrome)
|
||||
- [ ] VAPID keys generated and added to `.env`
|
||||
- [ ] `npm install @serwist/next serwist web-push idb` + `npm install -D @types/web-push`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Pages render at 320px–1024px | MOB-01 | Device emulation | Chrome DevTools device mode, check all pages |
|
||||
| Bottom tab bar on mobile | MOB-02 | Visual layout | Resize below 768px, verify tabs appear |
|
||||
| Chat full-screen on mobile | MOB-03 | UI interaction | Tap conversation, verify full-screen with back arrow |
|
||||
| PWA installable | MOB-04 | Browser behavior | Lighthouse PWA audit, manual install from Chrome |
|
||||
| Push notification received | MOB-05 | Requires HTTPS + real device | Install PWA, send test message, verify notification |
|
||||
| No hover-stuck interactions | MOB-06 | Touch device | Tap all buttons/links on real touch device |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 45s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user