Files
route-commerce/docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md
T
2026-06-17 13:13:06 -06:00

517 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 4044pt; 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 `<dialog>` + 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 (72512) 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/<id>` 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