---
phase: 26
slug: pwa-performance
status: draft
shadcn_initialized: true
preset: new-york / neutral / cssVariables
created: 2026-04-01
---

# Phase 26 — UI Design Contract

> Visual and interaction contract for Phase 26: PWA & Performance.
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | shadcn (new-york style) |
| Preset | new-york, neutral base color, cssVariables: true |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react |
| Font | System default (no custom web font — performance constraint) |

Source: `ui/components.json`, `ui/vite.config.ts`

---

## Existing PWA Infrastructure (Pre-Phase)

The following already exists and must NOT be re-implemented — Phase 26 extends it:

| Asset | Path | Current State |
|-------|------|---------------|
| Service worker | `ui/public/sw.js` | Network-first, cache-as-fallback, skips `/api` |
| Web manifest | `ui/public/site.webmanifest` | Standalone display, Nexus name, two PNG icon sizes |
| Viewport meta | `ui/index.html` | `viewport-fit=cover`, `user-scalable=no`, `maximum-scale=1` |
| Apple PWA meta | `ui/index.html` | `apple-mobile-web-app-capable`, status bar style, title |
| Theme-color meta | `ui/index.html` | Dynamic per-theme via inline script |
| SW registration | `ui/src/main.tsx` | Registered on `load` event |
| Icons (existing) | `ui/public/` | `android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`, favicons |

Phase 26 upgrades the SW to a proper cache-first strategy for static assets, adds offline message queuing, adds the install prompt UI, adds push notification wiring, and completes the responsive mobile layout.

---

## Spacing Scale

Declared values (multiples of 4):

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon gaps, inline chip padding |
| sm | 8px | Compact element spacing, icon button padding |
| md | 16px | Default element spacing, input padding |
| lg | 24px | Section padding, card padding |
| xl | 32px | Layout gaps |
| 2xl | 48px | Major section breaks |
| 3xl | 64px | Page-level spacing |

Exceptions:
- Touch targets: minimum 44px × 44px on all interactive elements (mobile). Applies to: conversation list items, message action buttons, pull-to-refresh drag zone, install prompt dismiss button, notification permission button.
- iOS safe area insets: input bar bottom padding must use `env(safe-area-inset-bottom)` on mobile to avoid the home indicator. Apply as `pb-[env(safe-area-inset-bottom)]` or equivalent.
- Keyboard-aware resize: when the virtual keyboard appears, the sticky input bar must remain visible above it. This is handled via `100dvh` viewport height (dynamic viewport unit) rather than `100vh`.

---

## Typography

| Role | Size | Weight | Line Height |
|------|------|--------|-------------|
| Body | 14px | 400 | 1.5 |
| Label | 12px | 400 | 1.3 |
| Heading | 16px | 600 | 1.2 |
| Display | 20px | 600 | 1.2 |

Source: Matches existing chat component patterns (ChatPanel, ChatInput, ChatConversationList use `text-sm`/`text-xs`/`text-base`/`font-semibold`).

No new font sizes are introduced in Phase 26. All new UI elements (install prompt, offline banner, notification permission prompt) use the existing Body/Label scale.

---

## Color

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `var(--background)` | App shell, chat background |
| Secondary (30%) | `var(--card)` / `var(--sidebar)` | Sidebar, cards, input surface, overlays |
| Accent (10%) | `var(--primary)` | Install prompt CTA button, notification permission accept button |
| Destructive | `var(--destructive)` | Offline banner error state, notification permission deny action |

Theme-aware values:
- Catppuccin Mocha (dark default): background `#1e1e2e`, card `#181825`, primary `#89b4fa`
- Tokyo Night (dark): background `#1a1b26`, card `#16161e`, primary `#7aa2f7`
- Catppuccin Latte (light): background `#eff1f5`, card `#e6e9ef`, primary `#1e66f5`

Accent reserved for:
1. "Add to Home Screen" / "Install app" CTA button in the install prompt banner
2. "Allow notifications" accept button in the push notification permission prompt
3. Offline queue flush indicator (spinner/dot when messages are queued and reconnection is in progress)

Offline banner uses a dedicated amber/warning surface:
- Dark themes: `bg-amber-900/40 text-amber-200 border border-amber-800`
- Light theme: `bg-amber-50 text-amber-800 border border-amber-200`
(These are utility classes, not new CSS variables — keeps the token count stable.)

---

## Layout Breakpoints

| Breakpoint | Viewport | Chat Layout |
|------------|----------|-------------|
| Mobile | < 768px (below Tailwind `md`) | Full-screen chat replaces desktop slide-in panel; sidebar becomes a bottom sheet or full-screen overlay; input bar sticky at bottom |
| Tablet | 768px–1024px | Split view: sidebar at 280px + chat panel fills remainder |
| Desktop | > 1024px | Existing layout: `hidden md:flex` chat panel at fixed width, sidebar pinned |

The existing `hidden md:flex` class on ChatPanel means the panel is invisible on mobile. Phase 26 adds a mobile-first full-screen chat view accessible via a bottom navigation tap or a floating action button.

Mobile chat layout:
- Header: 48px tall, contains back button (← with `aria-label="Back to conversations"`), conversation title (truncated), agent selector icon
- Message list: fills remaining viewport height using `h-[calc(100dvh-48px-56px-env(safe-area-inset-bottom))]`
- Input bar: 56px minimum, sticky `bottom-0`, padded by `env(safe-area-inset-bottom)`

Conversation list on mobile:
- Full-screen overlay or bottom sheet (Sheet component from shadcn)
- Pull-to-refresh drag zone: 60px from top, visual indicator using a spinner at `--primary` color
- Each conversation list item: minimum 48px tall touch target

---

## Component Inventory (New for Phase 26)

| Component | Type | Description |
|-----------|------|-------------|
| `InstallPromptBanner` | New | Top or bottom banner shown after `beforeinstallprompt`; contains "Add to Home Screen" CTA + dismiss |
| `OfflineBanner` | New | Amber banner shown when `navigator.onLine` is false; shows queued message count |
| `NotificationPermissionPrompt` | New | Modal or inline prompt requesting push notification permission; two buttons: Allow + Not now |
| `PullToRefresh` | New | Wraps conversation list; detects swipe-down gesture and triggers refetch |
| `MobileChatView` | New | Full-screen mobile chat layout (header + message list + sticky input) |
| `MobileNavBar` | New | Bottom navigation bar for mobile: Dashboard, Chat, Inbox tabs (minimum 44px tap height) |
| `OfflineMessageQueue` | Logic only | Hook `useOfflineQueue` — no visual output; stores unsent messages in IndexedDB, flushes on reconnect |

Existing components modified in Phase 26:
| Component | Change |
|-----------|--------|
| `ChatPanel` | Wrap with responsive logic; show `MobileChatView` on mobile, existing panel on desktop |
| `ChatConversationList` | Wrap with `PullToRefresh` |
| `ChatInput` | Add `pb-[env(safe-area-inset-bottom)]` safe area padding; ensure 44px minimum tap height on Send button |

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Install prompt heading | "Add Nexus to your home screen" |
| Install prompt body | "Get the full experience — launch instantly, works offline." |
| Install prompt CTA | "Add to Home Screen" |
| Install prompt dismiss | "Not now" |
| Offline banner | "You're offline — messages will send when you reconnect" |
| Offline banner with queue | "You're offline — {n} message{s} queued" |
| Notification permission heading | "Stay in the loop" |
| Notification permission body | "Get notified when your agents complete tasks or need input." |
| Notification permission accept | "Allow notifications" |
| Notification permission deny | "Not now" |
| Pull-to-refresh loading | (spinner only, no text) |
| Pull-to-refresh release | "Release to refresh" |
| Pull-to-refresh pulling | "Pull to refresh" |
| Empty offline state | "No conversations yet" / "Start a conversation to get going." |
| Cached load label | (no user-visible label — load is silent) |
| Push notification title (mention) | "Nexus — {AgentName} mentioned you" |
| Push notification body (mention) | "{snippet of message, max 80 chars}" |
| Push notification title (task complete) | "Nexus — Task completed" |
| Push notification body (task complete) | "{AgentName} completed "{TaskTitle}"" |
| Push notification title (handoff) | "Nexus — Handoff request" |
| Push notification body (handoff) | "{AgentName} is ready with a spec for you." |
| Destructive: revoke notifications | "Turn off notifications": "You can re-enable them any time in your browser settings." |

Destructive actions in Phase 26:
- None requiring a confirmation dialog. Notification permission denial is non-destructive (browser handles revoking via settings). No data deletion in this phase.

---

## Interaction States

### Install Prompt Banner

- Shown: once per session, after `beforeinstallprompt` fires AND user has visited at least one conversation (not shown on first-ever load before any engagement)
- Dismissed: stored in `localStorage` key `nexus.installPromptDismissed`; not shown again for 7 days
- Already installed: banner never shown (detect via `window.matchMedia('(display-mode: standalone)')`)
- Position: fixed bottom, above the input bar on mobile; fixed top on desktop

### Offline Banner

- Shown: when `navigator.onLine === false`
- Hidden: auto-dismisses 3 seconds after `navigator.onLine` returns true and the queue is empty
- Persistent while offline: does not auto-dismiss while still offline

### Notification Permission Prompt

- Shown: after user's third agent response in any conversation (engagement gate)
- Never shown if `Notification.permission === 'granted'` or `=== 'denied'`
- Stored dismissed state: `localStorage` key `nexus.notifPromptDismissed`

### Pull-to-Refresh

- Trigger threshold: 64px downward drag from top of conversation list
- Visual: spinner using `var(--primary)` color, 24px, appears at the pull origin point
- Max overdrag: 96px before triggering (prevents accidental trigger)
- Haptic feedback: `navigator.vibrate(10)` on trigger if available

### Mobile Chat Navigation

- Bottom nav is always visible on mobile (< 768px) when the app is installed (standalone) or on a touch device
- Active tab uses accent color (`var(--primary)`) for icon tint
- Inactive tabs use `var(--muted-foreground)` for icon tint

---

## Performance Targets (Visual-Side Contracts)

| Metric | Target | Implementation Contract |
|--------|--------|------------------------|
| Initial load (broadband) | < 2s | Vite code-split per route; no blocking scripts; no web fonts |
| Initial load (3G) | < 5s | Vite code-split; compress assets; inline critical CSS |
| PWA cached load | < 1s | SW cache-first for all static assets (JS/CSS/HTML); stale-while-revalidate for fonts/images |
| Shell paint | < 200ms | App shell (HTML/CSS) cached by SW; no API calls blocking shell render |
| Offline UI load | < 1s | All static assets served from SW cache; API failures handled gracefully |

SW cache strategy:
- Cache name: `nexus-v1` (rename from existing `paperclip-v2` to bust stale cache)
- Static assets (JS, CSS, fonts, images): cache-first with network fallback
- Navigation requests (`/`): cache-first (app shell)
- API requests (`/api/*`): network-only; failures trigger offline queue for POST/mutations

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | button, sheet, dialog, badge, skeleton, tooltip | not required |
| none (third-party) | — | not applicable |

No third-party registries are declared for Phase 26.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
