# 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 `` + 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 `` 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 `` 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 `` 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/` 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