Files
pn-new-crm/docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Matt Ciaccio fbb1f1f366 scaffold(mobile): branch setup — audit harness, spec, plan, gitignore + client-portal cleanup
Pre-execution baseline for the mobile foundation PR:

- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:38 +02:00

16 KiB
Raw Permalink Blame History

Mobile Optimization Design

Status: Design approved 2026-04-29 — pending plan. Plan decomposition: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase. Branch base: stacks on refactor/data-model. Out of scope: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.


1. Background

The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:

  1. No viewport meta in the root layout (one exists only in the scanner PWA sub-layout, src/app/(scanner)/[portSlug]/scan/layout.tsx). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's isMobile emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
  2. Topbar overflows. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
  3. Tables render as desktop tables. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
  4. Page headers don't downsize. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (+ New Client) overlap their subtitles.
  5. Detail page action chips overflow. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
  6. One half-good pattern: detail pages already collapse their tabs to a <select> dropdown on small screens. Worth extending.
  7. Auth + scanner pages are already mobile-first (/login, /[portSlug]/scan). Reference for the "what good looks like" target.

The audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) is added on this branch (not yet committed); re-runs regenerate .audit/mobile/ (gitignored).

2. Approach

Adaptive shell + responsive content — chosen over (a) per-page conditional render, (b) a separate (mobile) route group, and (c) Tailwind-only responsive.

The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.

Breakpoint: Tailwind lg (1024px). Below lg, the mobile shell renders. At and above, the existing desktop shell is untouched.

2.1 Target iPhone viewport range

The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):

Tier Models Viewport
Narrowest iPhone SE 2nd / 3rd gen 375×667
Standard iPhone 12/13/14 (and Mini) 390×844
Standard newer iPhone 15 / 15 Pro / 16 393×852
Pro newer (Dynamic Island, thinner bezels) iPhone 16 Pro / 17 Pro 402×874
Plus / older Max iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus 430×932
Pro Max iPhone 16 Pro Max / 17 Pro Max 440×956

Anchors used by audit and design validation: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.

Dynamic Island: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS env(safe-area-inset-top) we expose as pt-safe handles this transparently — no per-model code paths.

Landscape: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under lg and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.

Routing: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.

3. Foundation PR

A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.

3.1 Infrastructure

  • viewport export in src/app/layout.tsxwidth=device-width, initial-scale=1, viewport-fit=cover.
  • theme-color meta + apple-mobile-web-app-capable meta + apple-mobile-web-app-status-bar-style for PWA-ish status-bar integration.
  • Safe-area CSS variables (env(safe-area-inset-*)) exposed as Tailwind utilities (pt-safe, pb-safe, pl-safe, pr-safe).
  • useIsMobile() hook in src/hooks/use-is-mobile.ts — backed by window.matchMedia('(max-width: 1023.98px)'), no resize listener.
  • Server-side body-class detection: the root layout (src/app/layout.tsx) reads the user-agent request header via next/headers's headers(), runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders <body data-form-factor="mobile|desktop">. No middleware needed. CSS [data-form-factor="mobile"] reveals the mobile shell. The CSS media-query fallback (@media (max-width: 1023.98px)) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).

3.2 Mobile shell

Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on [data-form-factor="mobile"] plus a @media (max-width: 1023.98px) fallback. The existing <Sidebar> and <Topbar> components stay unchanged for the desktop shell. The mobile shell is wholly new:

  • <MobileLayout> (src/components/layout/mobile/mobile-layout.tsx) Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.

  • <MobileTopbar> Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a <Sheet> trigger.

  • <MobileBottomTabs> Fixed 5 tabs: Dashboard / Clients / Yachts / Berths / More. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.

  • <MoreSheet> Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).

  • <MobileLayoutProvider> React context that lets each page push its title, back button, and primary action slot to <MobileTopbar> via a hook (useMobileChrome({ title, action })).

3.3 Primitives

All built once in src/components/shared/. Render desktop-style above lg, mobile-style below.

  • <Sheet> — vaul-based bottom sheet on mobile, falls through to existing Radix <Dialog> on desktop. Same API as <Dialog> so adoption is mechanical.
  • <DataView> — accepts the same column defs the codebase uses today via TanStack Table. Above lg: renders the existing table. Below lg: renders a card list with a per-row cardRender({ row }) => ReactNode callback. Filter chips stay above the list; sort moves into a <Sheet> opened by a sort button.
  • <PageHeader> — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below sm if action row is present.
  • <ActionRow> — chip-style action group; flex-nowrap overflow-x-auto scroll-smooth snap-x on mobile, no overflow on desktop.
  • <DetailPageShell> — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
  • <FilterChips> — chip-row filter UI used by <DataView>. Active filters are dismissable chips; "Add filter" opens a <Sheet>.

3.4 Default style adjustments

  • <Button> and <Input> defaults: min-h-11 (44px, Apple HIG touch-target).
  • <Input> and <Textarea> body text: text-base (16px) so iOS doesn't zoom on focus.
  • <Dialog> default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to <Sheet>).

3.5 Bundle impact

Both shells render server-side and switch via the data-form-factor body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with pnpm build and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.

3.6 PWA assets

The PWA scanner already references icon-192.png, icon-512.png, icon-512-maskable.png from public/, but those files don't exist yet (separate flagged blocker). The mobile shell adds an apple-touch-icon reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.

4. Per-page playbook

Once foundation lands, each page follows the same workflow:

  1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
  2. Replace any <Dialog> with <Sheet>.
  3. If list page: wrap the table in <DataView> and provide a cardRender callback. The 2-3 fields shown on the card are decided per page during migration with the user.
  4. Replace the ad-hoc page header with <PageHeader>.
  5. Replace ad-hoc action button rows with <ActionRow>.
  6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for email, documents, expenses/scan).
  7. User reviews live in the headed browser, points out tweaks, iterate.

Most pages take 515 minutes in this loop. Heavy pages (email inbox, documents hub) may take 3060 because the embedded widgets need their own mobile treatment beyond the primitives.

5. Migration sequence

After foundation PR:

  1. Quick-win sweep (~half day) — pages mostly fixed by foundation alone. Just need <PageHeader> swap-in (no list-card conversion, no detail-shell wrap): dashboard (overview), settings (user-profile), reports, and the admin sub-pages that are forms or stat cards: admin/settings, admin/branding, admin/forms, admin/ocr, admin/roles, admin/tags, admin/documenso, admin/templates, admin/custom-fields, admin/monitoring, admin/backup, admin/webhooks, admin/import, admin/ports.
  2. List pages (~12 days) — convert via <DataView> + per-page cardRender: clients, yachts, companies, berths, interests, invoices, expenses, alerts, reminders, admin/audit, admin/users.
  3. Heavy pages (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives: documents (sig-tracking + filters from Phase A), email (thread list + reader + composer).
  4. Detail pages (~12 days) — wrap in <DetailPageShell>, extend the tab-dropdown pattern, add sticky bottom action shelf: clients/[clientId], yachts/[yachtId], companies/[companyId], berths/[berthId], invoices/[id], expenses/[id].
  5. Forms & wizards — touch-up only, since <Input>/<Button> defaults handle the bulk: invoices/new (3-step wizard), expenses/scan (already mobile-first, just verify).
  6. Portal — same patterns, smaller scope: authenticated: portal/dashboard, portal/invoices, portal/my-yachts, portal/documents, portal/interests, portal/my-reservations. Public: portal/login, portal/activate, portal/forgot-password, portal/reset-password (already styled by <BrandedAuthShell> — just verify).
  7. Tablet pass — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above lg and use the desktop shell unchanged.

6. Testing

  • Mobile audit project (mobile-audit in playwright.config.ts) is the regression harness. Re-runs after every page-migration PR; output goes to .audit/mobile/ (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
  • Smoke project gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
  • Visual baselinesvisual project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with --update-snapshots after intentional changes (existing convention).
  • Anchor device descriptors lifted into a shared fixture at tests/e2e/fixtures/devices.ts (one per anchor in §2.1) so specs don't redefine viewport.
  • No new unit tests for the primitives — they are presentational. Coverage comes from visual + integration runs.

7. Open questions

  • Bottom-tab taxonomy: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
  • refactor/data-model push order: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
  • Desktop touch-target adjustments: bumping <Button>/<Input> to min-h-11 will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the data-form-factor attribute.

8. Files to create

src/hooks/use-is-mobile.ts
src/components/layout/mobile/
  mobile-layout.tsx
  mobile-topbar.tsx
  mobile-bottom-tabs.tsx
  more-sheet.tsx
  mobile-layout-provider.tsx
src/components/shared/
  sheet.tsx                    (new — vaul wrapper)
  data-view.tsx                (new — table↔card)
  page-header.tsx              (new)
  action-row.tsx               (new)
  detail-page-shell.tsx        (new)
  filter-chips.tsx             (new)
src/app/layout.tsx             (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
public/icon-192.png            (placeholder PWA asset)
public/icon-512.png            (placeholder PWA asset)
public/icon-512-maskable.png   (placeholder PWA asset)
public/apple-touch-icon.png    (placeholder PWA asset)
tailwind.config.ts             (modified — safe-area utilities, touch-target defaults)
tests/e2e/fixtures/devices.ts  (new — shared device descriptors)

9. Files to modify per page

Per the playbook in §4, each page typically needs:

  • One swap of header markup → <PageHeader>.
  • For list pages: one wrap of table → <DataView> + add cardRender callback.
  • For detail pages: wrap in <DetailPageShell>.
  • Replace <Dialog> imports with <Sheet>.
  • No service, validator, query, or schema changes anywhere.