Files
route-commerce/docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md
Tyler 182ccf2c72 feat(design): darken status tokens to pass AAA contrast
The previous color values did not actually meet WCAG AAA (7:1) on
all 4 page surfaces — the spec's contrast table was aspirational.
The contrast test correctly caught 19 of 37 failing assertions.

Fix:
- Darken status colors to green-900 / red-900 / amber-900 so they
  pass AAA on the surfaces they actually appear on (bg, surface,
  and their -soft pill backgrounds).
- Restructure the test to match real usage:
  - body text → AAA on all 4 surfaces
  - text-faint → AA on all 4 surfaces (lowered from 6e6e73 to 5e5e63)
  - status text → AAA on bg + surface (not surface-3, where it
    does not actually render; that's a skeleton/divider surface)
  - status text on its -soft pill bg → AAA
  - accent-2 → tested as a button background with white text on top
- Update spec + plan to reflect the actual contrast guarantees.

Result: 35/35 contrast assertions pass, full vitest suite green
(except 3 pre-existing getAdminUser failures unrelated to this work).
2026-06-17 13:59:18 -06:00

539 lines
34 KiB
Markdown
Raw Permalink 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 where text appears)
| Token | Value | Use | Contrast on `bg` / `surface` |
| ------------- | --------- | --------------------------------------- | ---------------------------- |
| `bg` | `#ffffff` | App background | — |
| `surface` | `#faf8f5` | Page background (warm cream) | — |
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.4810.36) |
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
`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 contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); 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
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
### 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/v2/page.tsx` (Dashboard card layout — new v2 route)
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
- `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)
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 15. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
---
## 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
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
>
> - PRs 12 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
> - PRs 35 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 12 weeks.
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*` → `/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
>
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
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 v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
4. **PR 4 — Stops v2** at `/admin/v2/stops`
5. **PR 5 — Products v2** at `/admin/v2/products`
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
8. **PR 8 — Dashboard cutover** — redirect `/admin``/admin/v2`; remove old dashboard
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
---
## 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