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

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

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

34 KiB
Raw Permalink Blame History

Admin Mobile PWA — Design Spec

  • Date: 2026-06-17
  • Status: Draft, awaiting user review
  • Author: Grok
  • Branch: main
  • Scope: Admin dashboard only (/admin/*), with full PWA + offline support

Context

Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but not mobile-first, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.

Symptoms today:

  • public/icons/ and public/screenshots/ directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
  • registerServiceWorker() is exported but never called
  • PWAInstallPrompt is never mounted in the tree
  • apple-touch-icon.png and favicon.ico are referenced but missing
  • Service worker references /offline and /og-default.jpg that don't exist
  • themeColor in viewport (#0a0a0a) mismatches manifest (#1a4d2e)
  • AdminSidebar uses lg:pl-60 — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
  • Body text is 16pt; tap targets are 4044pt; status colors don't all clear WCAG AA on the warm cream background
  • No offline mutation queue — losing signal mid-fulfillment is a real operational problem

Goals

  1. Ship a real, installable PWA — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
  2. Mobile-first admin shell — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
  3. HIG Accessibility-tier legibility — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
  4. Read + queue mutations offline — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
  5. Production-optimized — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.

Non-Goals

  • Redesigning marketing pages (landing, pricing, blog). They stay as-is.
  • Redesigning the public storefronts. They stay as-is.
  • Push notifications / Web Push. The SW has a push handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
  • Wholesale portal mobile redesign. Tabled for a future spec.
  • Real-time collaboration (websockets / live cursors). Out of scope.
  • CRDT-style conflict resolution. We use last-write-wins keyed on updated_at.

Surfaces in scope

  • /admin (Dashboard)
  • /admin/orders + /admin/orders/[id]
  • /admin/stops
  • /admin/products
  • The admin shell that wraps all of the above
  • PWA install, splash, offline page, and SW behavior across the whole app

1. Aesthetic direction & design system

Direction: "HIG-First Field Almanac"

Apple HIG Accessibility-tier legibility is the floor; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in globals.css) pushed harder is the ceiling. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.

Type scale (HIG Accessibility-tier)

Token Size / Weight Use
text-display 32pt / Fraunces 600 Page titles (Dashboard, Orders, etc.)
text-h1 24pt / Fraunces 600 Section headers
text-h2 19pt / Manrope 700 Card titles, list primary
text-body 18pt / Manrope 500 All body text
text-label 15pt / Manrope 600 / +0.02em Button text, field labels, status pills
text-mono 14pt / Fragment Mono 400 Order IDs, totals, tracking numbers
text-meta 13pt / Manrope 600 Timestamps, helper text

Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.

Color tokens (AAA contrast where text appears)

Token Value Use Contrast on bg / surface
bg #ffffff App background
surface #faf8f5 Page background (warm cream)
surface-2 #f5f5f7 Cards, raised surfaces
surface-3 #e8e8ed Hover / pressed / skeletons
text #1d1d1f Primary text AAA on all 4 surfaces
text-muted #424245 Secondary text AAA on all 4 surfaces
text-faint #5e5e63 Tertiary / meta AA on all 4 surfaces (≥ 5.28 : 1)
accent #14532d Primary actions, brand AAA on bg (9.11), surface (8.59), surface-2 (8.37), surface-3 (7.46)
accent-2 #166534 Hover state (button background) White text on accent-2 = 7.13 : 1 (AAA)
danger #7f1d1d Errors, destructive AAA on bg (10.02), surface (9.45), surface-2 (9.20), surface-3 (8.20)
warning #5e2a04 Warnings, low stock AAA on bg (11.61), surface (10.95), surface-2 (10.66), surface-3 (9.51)
success #14532d Success, completed Mirrors accent
info #1e3a8a Informational AAA on all 4 surfaces (8.4810.36)

Status text on its -soft pill background is also AAA — the dark status text (e.g. #14532d) sits on a light tinted fill (e.g. #dcfce7) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by src/lib/__tests__/design-tokens.test.ts.

text-faint is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.

Spacing (8pt grid)

4 / 8 / 12 / 16 / 24 / 32 / 48 / 64

Touch targets (HIG Accessibility + gloves)

  • All interactive elements: 56pt min height (HIG default 44pt → 56pt for gloves)
  • Tab bar items: 60pt tall, icon 28pt + label 12pt
  • List row primary action: full-row 72pt tap zone
  • Bottom sheet drag handle: 56pt wide × 5pt tall
  • FAB: 64pt diameter
  • Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)

Motion

  • All motion respects prefers-reduced-motion
  • Card list items stagger in (16ms delay each, max 240ms total) on first mount
  • Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
  • StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
  • Pull-to-refresh: custom indicator showing last-synced time in mono
  • Default easing: cubic-bezier(0.32, 0.72, 0, 1) (Apple's standard)

2. Architecture & data flow

Mobile admin shell

┌─────────────────────────────────┐
│ TopBar: [brand▼] [🔍] [👤]      │  ← 56pt, sticky
├─────────────────────────────────┤
│                                 │
│  Page content                   │  ← scrollable, max 720pt content width
│  (card-stacked)                 │
│                                 │
├─────────────────────────────────┤
│ [Home][Orders][Stops][Products] │  ← bottom tab bar, 60pt, fixed
│                  [More]         │
└─────────────────────────────────┘
  • Bottom tab bar (always visible, thumb zone): Dashboard · Orders · Stops · Products · More
  • "More" sheet (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
  • TopBar (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
  • No desktop sidebar on mobileAdminSidebar 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)

CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  result JSONB;
BEGIN
  SELECT jsonb_build_object(
    'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE),
    'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')),
    'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')),
    'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE),
    'orders_last_7_days', (
      SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)))
      FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
      LEFT JOIN (
        SELECT created_at::date AS day, COUNT(*) AS cnt
        FROM orders
        WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days'
        GROUP BY 1
      ) o ON o.day = d::date
    )
  ) INTO result;
  RETURN result;
END;
$$;

3. PWA, offline, and production hardening

PWA — what's broken and what we're fixing

Issue today Fix
public/icons/ doesn't exist Generate all 8 sizes (72512) PNG + maskable + badge + 3 shortcut icons
public/screenshots/ doesn't exist Generate dashboard.png (1280×720) and mobile-storefront.png (390×844)
apple-touch-icon.png missing Generate 180×180 + 167×167 + 152×152
favicon.ico missing Generate from SVG
sw.js references /offline page that doesn't exist Create public/offline.html (clean cream HIG-style page)
sw.js references /og-default.jpg (only .svg exists) Update ref to /og-default.svg
registerServiceWorker() exported but never called Call it from src/components/Providers.tsx (client side)
PWAInstallPrompt never mounted Mount in src/app/admin/layout.tsx (admin is the installable surface)
themeColor mismatch (#0a0a0a vs #1a4d2e) Standardize on #166534 (forest-800)
Missing PWA meta tags Add apple-mobile-web-app-capable, application-name, mobile-web-app-capable, apple-mobile-web-app-status-bar-style
viewport.maximumScale = 5 but no viewportFit: cover Add viewportFit: 'cover' for notched devices

Service worker rewrite

Replace public/sw.js with a versioned, two-cache SW:

  • App shell cache (e.g. rc-shell-v3): pre-caches /, /admin, /manifest.json, fonts, icons, offline page — install-time cache.addAll(…).
  • Data cache (e.g. rc-data-v3): stale-while-revalidate for /api/orders, /api/stops, /api/products, /api/dashboard — read offline, refetch in background.
  • Cache strategies:
    • Navigations → network-first, fall back to cache, fall back to /offline.html
    • Static assets (/_next/static/*, /icons/*, /screenshots/*) → cache-first
    • API GETs (/api/* non-mutation) → stale-while-revalidate
    • API mutations (POST/PATCH/DELETE) → never cached, pass-through; offline behavior handled by the client queue
  • Versioning: CACHE_NAME bumped on each release; activate handler deletes old caches; skipWaiting() + clients.claim() to roll out updates fast.
  • Logging: all console.log calls guarded by process.env.NODE_ENV !== 'production'.

Production hardening

  • next.config.ts — add headers() for Cache-Control: public, max-age=31536000, immutable on /_next/static/* and /icons/*
  • Font preloading: next/font already inlines the CSS — verify the display: swap strategy stays
  • Image optimization: existing next/image config; add priority to the admin logo in the top bar
  • Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
  • No layout shift on font load: next/font reserves metric-based space

Files touched in this section

  • public/manifest.json (theme_color + new screenshot paths)
  • public/sw.js (full rewrite)
  • public/offline.html (new)
  • public/icons/*.png (8 + maskable + badge + 3 shortcuts = 13 new files)
  • public/screenshots/*.png (2 new)
  • public/apple-touch-icon.png + variants (3 new)
  • public/favicon.ico (1 new)
  • src/app/layout.tsx (add PWA meta + register SW via Providers)
  • src/components/Providers.tsx (call registerServiceWorker() on mount)
  • src/app/admin/layout.tsx (mount PWAInstallPrompt)
  • next.config.ts (cache headers for static assets)

4. Page-by-page layouts

All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above lg.

Dashboard (/admin)

  • Page header: "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
  • Stat row (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is surface-2 with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
  • "Needs you" card (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
  • Today's stops timeline: vertical timeline of upcoming stops, each row tappable
  • Aesthetic: calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.

Orders list (/admin/orders)

  • Page header: "Orders" + filter button (opens bottom sheet) + "+ New order"
  • Filter sheet: status, date range, customer, stop — drag-up sheet, sticky apply bar
  • Card list (one per order):
    • Top row: status pill (left) · order # in mono · time-ago (right)
    • Middle: customer name (Manrope 700 19pt) + item count ("4 items")
    • Bottom: total (Fraunces 22pt) · arrow chevron
    • Whole row: 72pt tap zone
  • Empty state: "No orders yet" + illustration + "+ New order" CTA
  • Pull-to-refresh at the top

Order detail (/admin/orders/[id])

  • Header card: customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
  • Line items card: each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
  • Fulfillment timeline card: horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
  • Customer notes card (if present): quoted, italic
  • StickyActionBar (bottom, above tab bar): primary action changes by status:
    • Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
    • Ready → "Mark Picked Up" (forest-800 fill)
    • Picked up → "View Receipt" (outlined)
  • Secondary actions (refund, edit, contact customer) live in a top-right "•••" menu
  • Offline pending badge: if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays

Stops (/admin/stops)

  • Page header: "Stops" + date picker (large, tappable) + "+ New stop"
  • Time-grouped sections: "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
  • Stop card:
    • Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
    • Status pill + "Navigate" icon button (opens maps app via navigator.share fallback to URL)
    • Inline status changer: tap status pill → bottom sheet with status options
    • Whole row: 88pt to accommodate time + address comfortably
  • Empty state: "No stops scheduled" + "+ New stop"

Products (/admin/products)

  • Page header: "Products" + search input (always visible, prominent) + "+ New product"
  • Card list:
    • Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
    • Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
    • "Adjust stock" via long-press → bottom sheet with +/ stepper
  • Filter chips above list: All · In stock · Low · Out · Hidden
  • Empty state: "No products" + "+ New product"

Motion (per page)

  • Card list items stagger in (16ms delay each, max 240ms total) on first mount
  • Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
  • Pull-to-refresh: custom indicator showing last-synced time
  • StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
  • All motion respects prefers-reduced-motion

Aesthetic summary

Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.


5. Testing & verification

Unit tests (Vitest)

  • src/lib/offline/queue.test.ts — enqueue, dequeue, persist across page reload, idempotency on clientActionId
  • src/lib/offline/sync.test.ts — replays on online event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
  • src/lib/format-date.test.ts — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
  • src/lib/__tests__/design-tokens.test.ts — asserts all token combinations meet WCAG contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on bg + surface + their -soft pill backgrounds at AAA; text-faint on all 4 surfaces at AA; accent-2 as a button background with white text at AAA); fails CI if a new color regresses

Component tests (Vitest + Testing Library)

  • MobileTabBar — active state, more-sheet trigger, keyboard nav
  • StickyActionBar — pending badge, conflict state
  • OfflineBanner — appears on offline event, hidden on online
  • StatusPill — every status × every color combination renders the right aria-label
  • MoreSheet — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close

UI primitives: No dialog/sheet library (vaul, radix, headlessui) is currently in the project. MoreSheet and the various bottom sheets (filter, status changer, stock stepper) use the native <dialog> element with showModal() + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native <dialog> is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.

Pull-to-refresh: Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects overscroll-behavior: contain, and falls back to no-op if the user has indicated motion sensitivity.

Tap-to-call / tap-to-email: Implemented with explicit tel: and mailto: URL schemes on <a> tags (not buttons that call window.location). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.

Color contrast test exclusions: text-faint is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in src/lib/__tests__/design-tokens.test.ts will assert AAA (7:1) for every token combination except text-faint, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.

E2E (Playwright)

New spec: tests/mobile-admin/pwa-install.spec.ts

  • Loads /admin on iPhone 13 viewport
  • Asserts manifest link present, manifest is valid JSON, all icons return 200
  • Asserts apple-touch-icon link present
  • Asserts service worker registers successfully (via navigator.serviceWorker.ready)

New spec: tests/mobile-admin/orders-list.spec.ts

  • Loads /admin/orders on iPhone 13 viewport
  • Asserts no horizontal scroll (page width ≤ viewport)
  • Asserts every interactive element has touch target ≥ 48pt
  • Asserts all text passes axe-core

New spec: tests/mobile-admin/offline-queue.spec.ts

  • Loads /admin/orders/<id> on iPhone 13 viewport
  • Goes offline (Playwright context.setOffline(true))
  • Clicks "Mark Ready"
  • Asserts the optimistic UI updates
  • Asserts the action bar shows "Pending sync" badge
  • Goes back online
  • Asserts the badge clears within 5s and the server has the new status

Visual regression (Playwright toHaveScreenshot)

  • All 4 key page screens at 390×844 (iPhone 13)
  • All 4 key page screens at 1024×768 (iPad)
  • MobileTabBar — light + dark
  • OfflineBanner — visible, hidden

Manual QA checklist (added to PR template)

  • Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
  • Tap every interactive element while wearing work gloves
  • Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
  • Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
  • Run npx lighthouse http://localhost:3000/admin --preset=mobile --view and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass

Lighthouse / PWA gate (CI)

  • Add a CI job (.gitea/workflows/pwa-audit.yml) that runs lighthouse-ci against the preview URL on every PR
  • PWA category must pass (installable, manifest valid, service worker registered, themed)
  • Mobile accessibility score must be ≥ 95

Documentation

  • docs/pwa-testing.md — how to manually test PWA + offline on a real device
  • docs/admin-mobile.md — design rationale + token reference for the team

Files touched in this section

~8 new test files, 1 CI workflow, 2 docs, plus assertions added to playwright.config.ts to include the mobile-admin project at iPhone 13 viewport by default.


File inventory (consolidated)

New files

  • public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png (8)
  • public/icons/icon-maskable-512x512.png (1)
  • public/icons/badge-72x72.png (1)
  • public/icons/{orders,products,stops}-shortcut.png (3)
  • public/screenshots/dashboard.png (1)
  • public/screenshots/mobile-storefront.png (1)
  • public/apple-touch-icon.png (180×180), public/apple-touch-icon-167.png, public/apple-touch-icon-152.png (3)
  • public/favicon.ico (1)
  • public/offline.html (1)
  • src/components/admin/AdminShell.tsx
  • src/components/admin/MobileTabBar.tsx
  • src/components/admin/MoreSheet.tsx
  • src/components/admin/PageHeader.tsx
  • src/components/admin/StatusPill.tsx
  • src/components/admin/EmptyState.tsx
  • src/components/admin/CardList.tsx
  • src/components/admin/StickyActionBar.tsx
  • src/components/admin/OfflineBanner.tsx
  • src/lib/offline/queue.ts
  • src/lib/offline/sync.ts
  • src/lib/offline/db.ts (IndexedDB wrapper)
  • src/lib/__tests__/design-tokens.test.ts
  • src/lib/offline/queue.test.ts
  • src/lib/offline/sync.test.ts
  • tests/mobile-admin/pwa-install.spec.ts
  • tests/mobile-admin/orders-list.spec.ts
  • tests/mobile-admin/offline-queue.spec.ts
  • .gitea/workflows/pwa-audit.yml
  • docs/pwa-testing.md
  • docs/admin-mobile.md
  • db/migrations/NNN_dashboard_summary_rpc.sql (new RPC for dashboard)

Modified files

  • public/manifest.json (theme_color, screenshot paths)
  • public/sw.js (full rewrite)
  • src/app/layout.tsx (PWA meta tags, viewport.fit, themeColor)
  • src/components/Providers.tsx (call registerServiceWorker())
  • src/app/admin/layout.tsx (mount PWAInstallPrompt + OfflineBanner, swap sidebar for AdminShell)
  • src/app/admin/v2/page.tsx (Dashboard card layout — new v2 route)
  • src/app/admin/v2/orders/page.tsx (CardList — new v2 route)
  • src/app/admin/v2/orders/[id]/page.tsx (card layout + StickyActionBar + offline-queue integration — new v2 route)
  • src/app/admin/v2/stops/page.tsx (time-grouped card list — new v2 route)
  • src/app/admin/v2/products/page.tsx (image-forward card list — new v2 route)
  • next.config.ts (cache headers for static assets; cutover redirects in PRs 6 and 8)
  • playwright.config.ts (add mobile-admin project at iPhone 13 viewport)
  • src/lib/pwa.ts (small fixes: load listener guard, dev-only logging)
  • src/app/page.tsx (favicon ref fix)

Note: the v1 admin pages (src/app/admin/page.tsx, src/app/admin/orders/page.tsx, etc.) are not modified by PRs 15. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.


Open questions (deferred)

  • Web Push notifications — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
  • Wholesale portal mobile — out of scope here; would benefit from the same design system once the admin is shipped.
  • App Store / Play Store packaging — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
  • Image upload from camera — products/stops could use this; separate spec.

Risks

  1. The existing admin pages have a lot of bespoke UI (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
  2. The IndexedDB queue + idempotency requires server-side support (clientActionId parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
  3. iOS PWA limitations — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual beforeinstallprompt only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
  4. Outdoor screen testing is subjective. The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.

Rollout plan

Note on feature flags: The project has a src/lib/feature-flags.ts system, but it's for brand-scoped add-on features (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a route-based dual-deployment strategy for rollout:

  • PRs 12 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
  • PRs 35 (Orders, Stops, Products) ship the new mobile-first versions at /admin/v2/orders, /admin/v2/stops, /admin/v2/products, and /admin/v2/orders/[id] initially. The old /admin/orders etc. routes continue to work unchanged. Internal team dogfoods at /admin/v2/* for 12 weeks.
  • PR 6 cuts over: a redirect in next.config.ts sends /admin/orders*/admin/v2/orders*, etc. The old routes are removed in a follow-up commit.
  • PR 7 (Dashboard at /admin/v2) follows the same pattern.

This avoids the need to introduce a feature flag system, which would be a separate piece of work.

  1. PR 1 — Design system + admin shell — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
  2. PR 2 — PWA manifest + SW + icons + offline page — PWA installable, splash, themed status bar. All tests pass.
  3. PR 3 — Orders v2 (list + detail) at /admin/v2/orders/* — first page on the new design language. Validates the components in real use.
  4. PR 4 — Stops v2 at /admin/v2/stops
  5. PR 5 — Products v2 at /admin/v2/products
  6. PR 6 — Cutover Orders + Stops + Products — redirects in next.config.ts; old routes removed in a follow-up commit
  7. PR 7 — Dashboard v2 at /admin/v2 (depends on the new get_dashboard_summary RPC migration)
  8. PR 8 — Dashboard cutover — redirect /admin/admin/v2; remove old dashboard

Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.


Definition of done

  • All 4 key pages ship the mobile-first treatment
  • PWA installs on iOS Safari and Android Chrome
  • Service worker pre-caches the app shell, swr-caches the data, falls back to /offline on navigation
  • Offline mutation queue replays actions on reconnect with no data loss
  • All interactive elements ≥ 48pt (aim 56pt) on mobile
  • All text passes WCAG AAA (7:1) against its background
  • Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
  • No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
  • prefers-reduced-motion honored
  • All Playwright specs (PWA install, orders list, offline queue) pass in CI
  • Visual regression baselines committed for all 4 key pages × 2 viewports
  • Real-device install + offline testing documented and signed off