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>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,3 +27,9 @@ eoi/
|
|||||||
|
|
||||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||||
/*.png
|
/*.png
|
||||||
|
|
||||||
|
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||||
|
/client-portal/
|
||||||
|
|
||||||
|
# Mobile audit screenshots — generated locally, regenerable
|
||||||
|
/.audit/
|
||||||
|
|||||||
Submodule client-portal deleted from 84f89f9409
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# 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.tsx` — `width=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 5–15 minutes in this loop. Heavy pages (email inbox, documents hub) may take 30–60 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** (~1–2 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** (~1–2 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 baselines** — `visual` 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.
|
||||||
@@ -75,6 +75,22 @@ export default defineConfig({
|
|||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||||
|
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||||
|
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||||
|
name: 'mobile-audit',
|
||||||
|
testMatch: /audit\/mobile\.spec\.ts/,
|
||||||
|
dependencies: ['setup'],
|
||||||
|
timeout: 600_000,
|
||||||
|
use: {
|
||||||
|
headless: false,
|
||||||
|
launchOptions: { slowMo: 200 },
|
||||||
|
screenshot: 'off',
|
||||||
|
video: 'off',
|
||||||
|
trace: 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Don't start the dev server — we expect it to already be running
|
// Don't start the dev server — we expect it to already be running
|
||||||
|
|||||||
380
tests/e2e/audit/mobile.spec.ts
Normal file
380
tests/e2e/audit/mobile.spec.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* Mobile Audit Spec
|
||||||
|
* -----------------
|
||||||
|
* Visits every user-facing page in the app at iPhone 14 Pro and iPhone SE
|
||||||
|
* viewports (portrait), takes full-page screenshots to .audit/mobile/*, and
|
||||||
|
* writes an index.md grouped by route area.
|
||||||
|
*
|
||||||
|
* Self-seeds the super-admin via better-auth's REST sign-up endpoint so it
|
||||||
|
* does NOT depend on the docker-based smoke setup.
|
||||||
|
*
|
||||||
|
* Run: pnpm exec playwright test --project=mobile-audit
|
||||||
|
*/
|
||||||
|
import { test, type Page, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const PORT_SLUG = 'port-nimara';
|
||||||
|
const ADMIN = {
|
||||||
|
email: 'admin@portnimara.test',
|
||||||
|
password: 'SuperAdmin12345!',
|
||||||
|
name: 'Test Admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUT_ROOT = path.resolve(process.cwd(), '.audit/mobile');
|
||||||
|
|
||||||
|
// Anchor viewports covering the active iPhone range (portrait CSS pixels).
|
||||||
|
// See docs/superpowers/specs/2026-04-29-mobile-optimization-design.md §2.1.
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: 'iphone-se', label: 'iPhone SE 3 (375×667)', width: 375, height: 667 },
|
||||||
|
{ name: 'iphone-16', label: 'iPhone 15/16 (393×852)', width: 393, height: 852 },
|
||||||
|
{ name: 'iphone-16-pro', label: 'iPhone 16/17 Pro (402×874)', width: 402, height: 874 },
|
||||||
|
{ name: 'iphone-pro-max', label: 'iPhone 16/17 Pro Max (440×956)', width: 440, height: 956 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Auth = 'admin' | 'public';
|
||||||
|
type Route = {
|
||||||
|
group: string;
|
||||||
|
slug: string;
|
||||||
|
path: string;
|
||||||
|
auth: Auth;
|
||||||
|
/**
|
||||||
|
* If set, after navigating to `path` we click the first matching anchor and
|
||||||
|
* screenshot the resulting detail page under the slug `slug + '-detail'`.
|
||||||
|
*/
|
||||||
|
detailLinkSelector?: string;
|
||||||
|
/** Extra wait after navigation (ms) for content to settle. */
|
||||||
|
settleMs?: number;
|
||||||
|
/** If true, skip — useful for known-broken or out-of-scope routes. */
|
||||||
|
skip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTES: Route[] = [
|
||||||
|
// (auth)
|
||||||
|
{ group: 'auth', slug: 'auth-login', path: '/login', auth: 'public' },
|
||||||
|
{ group: 'auth', slug: 'auth-reset-password', path: '/reset-password', auth: 'public' },
|
||||||
|
{ group: 'auth', slug: 'auth-set-password', path: '/set-password', auth: 'public' },
|
||||||
|
|
||||||
|
// (portal) public
|
||||||
|
{ group: 'portal', slug: 'portal-login', path: '/portal/login', auth: 'public' },
|
||||||
|
{ group: 'portal', slug: 'portal-activate', path: '/portal/activate', auth: 'public' },
|
||||||
|
{
|
||||||
|
group: 'portal',
|
||||||
|
slug: 'portal-forgot-password',
|
||||||
|
path: '/portal/forgot-password',
|
||||||
|
auth: 'public',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'portal',
|
||||||
|
slug: 'portal-reset-password',
|
||||||
|
path: '/portal/reset-password',
|
||||||
|
auth: 'public',
|
||||||
|
},
|
||||||
|
|
||||||
|
// (dashboard)
|
||||||
|
{ group: 'dashboard', slug: 'dash-port-home', path: `/${PORT_SLUG}`, auth: 'admin' },
|
||||||
|
{ group: 'dashboard', slug: 'dash-overview', path: `/${PORT_SLUG}/dashboard`, auth: 'admin' },
|
||||||
|
|
||||||
|
// CRUD lists + detail
|
||||||
|
{
|
||||||
|
group: 'clients',
|
||||||
|
slug: 'clients-list',
|
||||||
|
path: `/${PORT_SLUG}/clients`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/clients/"]:not([href$="/clients"])',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'yachts',
|
||||||
|
slug: 'yachts-list',
|
||||||
|
path: `/${PORT_SLUG}/yachts`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/yachts/"]:not([href$="/yachts"])',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'companies',
|
||||||
|
slug: 'companies-list',
|
||||||
|
path: `/${PORT_SLUG}/companies`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/companies/"]:not([href$="/companies"])',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'berths',
|
||||||
|
slug: 'berths-list',
|
||||||
|
path: `/${PORT_SLUG}/berths`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/berths/"]:not([href$="/berths"])',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'interests',
|
||||||
|
slug: 'interests-list',
|
||||||
|
path: `/${PORT_SLUG}/interests`,
|
||||||
|
auth: 'admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'invoices',
|
||||||
|
slug: 'invoices-list',
|
||||||
|
path: `/${PORT_SLUG}/invoices`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/invoices/"]:not([href$="/invoices"]):not([href$="/new"])',
|
||||||
|
},
|
||||||
|
{ group: 'invoices', slug: 'invoices-new', path: `/${PORT_SLUG}/invoices/new`, auth: 'admin' },
|
||||||
|
{
|
||||||
|
group: 'expenses',
|
||||||
|
slug: 'expenses-list',
|
||||||
|
path: `/${PORT_SLUG}/expenses`,
|
||||||
|
auth: 'admin',
|
||||||
|
detailLinkSelector: 'a[href*="/expenses/"]:not([href$="/expenses"]):not([href$="/scan"])',
|
||||||
|
},
|
||||||
|
{ group: 'expenses', slug: 'expenses-scan', path: `/${PORT_SLUG}/expenses/scan`, auth: 'admin' },
|
||||||
|
|
||||||
|
// Cross-cutting features
|
||||||
|
{ group: 'documents', slug: 'documents', path: `/${PORT_SLUG}/documents`, auth: 'admin' },
|
||||||
|
{ group: 'email', slug: 'email', path: `/${PORT_SLUG}/email`, auth: 'admin' },
|
||||||
|
{ group: 'alerts', slug: 'alerts', path: `/${PORT_SLUG}/alerts`, auth: 'admin' },
|
||||||
|
{ group: 'reports', slug: 'reports', path: `/${PORT_SLUG}/reports`, auth: 'admin' },
|
||||||
|
{ group: 'reminders', slug: 'reminders', path: `/${PORT_SLUG}/reminders`, auth: 'admin' },
|
||||||
|
{ group: 'settings', slug: 'settings-user', path: `/${PORT_SLUG}/settings`, auth: 'admin' },
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
{ group: 'admin', slug: 'admin-home', path: `/${PORT_SLUG}/admin`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-settings', path: `/${PORT_SLUG}/admin/settings`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-branding', path: `/${PORT_SLUG}/admin/branding`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-forms', path: `/${PORT_SLUG}/admin/forms`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-ocr', path: `/${PORT_SLUG}/admin/ocr`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-roles', path: `/${PORT_SLUG}/admin/roles`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-tags', path: `/${PORT_SLUG}/admin/tags`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-audit', path: `/${PORT_SLUG}/admin/audit`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-documenso', path: `/${PORT_SLUG}/admin/documenso`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-users', path: `/${PORT_SLUG}/admin/users`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-templates', path: `/${PORT_SLUG}/admin/templates`, auth: 'admin' },
|
||||||
|
{
|
||||||
|
group: 'admin',
|
||||||
|
slug: 'admin-custom-fields',
|
||||||
|
path: `/${PORT_SLUG}/admin/custom-fields`,
|
||||||
|
auth: 'admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'admin',
|
||||||
|
slug: 'admin-monitoring',
|
||||||
|
path: `/${PORT_SLUG}/admin/monitoring`,
|
||||||
|
auth: 'admin',
|
||||||
|
},
|
||||||
|
{ group: 'admin', slug: 'admin-backup', path: `/${PORT_SLUG}/admin/backup`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-webhooks', path: `/${PORT_SLUG}/admin/webhooks`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-import', path: `/${PORT_SLUG}/admin/import`, auth: 'admin' },
|
||||||
|
{ group: 'admin', slug: 'admin-ports', path: `/${PORT_SLUG}/admin/ports`, auth: 'admin' },
|
||||||
|
|
||||||
|
// Scanner PWA
|
||||||
|
{ group: 'scanner', slug: 'scanner-scan', path: `/${PORT_SLUG}/scan`, auth: 'admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Capture = {
|
||||||
|
group: string;
|
||||||
|
slug: string;
|
||||||
|
path: string;
|
||||||
|
file: string;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureAdminExists(request: APIRequestContext) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Origin: 'http://localhost:3000',
|
||||||
|
Referer: 'http://localhost:3000/',
|
||||||
|
};
|
||||||
|
const signUp = await request.post('/api/auth/sign-up/email', {
|
||||||
|
headers,
|
||||||
|
data: { email: ADMIN.email, password: ADMIN.password, name: ADMIN.name },
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
if (!signUp.ok()) {
|
||||||
|
// Already exists — verify sign-in works
|
||||||
|
const signIn = await request.post('/api/auth/sign-in/email', {
|
||||||
|
headers,
|
||||||
|
data: { email: ADMIN.email, password: ADMIN.password },
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
if (!signIn.ok()) {
|
||||||
|
const body = await signIn.text();
|
||||||
|
throw new Error(`Cannot sign in admin: ${signIn.status()} ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginThroughUI(page: Page) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[type="email"], input[name="email"]', ADMIN.email);
|
||||||
|
await page.fill('input[type="password"], input[name="password"]', ADMIN.password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
// Better-auth redirect can land on `/` or `/[portSlug]` depending on user_port_roles.
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureRoute(
|
||||||
|
page: Page,
|
||||||
|
route: Route,
|
||||||
|
outDir: string,
|
||||||
|
captures: Capture[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (route.skip) return;
|
||||||
|
|
||||||
|
// Main capture
|
||||||
|
const mainFile = path.join(outDir, `${route.slug}.png`);
|
||||||
|
try {
|
||||||
|
await page.goto(route.path, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(route.settleMs ?? 600);
|
||||||
|
await page.screenshot({ path: mainFile, fullPage: true });
|
||||||
|
captures.push({
|
||||||
|
group: route.group,
|
||||||
|
slug: route.slug,
|
||||||
|
path: route.path,
|
||||||
|
file: mainFile,
|
||||||
|
status: 'ok',
|
||||||
|
});
|
||||||
|
console.log(` ✓ ${route.path}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
captures.push({
|
||||||
|
group: route.group,
|
||||||
|
slug: route.slug,
|
||||||
|
path: route.path,
|
||||||
|
file: mainFile,
|
||||||
|
status: 'error',
|
||||||
|
error: msg.split('\n')[0],
|
||||||
|
});
|
||||||
|
console.log(` ✗ ${route.path} — ${msg.split('\n')[0]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail capture (optional)
|
||||||
|
if (route.detailLinkSelector) {
|
||||||
|
const detailSlug = `${route.slug.replace(/-list$/, '')}-detail`;
|
||||||
|
const detailFile = path.join(outDir, `${detailSlug}.png`);
|
||||||
|
try {
|
||||||
|
const link = page.locator(route.detailLinkSelector).first();
|
||||||
|
const count = await link.count();
|
||||||
|
if (count === 0) {
|
||||||
|
captures.push({
|
||||||
|
group: route.group,
|
||||||
|
slug: detailSlug,
|
||||||
|
path: `${route.path} → (no rows seeded)`,
|
||||||
|
file: detailFile,
|
||||||
|
status: 'error',
|
||||||
|
error: 'No detail rows present in list',
|
||||||
|
});
|
||||||
|
console.log(` – ${route.path} detail skipped (empty list)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
const target = href ?? route.path;
|
||||||
|
await page.goto(target, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.screenshot({ path: detailFile, fullPage: true });
|
||||||
|
captures.push({
|
||||||
|
group: route.group,
|
||||||
|
slug: detailSlug,
|
||||||
|
path: target,
|
||||||
|
file: detailFile,
|
||||||
|
status: 'ok',
|
||||||
|
});
|
||||||
|
console.log(` ✓ ${target} (detail)`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
captures.push({
|
||||||
|
group: route.group,
|
||||||
|
slug: detailSlug,
|
||||||
|
path: `${route.path} → detail`,
|
||||||
|
file: detailFile,
|
||||||
|
status: 'error',
|
||||||
|
error: msg.split('\n')[0],
|
||||||
|
});
|
||||||
|
console.log(` ✗ ${route.path} detail — ${msg.split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIndex(allByViewport: Map<string, Capture[]>): Promise<void> {
|
||||||
|
const lines: string[] = [
|
||||||
|
'# Mobile Audit',
|
||||||
|
'',
|
||||||
|
`Generated: ${new Date().toISOString()}`,
|
||||||
|
'',
|
||||||
|
'Captured at iPhone 14 Pro (393×852) and iPhone SE 3 (375×667), portrait, full-page.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [vpName, captures] of allByViewport) {
|
||||||
|
lines.push(`## ${vpName}`, '');
|
||||||
|
|
||||||
|
const grouped = new Map<string, Capture[]>();
|
||||||
|
for (const c of captures) {
|
||||||
|
if (!grouped.has(c.group)) grouped.set(c.group, []);
|
||||||
|
grouped.get(c.group)!.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [group, items] of grouped) {
|
||||||
|
lines.push(`### ${group}`, '');
|
||||||
|
lines.push('| route | shot |');
|
||||||
|
lines.push('| --- | --- |');
|
||||||
|
for (const c of items) {
|
||||||
|
const rel = path.relative(OUT_ROOT, c.file);
|
||||||
|
if (c.status === 'ok') {
|
||||||
|
lines.push(`| \`${c.path}\` |  |`);
|
||||||
|
} else {
|
||||||
|
lines.push(`| \`${c.path}\` | _error: ${c.error}_ |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(OUT_ROOT, 'index.md'), lines.join('\n'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mobile audit — every page at iPhone viewports', async ({ browser, request }) => {
|
||||||
|
test.setTimeout(600_000);
|
||||||
|
|
||||||
|
await fs.mkdir(OUT_ROOT, { recursive: true });
|
||||||
|
await ensureAdminExists(request);
|
||||||
|
|
||||||
|
const allByViewport = new Map<string, Capture[]>();
|
||||||
|
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
console.log(`\n─── Viewport: ${vp.label} ───`);
|
||||||
|
const outDir = path.join(OUT_ROOT, vp.name);
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const captures: Capture[] = [];
|
||||||
|
|
||||||
|
// Public pages first (no auth state)
|
||||||
|
for (const route of ROUTES.filter((r) => r.auth === 'public')) {
|
||||||
|
await captureRoute(page, route, outDir, captures);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in once via UI for authenticated pages
|
||||||
|
await loginThroughUI(page);
|
||||||
|
|
||||||
|
for (const route of ROUTES.filter((r) => r.auth === 'admin')) {
|
||||||
|
await captureRoute(page, route, outDir, captures);
|
||||||
|
}
|
||||||
|
|
||||||
|
allByViewport.set(vp.label, captures);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeIndex(allByViewport);
|
||||||
|
console.log(`\nIndex written to ${path.join(OUT_ROOT, 'index.md')}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user