diff --git a/docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md b/docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md new file mode 100644 index 0000000..b940f69 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md @@ -0,0 +1,516 @@ +# Admin Mobile PWA — Design Spec + +- **Date:** 2026-06-17 +- **Status:** Draft, awaiting user review +- **Author:** Grok +- **Branch:** `main` +- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support + +--- + +## Context + +Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline. + +**Symptoms today:** + +- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404 +- `registerServiceWorker()` is exported but never called +- `PWAInstallPrompt` is never mounted in the tree +- `apple-touch-icon.png` and `favicon.ico` are referenced but missing +- Service worker references `/offline` and `/og-default.jpg` that don't exist +- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`) +- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics +- Body text is 16pt; tap targets are 40–44pt; status colors don't all clear WCAG AA on the warm cream background +- No offline mutation queue — losing signal mid-fulfillment is a real operational problem + +## Goals + +1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar. +2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile. +3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves. +4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution. +5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI. + +## Non-Goals + +- Redesigning marketing pages (landing, pricing, blog). They stay as-is. +- Redesigning the public storefronts. They stay as-is. +- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision. +- Wholesale portal mobile redesign. Tabled for a future spec. +- Real-time collaboration (websockets / live cursors). Out of scope. +- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`. + +## Surfaces in scope + +- `/admin` (Dashboard) +- `/admin/orders` + `/admin/orders/[id]` +- `/admin/stops` +- `/admin/products` +- The admin shell that wraps all of the above +- PWA install, splash, offline page, and SW behavior across the whole app + +--- + +## 1. Aesthetic direction & design system + +### Direction: "HIG-First Field Almanac" + +Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious. + +### Type scale (HIG Accessibility-tier) + +| Token | Size / Weight | Use | +| ---------------- | ----------------------------------- | -------------------------------------- | +| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) | +| `text-h1` | 24pt / Fraunces 600 | Section headers | +| `text-h2` | 19pt / Manrope 700 | Card titles, list primary | +| `text-body` | 18pt / Manrope 500 | All body text | +| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills| +| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers | +| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text | + +Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility. + +### Color tokens (AAA contrast on `surface`) + +| Token | Value | Use | Contrast on `#faf8f5` | +| ------------------ | --------- | --------------------------------------- | --------------------- | +| `bg` | `#ffffff` | App background | — | +| `surface` | `#faf8f5` | Page background (warm cream) | — | +| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — | +| `surface-3` | `#e8e8ed` | Hover / pressed | — | +| `text` | `#1d1d1f` | Primary text | 14.6 : 1 | +| `text-muted` | `#424245` | Secondary text | 9.4 : 1 | +| `text-faint` | `#6e6e73` | Tertiary / meta | 5.3 : 1 (AA only) | +| `accent` | `#166534` | Primary actions, brand | 8.2 : 1 | +| `accent-2` | `#15803d` | Hover state | 7.4 : 1 | +| `danger` | `#b91c1c` | Errors, destructive | 7.1 : 1 | +| `warning` | `#854d0e` | Warnings, low stock | 7.6 : 1 | +| `success` | `#166534` | Success, completed | 8.2 : 1 | +| `info` | `#1e40af` | Informational | 9.0 : 1 | + +`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI. + +### Spacing (8pt grid) + +`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64` + +### Touch targets (HIG Accessibility + gloves) + +- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves) +- Tab bar items: 60pt tall, icon 28pt + label 12pt +- List row primary action: full-row 72pt tap zone +- Bottom sheet drag handle: 56pt wide × 5pt tall +- FAB: 64pt diameter +- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable) + +### Motion + +- All motion respects `prefers-reduced-motion` +- Card list items stagger in (16ms delay each, max 240ms total) on first mount +- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out) +- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out) +- Pull-to-refresh: custom indicator showing last-synced time in mono +- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard) + +--- + +## 2. Architecture & data flow + +### Mobile admin shell + +``` +┌─────────────────────────────────┐ +│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky +├─────────────────────────────────┤ +│ │ +│ Page content │ ← scrollable, max 720pt content width +│ (card-stacked) │ +│ │ +├─────────────────────────────────┤ +│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed +│ [More] │ +└─────────────────────────────────┘ +``` + +- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More** +- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections. +- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right). +- **No desktop sidebar on mobile** — `AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged. +- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`. + +### New shared components + +| File | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------ | +| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint | +| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` | +| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `` + View Transitions | +| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row | +| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) | +| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages | +| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile | +| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens | +| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count | + +### Routing + +No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them. + +### Data flow + +| Page | Server source | Mobile client behavior | +| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- | +| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone | +| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time | +| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar | +| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action | +| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock | + +### Offline mutation queue + +- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId` +- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff +- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online +- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar` +- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count +- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges. +- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item. +- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id. + +### New RPC (for dashboard) + +```sql +CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result JSONB; +BEGIN + SELECT jsonb_build_object( + 'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE), + 'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')), + 'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')), + 'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE), + 'orders_last_7_days', ( + SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0))) + FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d + LEFT JOIN ( + SELECT created_at::date AS day, COUNT(*) AS cnt + FROM orders + WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days' + GROUP BY 1 + ) o ON o.day = d::date + ) + ) INTO result; + RETURN result; +END; +$$; +``` + +--- + +## 3. PWA, offline, and production hardening + +### PWA — what's broken and what we're fixing + +| Issue today | Fix | +| ------------------------------------------------------ | ------------------------------------------------------------------ | +| `public/icons/` doesn't exist | Generate all 8 sizes (72–512) PNG + maskable + badge + 3 shortcut icons | +| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) | +| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 | +| `favicon.ico` missing | Generate from SVG | +| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) | +| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` | +| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) | +| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) | +| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) | +| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` | +| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices | + +### Service worker rewrite + +Replace `public/sw.js` with a versioned, two-cache SW: + +- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`. +- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background. +- **Cache strategies:** + - Navigations → network-first, fall back to cache, fall back to `/offline.html` + - Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first + - API GETs (`/api/*` non-mutation) → stale-while-revalidate + - API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue +- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast. +- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`. + +### Production hardening + +- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*` +- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays +- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar +- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport +- No layout shift on font load: `next/font` reserves metric-based space + +### Files touched in this section + +- `public/manifest.json` (theme_color + new screenshot paths) +- `public/sw.js` (full rewrite) +- `public/offline.html` (new) +- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files) +- `public/screenshots/*.png` (2 new) +- `public/apple-touch-icon.png` + variants (3 new) +- `public/favicon.ico` (1 new) +- `src/app/layout.tsx` (add PWA meta + register SW via Providers) +- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount) +- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`) +- `next.config.ts` (cache headers for static assets) + +--- + +## 4. Page-by-page layouts + +All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`. + +### Dashboard (`/admin`) + +- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar) +- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono +- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA +- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable +- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking. + +### Orders list (`/admin/orders`) + +- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order" +- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar +- **Card list** (one per order): + - Top row: status pill (left) · order # in mono · time-ago (right) + - Middle: customer name (Manrope 700 19pt) + item count ("4 items") + - Bottom: total (Fraunces 22pt) · arrow chevron + - Whole row: 72pt tap zone +- **Empty state:** "No orders yet" + illustration + "+ New order" CTA +- **Pull-to-refresh** at the top + +### Order detail (`/admin/orders/[id]`) + +- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill +- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship) +- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono +- **Customer notes card** (if present): quoted, italic +- **StickyActionBar** (bottom, above tab bar): primary action changes by status: + - Placed → "Mark Ready" (forest-800 fill, full width, 56pt) + - Ready → "Mark Picked Up" (forest-800 fill) + - Picked up → "View Receipt" (outlined) +- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu +- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays + +### Stops (`/admin/stops`) + +- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop" +- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding) +- **Stop card:** + - Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count + - Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL) + - Inline status changer: tap status pill → bottom sheet with status options + - Whole row: 88pt to accommodate time + address comfortably +- **Empty state:** "No stops scheduled" + "+ New stop" + +### Products (`/admin/products`) + +- **Page header:** "Products" + search input (always visible, prominent) + "+ New product" +- **Card list:** + - Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt) + - Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone + - "Adjust stock" via long-press → bottom sheet with +/− stepper +- **Filter chips above list:** All · In stock · Low · Out · Hidden +- **Empty state:** "No products" + "+ New product" + +### Motion (per page) + +- Card list items stagger in (16ms delay each, max 240ms total) on first mount +- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out) +- Pull-to-refresh: custom indicator showing last-synced time +- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out) +- All motion respects `prefers-reduced-motion` + +### Aesthetic summary + +Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template. + +--- + +## 5. Testing & verification + +### Unit tests (Vitest) + +- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId` +- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing +- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday") +- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG AAA (7:1) on `surface` and `surface-2` backgrounds; fails CI if a new color regresses + +### Component tests (Vitest + Testing Library) + +- `MobileTabBar` — active state, more-sheet trigger, keyboard nav +- `StickyActionBar` — pending badge, conflict state +- `OfflineBanner` — appears on `offline` event, hidden on `online` +- `StatusPill` — every status × every color combination renders the right aria-label + +### E2E (Playwright) + +New spec: `tests/mobile-admin/pwa-install.spec.ts` +- Loads `/admin` on iPhone 13 viewport +- Asserts manifest link present, manifest is valid JSON, all icons return 200 +- Asserts `apple-touch-icon` link present +- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`) + +New spec: `tests/mobile-admin/orders-list.spec.ts` +- Loads `/admin/orders` on iPhone 13 viewport +- Asserts no horizontal scroll (page width ≤ viewport) +- Asserts every interactive element has touch target ≥ 48pt +- Asserts all text passes axe-core + +New spec: `tests/mobile-admin/offline-queue.spec.ts` +- Loads `/admin/orders/` on iPhone 13 viewport +- Goes offline (Playwright `context.setOffline(true)`) +- Clicks "Mark Ready" +- Asserts the optimistic UI updates +- Asserts the action bar shows "Pending sync" badge +- Goes back online +- Asserts the badge clears within 5s and the server has the new status + +### Visual regression (Playwright `toHaveScreenshot`) + +- All 4 key page screens at 390×844 (iPhone 13) +- All 4 key page screens at 1024×768 (iPad) +- MobileTabBar — light + dark +- OfflineBanner — visible, hidden + +### Manual QA checklist (added to PR template) + +- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness) +- [ ] Tap every interactive element while wearing work gloves +- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct +- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust +- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass + +### Lighthouse / PWA gate (CI) + +- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR +- PWA category must pass (installable, manifest valid, service worker registered, themed) +- Mobile accessibility score must be ≥ 95 + +### Documentation + +- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device +- `docs/admin-mobile.md` — design rationale + token reference for the team + +### Files touched in this section + +~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default. + +--- + +## File inventory (consolidated) + +### New files + +- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8) +- `public/icons/icon-maskable-512x512.png` (1) +- `public/icons/badge-72x72.png` (1) +- `public/icons/{orders,products,stops}-shortcut.png` (3) +- `public/screenshots/dashboard.png` (1) +- `public/screenshots/mobile-storefront.png` (1) +- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3) +- `public/favicon.ico` (1) +- `public/offline.html` (1) +- `src/components/admin/AdminShell.tsx` +- `src/components/admin/MobileTabBar.tsx` +- `src/components/admin/MoreSheet.tsx` +- `src/components/admin/PageHeader.tsx` +- `src/components/admin/StatusPill.tsx` +- `src/components/admin/EmptyState.tsx` +- `src/components/admin/CardList.tsx` +- `src/components/admin/StickyActionBar.tsx` +- `src/components/admin/OfflineBanner.tsx` +- `src/lib/offline/queue.ts` +- `src/lib/offline/sync.ts` +- `src/lib/offline/db.ts` (IndexedDB wrapper) +- `src/lib/__tests__/design-tokens.test.ts` +- `src/lib/offline/queue.test.ts` +- `src/lib/offline/sync.test.ts` +- `tests/mobile-admin/pwa-install.spec.ts` +- `tests/mobile-admin/orders-list.spec.ts` +- `tests/mobile-admin/offline-queue.spec.ts` +- `.gitea/workflows/pwa-audit.yml` +- `docs/pwa-testing.md` +- `docs/admin-mobile.md` +- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard) + +### Modified files + +- `public/manifest.json` (theme_color, screenshot paths) +- `public/sw.js` (full rewrite) +- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor) +- `src/components/Providers.tsx` (call `registerServiceWorker()`) +- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`) +- `src/app/admin/page.tsx` (Dashboard card layout) +- `src/app/admin/orders/page.tsx` (CardList) +- `src/app/admin/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration) +- `src/app/admin/stops/page.tsx` (time-grouped card list) +- `src/app/admin/products/page.tsx` (image-forward card list) +- `src/app/globals.css` (new type scale, color tokens, spacing) +- `src/styles/admin-design-system.css` (admin-specific tokens) +- `next.config.ts` (cache headers for static assets) +- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport) +- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging) +- `src/app/page.tsx` (favicon ref fix) + +--- + +## Open questions (deferred) + +- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec. +- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped. +- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec. +- **Image upload from camera** — products/stops could use this; separate spec. + +--- + +## Risks + +1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it. +2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us. +3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI. +4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off. + +--- + +## Rollout plan + +1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added. +2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass. +3. **PR 3 — Orders (list + detail)** — first page on the new design language. Validates the components in real use. +4. **PR 4 — Stops** +5. **PR 5 — Products** +6. **PR 6 — Dashboard** (depends on the new `get_dashboard_summary` RPC migration) + +Each PR is independently shippable behind a feature flag (`ADMIN_MOBILE_V2`) so we can dogfood it internally before cutting over. The desktop layout is never affected until the flag is on for everyone. + +--- + +## Definition of done + +- [ ] All 4 key pages ship the mobile-first treatment +- [ ] PWA installs on iOS Safari and Android Chrome +- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation +- [ ] Offline mutation queue replays actions on reconnect with no data loss +- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile +- [ ] All text passes WCAG AAA (7:1) against its background +- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes +- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport +- [ ] `prefers-reduced-motion` honored +- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI +- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports +- [ ] Real-device install + offline testing documented and signed off