# 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 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.48–10.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 (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 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 1–5. 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 1–2 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
> - PRs 3–5 (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 1–2 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
