182ccf2c72
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).
539 lines
34 KiB
Markdown
539 lines
34 KiB
Markdown
# 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
|