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>
1919 lines
59 KiB
Markdown
1919 lines
59 KiB
Markdown
# Mobile Foundation PR Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Land the infrastructure, mobile shell, and mobile-aware primitives that §3 of the design spec requires, so subsequent per-page migrations are wrap-and-tweak. After this PR merges, every authenticated page already gains: viewport meta, no clipped topbar, bottom-tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
|
||
|
||
**Architecture:** Adaptive shell via a `data-form-factor` body attribute set server-side from the User-Agent (no middleware), with a CSS media-query fallback. Both desktop and mobile shells render to the DOM; CSS reveals one. Mobile-aware primitives (`<Drawer>`, `<DataView>`, `<PageHeader>`, `<ActionRow>`, `<DetailPageShell>`, `<FilterChips>`) live in `src/components/shared/` and switch presentation at the `lg` Tailwind breakpoint.
|
||
|
||
**Tech Stack:** Next.js 15 App Router, React 19, TypeScript strict, Tailwind 3, Radix/shadcn, vaul (new — for native-feel bottom sheets), Lucide icons, vitest (unit), Playwright (visual + audit).
|
||
|
||
**Spec reference:** `docs/superpowers/specs/2026-04-29-mobile-optimization-design.md` §3.
|
||
|
||
**Spec deviation:** spec calls the vaul-wrapper primitive `<Sheet>` but `src/components/ui/sheet.tsx` already exists (shadcn slide-from-side using Radix Dialog). The plan uses `<Drawer>` instead — matches shadcn's official vaul-wrapper naming convention and avoids collision.
|
||
|
||
**Out of scope** (separate follow-up plans, see spec §5):
|
||
|
||
- Per-page migration (quick-win sweep, list pages, detail pages, heavy pages, forms, portal, tablet pass).
|
||
- Adopting `<Drawer>` everywhere existing `<Dialog>` is used. Foundation only ships the primitive; per-page work swaps imports.
|
||
- Final PWA icon designs — placeholders only.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
**New files:**
|
||
|
||
- `src/hooks/use-is-mobile.ts` — viewport-driven mobile detection hook
|
||
- `src/lib/form-factor.ts` — pure UA-classification function (unit-testable)
|
||
- `src/components/layout/mobile/mobile-layout.tsx`
|
||
- `src/components/layout/mobile/mobile-topbar.tsx`
|
||
- `src/components/layout/mobile/mobile-bottom-tabs.tsx`
|
||
- `src/components/layout/mobile/more-sheet.tsx`
|
||
- `src/components/layout/mobile/mobile-layout-provider.tsx`
|
||
- `src/components/shared/drawer.tsx` — vaul wrapper (was `<Sheet>` in spec)
|
||
- `src/components/shared/data-view.tsx`
|
||
- `src/components/shared/page-header.tsx`
|
||
- `src/components/shared/action-row.tsx`
|
||
- `src/components/shared/detail-page-shell.tsx`
|
||
- `src/components/shared/filter-chips.tsx`
|
||
- `tests/unit/lib/form-factor.test.ts` — vitest
|
||
- `tests/unit/hooks/use-is-mobile.test.ts` — vitest
|
||
- `tests/e2e/fixtures/devices.ts` — anchor device descriptors
|
||
- `tests/e2e/visual/mobile-shell.spec.ts` — playwright visual snapshot for the mobile shell
|
||
- `public/icon-192.png` — placeholder PWA asset (solid blue 192×192)
|
||
- `public/icon-512.png` — placeholder PWA asset (solid blue 512×512)
|
||
- `public/icon-512-maskable.png` — placeholder PWA asset (solid blue 512×512 with safe zone padding)
|
||
- `public/apple-touch-icon.png` — placeholder PWA asset (solid blue 180×180)
|
||
|
||
**Modified files:**
|
||
|
||
- `src/app/layout.tsx` — add `viewport` export, theme-color, body data-form-factor, apple-mobile-web-app metas
|
||
- `src/app/(dashboard)/layout.tsx` — render `<MobileLayout>` alongside the existing `<Sidebar>`/`<Topbar>`; CSS hides the inactive shell
|
||
- `src/app/globals.css` — add `[data-form-factor]` reveal/hide rules + media-query fallback
|
||
- `tailwind.config.ts` — add `safe` spacing utilities (`pt-safe`/`pb-safe`/etc.)
|
||
- `src/components/ui/button.tsx` — bump `size: default` from `h-9` to `h-11` (and `sm`/`lg`/`icon` proportionally)
|
||
- `src/components/ui/input.tsx` — bump from `h-9` to `h-11`, drop `md:text-sm` (keep 16px to prevent iOS zoom)
|
||
- `src/components/ui/textarea.tsx` — drop `md:text-sm` (keep 16px)
|
||
- `src/components/ui/dialog.tsx` — adjust `DialogContent` to render full-screen on mobile (`inset-0 max-w-full sm:inset-auto sm:max-w-lg`)
|
||
- `package.json` — add `vaul` dependency
|
||
|
||
---
|
||
|
||
## Task 1: Add `viewport` export, theme-color, and PWA metas to root layout
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/app/layout.tsx`
|
||
|
||
- [ ] **Step 1: Add the `viewport` export and PWA-related metadata to the root layout**
|
||
|
||
```ts
|
||
// src/app/layout.tsx
|
||
import type { Metadata, Viewport } from 'next';
|
||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||
import { Toaster } from 'sonner';
|
||
import './globals.css';
|
||
|
||
const inter = Inter({
|
||
subsets: ['latin'],
|
||
variable: '--font-sans',
|
||
display: 'swap',
|
||
});
|
||
|
||
const jetbrainsMono = JetBrains_Mono({
|
||
subsets: ['latin'],
|
||
variable: '--font-mono',
|
||
display: 'swap',
|
||
});
|
||
|
||
export const viewport: Viewport = {
|
||
width: 'device-width',
|
||
initialScale: 1,
|
||
viewportFit: 'cover',
|
||
themeColor: '#1e2844',
|
||
};
|
||
|
||
export const metadata: Metadata = {
|
||
title: {
|
||
default: 'Port Nimara CRM',
|
||
template: '%s | Port Nimara CRM',
|
||
},
|
||
description: 'Marina management system for Port Nimara',
|
||
appleWebApp: {
|
||
capable: true,
|
||
statusBarStyle: 'black-translucent',
|
||
title: 'Port Nimara',
|
||
},
|
||
icons: {
|
||
icon: [
|
||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||
],
|
||
apple: '/apple-touch-icon.png',
|
||
},
|
||
};
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="en" suppressHydrationWarning>
|
||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
|
||
{children}
|
||
<Toaster richColors position="top-right" />
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
(The body `data-form-factor` attribute lands in Task 3.)
|
||
|
||
- [ ] **Step 2: Verify the root layout still typechecks**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Verify the dev server still serves the page**
|
||
|
||
Open `http://localhost:3000/login` in a browser, view source. Expected: `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` and `<meta name="theme-color" content="#1e2844">` are present in `<head>`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/layout.tsx
|
||
git commit -m "feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add safe-area Tailwind utilities
|
||
|
||
**Files:**
|
||
|
||
- Modify: `tailwind.config.ts`
|
||
|
||
- [ ] **Step 1: Add safe-area spacing utilities to the theme extension**
|
||
|
||
Find the `extend:` block in `tailwind.config.ts`. Add these keys (place `spacing` before `keyframes`):
|
||
|
||
```ts
|
||
spacing: {
|
||
safe: 'env(safe-area-inset-bottom)',
|
||
'safe-top': 'env(safe-area-inset-top)',
|
||
'safe-bottom': 'env(safe-area-inset-bottom)',
|
||
'safe-left': 'env(safe-area-inset-left)',
|
||
'safe-right': 'env(safe-area-inset-right)',
|
||
},
|
||
```
|
||
|
||
This makes `pt-safe-top`, `pb-safe-bottom`, `pl-safe-left`, `pr-safe-right` (and `pt-safe`/`pb-safe` shorthand) available as Tailwind utilities.
|
||
|
||
- [ ] **Step 2: Verify the config still parses**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Smoke-test the utility actually emits**
|
||
|
||
Add a temporary `pb-safe-bottom` class to a test page (e.g., `src/app/(auth)/login/page.tsx`). Reload, inspect — element should have `padding-bottom: env(safe-area-inset-bottom)`. Remove the test class.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tailwind.config.ts
|
||
git commit -m "feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Create UA-derived form-factor classifier (TDD)
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/lib/form-factor.ts`
|
||
- Create: `tests/unit/lib/form-factor.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// tests/unit/lib/form-factor.test.ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { classifyFormFactor } from '@/lib/form-factor';
|
||
|
||
describe('classifyFormFactor', () => {
|
||
it('returns "mobile" for an iPhone UA', () => {
|
||
expect(
|
||
classifyFormFactor(
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||
),
|
||
).toBe('mobile');
|
||
});
|
||
|
||
it('returns "mobile" for an iPad UA', () => {
|
||
expect(
|
||
classifyFormFactor(
|
||
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||
),
|
||
).toBe('mobile');
|
||
});
|
||
|
||
it('returns "mobile" for an Android UA', () => {
|
||
expect(
|
||
classifyFormFactor(
|
||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36',
|
||
),
|
||
).toBe('mobile');
|
||
});
|
||
|
||
it('returns "desktop" for a Mac Safari UA', () => {
|
||
expect(
|
||
classifyFormFactor(
|
||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15',
|
||
),
|
||
).toBe('desktop');
|
||
});
|
||
|
||
it('returns "desktop" for a Linux Chrome UA', () => {
|
||
expect(
|
||
classifyFormFactor(
|
||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36',
|
||
),
|
||
).toBe('desktop');
|
||
});
|
||
|
||
it('returns "desktop" for missing UA', () => {
|
||
expect(classifyFormFactor(null)).toBe('desktop');
|
||
expect(classifyFormFactor(undefined)).toBe('desktop');
|
||
expect(classifyFormFactor('')).toBe('desktop');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts`
|
||
Expected: FAIL with `Cannot find module '@/lib/form-factor'`.
|
||
|
||
- [ ] **Step 3: Write the minimal implementation**
|
||
|
||
```ts
|
||
// src/lib/form-factor.ts
|
||
export type FormFactor = 'mobile' | 'desktop';
|
||
|
||
const MOBILE_TOKENS = ['Mobile', 'iPhone', 'iPad', 'Android'] as const;
|
||
|
||
/**
|
||
* Classify a User-Agent string as 'mobile' or 'desktop'.
|
||
* Defaults to 'desktop' when the UA is missing or unrecognized — the CSS
|
||
* media-query fallback in globals.css handles desktop browsers resized below
|
||
* the lg breakpoint, so a wrong-but-defaultish classification never breaks UX.
|
||
*/
|
||
export function classifyFormFactor(userAgent: string | null | undefined): FormFactor {
|
||
if (!userAgent) return 'desktop';
|
||
return MOBILE_TOKENS.some((token) => userAgent.includes(token)) ? 'mobile' : 'desktop';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts`
|
||
Expected: 6 tests passing.
|
||
|
||
- [ ] **Step 5: Wire it into the root layout**
|
||
|
||
Modify `src/app/layout.tsx` — at the top, add the import:
|
||
|
||
```ts
|
||
import { headers } from 'next/headers';
|
||
import { classifyFormFactor } from '@/lib/form-factor';
|
||
```
|
||
|
||
Change the `RootLayout` to async and read the form factor:
|
||
|
||
```ts
|
||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||
const headerList = await headers();
|
||
const formFactor = classifyFormFactor(headerList.get('user-agent'));
|
||
|
||
return (
|
||
<html lang="en" suppressHydrationWarning>
|
||
<body
|
||
data-form-factor={formFactor}
|
||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||
>
|
||
{children}
|
||
<Toaster richColors position="top-right" />
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify it still builds**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 7: Verify the body attribute renders**
|
||
|
||
Open `http://localhost:3000/login` in a browser, inspect the `<body>` element. Expected: `<body data-form-factor="desktop">` (since this Mac Chrome UA is desktop). Open in a mobile-emulated tab (Chrome devtools → toggle device toolbar → iPhone) and reload — expected: `data-form-factor="mobile"`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/lib/form-factor.ts tests/unit/lib/form-factor.test.ts src/app/layout.tsx
|
||
git commit -m "feat(mobile): set data-form-factor body attr from User-Agent in root layout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add CSS rules that reveal mobile/desktop shells based on form factor
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/app/globals.css`
|
||
|
||
- [ ] **Step 1: Append the form-factor reveal rules to globals.css**
|
||
|
||
Add at the end of `src/app/globals.css`:
|
||
|
||
```css
|
||
/* ─── Form-factor shell visibility ──────────────────────────────────────────
|
||
* Two shells (desktop + mobile) render to the DOM on every page; CSS reveals
|
||
* one and hides the other. The data-form-factor body attribute is set
|
||
* server-side from User-Agent (see src/lib/form-factor.ts). The media-query
|
||
* fallback handles desktop browsers resized below lg (1024px), or stripped UAs.
|
||
*/
|
||
[data-shell='desktop'] {
|
||
display: block;
|
||
}
|
||
[data-shell='mobile'] {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 1023.98px) {
|
||
[data-shell='desktop'] {
|
||
display: none;
|
||
}
|
||
[data-shell='mobile'] {
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
body[data-form-factor='mobile'] [data-shell='desktop'] {
|
||
display: none;
|
||
}
|
||
body[data-form-factor='mobile'] [data-shell='mobile'] {
|
||
display: block;
|
||
}
|
||
```
|
||
|
||
The shell components themselves will set `data-shell="desktop"` or `data-shell="mobile"` on their root element (Tasks 13, 14).
|
||
|
||
- [ ] **Step 2: Verify globals.css still parses**
|
||
|
||
Reload `http://localhost:3000/login` — page should render normally with no console CSS errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/globals.css
|
||
git commit -m "feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Create `useIsMobile()` hook (TDD)
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/hooks/use-is-mobile.ts`
|
||
- Create: `tests/unit/hooks/use-is-mobile.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// tests/unit/hooks/use-is-mobile.test.ts
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||
|
||
type Listener = (e: { matches: boolean }) => void;
|
||
|
||
describe('useIsMobile', () => {
|
||
let mediaListeners: Listener[];
|
||
let currentMatches: boolean;
|
||
|
||
beforeEach(() => {
|
||
mediaListeners = [];
|
||
currentMatches = false;
|
||
vi.stubGlobal(
|
||
'matchMedia',
|
||
vi.fn((query: string) => ({
|
||
matches: currentMatches,
|
||
media: query,
|
||
onchange: null,
|
||
addEventListener: (_: string, l: Listener) => mediaListeners.push(l),
|
||
removeEventListener: (_: string, l: Listener) => {
|
||
mediaListeners = mediaListeners.filter((x) => x !== l);
|
||
},
|
||
addListener: () => {},
|
||
removeListener: () => {},
|
||
dispatchEvent: () => true,
|
||
})),
|
||
);
|
||
Object.defineProperty(window, 'matchMedia', {
|
||
configurable: true,
|
||
writable: true,
|
||
value: globalThis.matchMedia,
|
||
});
|
||
});
|
||
|
||
it('returns false for desktop viewport', () => {
|
||
currentMatches = false;
|
||
const { result } = renderHook(() => useIsMobile());
|
||
expect(result.current).toBe(false);
|
||
});
|
||
|
||
it('returns true for mobile viewport', () => {
|
||
currentMatches = true;
|
||
const { result } = renderHook(() => useIsMobile());
|
||
expect(result.current).toBe(true);
|
||
});
|
||
|
||
it('updates when the media query changes', () => {
|
||
currentMatches = false;
|
||
const { result } = renderHook(() => useIsMobile());
|
||
expect(result.current).toBe(false);
|
||
|
||
act(() => {
|
||
mediaListeners.forEach((l) => l({ matches: true }));
|
||
});
|
||
expect(result.current).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts`
|
||
Expected: FAIL with `Cannot find module '@/hooks/use-is-mobile'`.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
```ts
|
||
// src/hooks/use-is-mobile.ts
|
||
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
|
||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||
|
||
/**
|
||
* Returns true when the viewport is below the `lg` Tailwind breakpoint.
|
||
* Backed by a media-query listener; safe to call from any client component.
|
||
* Server renders return `false` (desktop default) — clients hydrate to the
|
||
* true viewport state on mount.
|
||
*/
|
||
export function useIsMobile(): boolean {
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia(MOBILE_QUERY);
|
||
const update = (e: { matches: boolean }) => setIsMobile(e.matches);
|
||
setIsMobile(mq.matches);
|
||
mq.addEventListener('change', update);
|
||
return () => mq.removeEventListener('change', update);
|
||
}, []);
|
||
|
||
return isMobile;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts`
|
||
Expected: 3 tests passing.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/hooks/use-is-mobile.ts tests/unit/hooks/use-is-mobile.test.ts
|
||
git commit -m "feat(mobile): add useIsMobile() hook backed by matchMedia"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Add vaul dependency
|
||
|
||
**Files:**
|
||
|
||
- Modify: `package.json` (via `pnpm add`)
|
||
|
||
- [ ] **Step 1: Install vaul**
|
||
|
||
Run: `pnpm add vaul@^1.1.2`
|
||
Expected: `vaul` appears in `package.json` dependencies.
|
||
|
||
- [ ] **Step 2: Verify install**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add package.json pnpm-lock.yaml
|
||
git commit -m "chore(deps): add vaul for native-feel bottom sheets"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Bump touch-target defaults on Button, Input, Textarea
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/components/ui/button.tsx`
|
||
- Modify: `src/components/ui/input.tsx`
|
||
- Modify: `src/components/ui/textarea.tsx`
|
||
|
||
- [ ] **Step 1: Update Button size variants**
|
||
|
||
In `src/components/ui/button.tsx`, change the `size` variants:
|
||
|
||
```ts
|
||
size: {
|
||
default: "h-11 px-4 py-2",
|
||
sm: "h-9 rounded-md px-3 text-xs",
|
||
lg: "h-12 rounded-md px-8",
|
||
icon: "h-11 w-11",
|
||
},
|
||
```
|
||
|
||
Rationale: 44px (h-11) hits the Apple HIG touch-target on default and icon. `sm` stays at 36px for dense desktop contexts (table inline actions); per-page work can opt into the larger size on mobile.
|
||
|
||
- [ ] **Step 2: Update Input height + drop md:text-sm**
|
||
|
||
In `src/components/ui/input.tsx`, change the className:
|
||
|
||
```ts
|
||
className={cn(
|
||
"flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||
className
|
||
)}
|
||
```
|
||
|
||
Removed: `md:text-sm`. Kept: `text-base` everywhere so iOS Safari doesn't zoom on focus (iOS zooms when focused input has a font-size below 16px).
|
||
|
||
- [ ] **Step 3: Update Textarea — drop md:text-sm**
|
||
|
||
In `src/components/ui/textarea.tsx`:
|
||
|
||
```ts
|
||
className={cn(
|
||
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||
className
|
||
)}
|
||
```
|
||
|
||
Removed: `md:text-sm`. Bumped `min-h-[60px]` to `min-h-[80px]` — single textarea is easier to use larger.
|
||
|
||
- [ ] **Step 4: Verify typecheck + visual smoke**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
Open `http://localhost:3000/port-nimara/invoices/new` in the browser. Buttons + inputs should be visibly taller. Existing pages should not look broken — desktop layouts that depended on the 36px button height may need follow-up tweaks tracked in spec §7.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/button.tsx src/components/ui/input.tsx src/components/ui/textarea.tsx
|
||
git commit -m "feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Make Dialog full-screen on mobile
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/components/ui/dialog.tsx`
|
||
|
||
- [ ] **Step 1: Update DialogContent positioning**
|
||
|
||
In `src/components/ui/dialog.tsx`, change `DialogContent`'s className:
|
||
|
||
```ts
|
||
className={cn(
|
||
"fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]",
|
||
className
|
||
)}
|
||
```
|
||
|
||
Below the `sm` breakpoint (640px) the Dialog renders full-screen (`inset-0 max-w-full`); at and above `sm` it keeps the centered modal behavior.
|
||
|
||
- [ ] **Step 2: Verify a Dialog still works at desktop**
|
||
|
||
Reload `http://localhost:3000/port-nimara/clients` in a desktop viewport and open any dialog (e.g., create new client). Expected: centered modal, looks unchanged.
|
||
|
||
Resize the browser to 400px wide. Same dialog should now fill the viewport.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/dialog.tsx
|
||
git commit -m "feat(mobile): render Dialog full-screen below sm, centered modal at sm+"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Add placeholder PWA assets
|
||
|
||
**Files:**
|
||
|
||
- Create: `public/icon-192.png`
|
||
- Create: `public/icon-512.png`
|
||
- Create: `public/icon-512-maskable.png`
|
||
- Create: `public/apple-touch-icon.png`
|
||
|
||
- [ ] **Step 1: Generate solid-color placeholder PNGs**
|
||
|
||
Use ImageMagick's `convert` (already on most macOS dev machines via Homebrew) to write four solid `#1e2844` (Port Nimara navy) PNGs at the right sizes. The maskable variant has a 20% transparent border on each side per the PWA maskable spec safe zone.
|
||
|
||
```bash
|
||
convert -size 192x192 xc:'#1e2844' public/icon-192.png
|
||
convert -size 512x512 xc:'#1e2844' public/icon-512.png
|
||
convert -size 180x180 xc:'#1e2844' public/apple-touch-icon.png
|
||
|
||
# Maskable: 410×410 navy centered on a 512×512 navy canvas (no transparent border;
|
||
# we want fully-bleeding navy so safe zone is purely a layout convention).
|
||
convert -size 512x512 xc:'#1e2844' public/icon-512-maskable.png
|
||
```
|
||
|
||
If `convert` is missing, install with `brew install imagemagick` first.
|
||
|
||
- [ ] **Step 2: Verify the files exist and have correct dimensions**
|
||
|
||
Run: `file public/icon-192.png public/icon-512.png public/apple-touch-icon.png public/icon-512-maskable.png`
|
||
Expected: each line reports the correct PNG dimensions.
|
||
|
||
- [ ] **Step 3: Verify the PWA manifest reference resolves**
|
||
|
||
Open `http://localhost:3000/port-nimara/scan/manifest.webmanifest` (the existing scanner manifest endpoint), confirm it references the icon paths. Open `http://localhost:3000/icon-192.png` in a tab — should show the navy square.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add public/icon-192.png public/icon-512.png public/icon-512-maskable.png public/apple-touch-icon.png
|
||
git commit -m "chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Create `<MobileLayoutProvider>` (context for topbar slots)
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/layout/mobile/mobile-layout-provider.tsx`
|
||
|
||
- [ ] **Step 1: Create the provider + hook**
|
||
|
||
```tsx
|
||
// src/components/layout/mobile/mobile-layout-provider.tsx
|
||
'use client';
|
||
|
||
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
||
|
||
type MobileChromeState = {
|
||
title: string | null;
|
||
primaryAction: ReactNode | null;
|
||
showBackButton: boolean;
|
||
};
|
||
|
||
type MobileChromeApi = MobileChromeState & {
|
||
setChrome: (next: Partial<MobileChromeState>) => void;
|
||
};
|
||
|
||
const MobileChromeContext = createContext<MobileChromeApi | null>(null);
|
||
|
||
export function MobileLayoutProvider({ children }: { children: ReactNode }) {
|
||
const [state, setState] = useState<MobileChromeState>({
|
||
title: null,
|
||
primaryAction: null,
|
||
showBackButton: false,
|
||
});
|
||
|
||
const value = useMemo<MobileChromeApi>(
|
||
() => ({
|
||
...state,
|
||
setChrome: (next) => setState((prev) => ({ ...prev, ...next })),
|
||
}),
|
||
[state],
|
||
);
|
||
|
||
return <MobileChromeContext.Provider value={value}>{children}</MobileChromeContext.Provider>;
|
||
}
|
||
|
||
/**
|
||
* Page-level hook to push a title / back-button / primary action into the
|
||
* mobile topbar. The provider is only mounted by `<MobileLayout>`, so
|
||
* desktop-shell renders never call into this context.
|
||
*/
|
||
export function useMobileChrome() {
|
||
const ctx = useContext(MobileChromeContext);
|
||
if (!ctx) {
|
||
throw new Error('useMobileChrome must be used inside <MobileLayoutProvider>');
|
||
}
|
||
return ctx;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/layout/mobile/mobile-layout-provider.tsx
|
||
git commit -m "feat(mobile): add MobileLayoutProvider context + useMobileChrome hook"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Create `<MobileTopbar>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/layout/mobile/mobile-topbar.tsx`
|
||
|
||
- [ ] **Step 1: Create the topbar component**
|
||
|
||
```tsx
|
||
// src/components/layout/mobile/mobile-topbar.tsx
|
||
'use client';
|
||
|
||
import { ChevronLeft } from 'lucide-react';
|
||
import { useRouter, usePathname } from 'next/navigation';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
import { useMobileChrome } from './mobile-layout-provider';
|
||
|
||
/**
|
||
* Fixed compact topbar (52px + safe-area top inset). Renders the page title
|
||
* (auto-truncating), an optional back button, and an optional primary action
|
||
* — all driven by `useMobileChrome()` from the active page.
|
||
*/
|
||
export function MobileTopbar() {
|
||
const { title, primaryAction, showBackButton } = useMobileChrome();
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
|
||
// Fall back to the last path segment (Title Case) if no page-supplied title.
|
||
const fallbackTitle =
|
||
pathname
|
||
.split('/')
|
||
.filter(Boolean)
|
||
.pop()
|
||
?.replace(/-/g, ' ')
|
||
.replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara';
|
||
|
||
return (
|
||
<header
|
||
className={cn(
|
||
'fixed top-0 inset-x-0 z-40 bg-background border-b border-border',
|
||
'h-[calc(52px+env(safe-area-inset-top))] pt-safe-top',
|
||
'flex items-center gap-2 px-3',
|
||
)}
|
||
>
|
||
{showBackButton ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => router.back()}
|
||
aria-label="Go back"
|
||
className="-ml-1 size-11 inline-flex items-center justify-center text-foreground"
|
||
>
|
||
<ChevronLeft className="size-5" />
|
||
</button>
|
||
) : (
|
||
<div className="size-11" aria-hidden />
|
||
)}
|
||
|
||
<h1 className="flex-1 text-base font-semibold truncate text-foreground">
|
||
{title ?? fallbackTitle}
|
||
</h1>
|
||
|
||
<div className="size-11 inline-flex items-center justify-end">{primaryAction}</div>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/layout/mobile/mobile-topbar.tsx
|
||
git commit -m "feat(mobile): add MobileTopbar with title, back-button, and primary-action slots"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Create `<MobileBottomTabs>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/layout/mobile/mobile-bottom-tabs.tsx`
|
||
|
||
- [ ] **Step 1: Create the bottom tab bar**
|
||
|
||
```tsx
|
||
// src/components/layout/mobile/mobile-bottom-tabs.tsx
|
||
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { usePathname } from 'next/navigation';
|
||
import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
type TabSpec = {
|
||
label: string;
|
||
icon: typeof LayoutDashboard;
|
||
segment: string; // route segment after /[portSlug]/
|
||
};
|
||
|
||
const TABS: TabSpec[] = [
|
||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||
];
|
||
|
||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||
const pathname = usePathname();
|
||
|
||
// Derive the active port slug from the URL so tab links always target the
|
||
// current port, even after a port-switch. The dashboard route shape is
|
||
// /[portSlug]/<rest>, so the slug is the first non-empty path segment.
|
||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||
|
||
function isActive(segment: string): boolean {
|
||
return pathname.startsWith(`/${portSlug}/${segment}`);
|
||
}
|
||
|
||
return (
|
||
<nav
|
||
aria-label="Primary navigation"
|
||
className={cn(
|
||
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
|
||
'pb-safe-bottom',
|
||
'grid grid-cols-5',
|
||
)}
|
||
>
|
||
{TABS.map((tab) => {
|
||
const active = isActive(tab.segment);
|
||
const Icon = tab.icon;
|
||
return (
|
||
<Link
|
||
key={tab.segment}
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
href={`/${portSlug}/${tab.segment}` as any}
|
||
aria-current={active ? 'page' : undefined}
|
||
className={cn(
|
||
'flex flex-col items-center justify-center gap-0.5 h-14 text-xs',
|
||
active ? 'text-primary' : 'text-muted-foreground',
|
||
)}
|
||
>
|
||
<Icon className="size-5" aria-hidden />
|
||
<span className="font-medium">{tab.label}</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
onClick={onMoreClick}
|
||
className="flex flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground"
|
||
>
|
||
<Menu className="size-5" aria-hidden />
|
||
<span className="font-medium">More</span>
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/layout/mobile/mobile-bottom-tabs.tsx
|
||
git commit -m "feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Create `<Drawer>` (vaul wrapper)
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/drawer.tsx`
|
||
|
||
- [ ] **Step 1: Create the vaul wrapper**
|
||
|
||
```tsx
|
||
// src/components/shared/drawer.tsx
|
||
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { Drawer as VaulDrawer } from 'vaul';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
const Drawer = ({
|
||
shouldScaleBackground = true,
|
||
...props
|
||
}: React.ComponentProps<typeof VaulDrawer.Root>) => (
|
||
<VaulDrawer.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||
);
|
||
Drawer.displayName = 'Drawer';
|
||
|
||
const DrawerTrigger = VaulDrawer.Trigger;
|
||
const DrawerPortal = VaulDrawer.Portal;
|
||
const DrawerClose = VaulDrawer.Close;
|
||
|
||
const DrawerOverlay = React.forwardRef<
|
||
React.ElementRef<typeof VaulDrawer.Overlay>,
|
||
React.ComponentPropsWithoutRef<typeof VaulDrawer.Overlay>
|
||
>(({ className, ...props }, ref) => (
|
||
<VaulDrawer.Overlay
|
||
ref={ref}
|
||
className={cn('fixed inset-0 z-50 bg-black/60', className)}
|
||
{...props}
|
||
/>
|
||
));
|
||
DrawerOverlay.displayName = 'DrawerOverlay';
|
||
|
||
const DrawerContent = React.forwardRef<
|
||
React.ElementRef<typeof VaulDrawer.Content>,
|
||
React.ComponentPropsWithoutRef<typeof VaulDrawer.Content>
|
||
>(({ className, children, ...props }, ref) => (
|
||
<DrawerPortal>
|
||
<DrawerOverlay />
|
||
<VaulDrawer.Content
|
||
ref={ref}
|
||
className={cn(
|
||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-xl border bg-background pb-safe-bottom',
|
||
className,
|
||
)}
|
||
{...props}
|
||
>
|
||
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted" aria-hidden />
|
||
{children}
|
||
</VaulDrawer.Content>
|
||
</DrawerPortal>
|
||
));
|
||
DrawerContent.displayName = 'DrawerContent';
|
||
|
||
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||
<div className={cn('grid gap-1.5 p-4 text-left', className)} {...props} />
|
||
);
|
||
DrawerHeader.displayName = 'DrawerHeader';
|
||
|
||
const DrawerTitle = React.forwardRef<
|
||
React.ElementRef<typeof VaulDrawer.Title>,
|
||
React.ComponentPropsWithoutRef<typeof VaulDrawer.Title>
|
||
>(({ className, ...props }, ref) => (
|
||
<VaulDrawer.Title
|
||
ref={ref}
|
||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||
{...props}
|
||
/>
|
||
));
|
||
DrawerTitle.displayName = 'DrawerTitle';
|
||
|
||
const DrawerDescription = React.forwardRef<
|
||
React.ElementRef<typeof VaulDrawer.Description>,
|
||
React.ComponentPropsWithoutRef<typeof VaulDrawer.Description>
|
||
>(({ className, ...props }, ref) => (
|
||
<VaulDrawer.Description
|
||
ref={ref}
|
||
className={cn('text-sm text-muted-foreground', className)}
|
||
{...props}
|
||
/>
|
||
));
|
||
DrawerDescription.displayName = 'DrawerDescription';
|
||
|
||
export {
|
||
Drawer,
|
||
DrawerPortal,
|
||
DrawerOverlay,
|
||
DrawerTrigger,
|
||
DrawerClose,
|
||
DrawerContent,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
DrawerDescription,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/drawer.tsx
|
||
git commit -m "feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Create `<MoreSheet>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/layout/mobile/more-sheet.tsx`
|
||
|
||
- [ ] **Step 1: Create the More bottom sheet**
|
||
|
||
```tsx
|
||
// src/components/layout/mobile/more-sheet.tsx
|
||
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { usePathname } from 'next/navigation';
|
||
import {
|
||
Building2,
|
||
Bookmark,
|
||
Receipt,
|
||
FileText,
|
||
FolderOpen,
|
||
Mail,
|
||
Bell,
|
||
ShieldAlert,
|
||
BarChart3,
|
||
Settings,
|
||
Shield,
|
||
} from 'lucide-react';
|
||
|
||
import {
|
||
Drawer,
|
||
DrawerContent,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
DrawerClose,
|
||
} from '@/components/shared/drawer';
|
||
|
||
type MoreItem = {
|
||
label: string;
|
||
icon: typeof Building2;
|
||
segment: string;
|
||
};
|
||
|
||
const MORE_ITEMS: MoreItem[] = [
|
||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||
{ label: 'Documents', icon: FolderOpen, segment: 'documents' },
|
||
{ label: 'Email', icon: Mail, segment: 'email' },
|
||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||
{ label: 'Reminders', icon: Bell, segment: 'reminders' },
|
||
{ label: 'Settings', icon: Settings, segment: 'settings' },
|
||
{ label: 'Admin', icon: Shield, segment: 'admin' },
|
||
];
|
||
|
||
export function MoreSheet({
|
||
open,
|
||
onOpenChange,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (next: boolean) => void;
|
||
}) {
|
||
const pathname = usePathname();
|
||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||
return (
|
||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||
<DrawerContent>
|
||
<DrawerHeader>
|
||
<DrawerTitle>More</DrawerTitle>
|
||
</DrawerHeader>
|
||
<ul className="grid grid-cols-3 gap-1 px-3 pb-4">
|
||
{MORE_ITEMS.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<li key={item.segment}>
|
||
<DrawerClose asChild>
|
||
<Link
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
href={`/${portSlug}/${item.segment}` as any}
|
||
className="flex flex-col items-center justify-center gap-1.5 rounded-md py-4 text-xs text-foreground hover:bg-accent"
|
||
>
|
||
<Icon className="size-6 text-muted-foreground" aria-hidden />
|
||
<span className="font-medium">{item.label}</span>
|
||
</Link>
|
||
</DrawerClose>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/layout/mobile/more-sheet.tsx
|
||
git commit -m "feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Create `<MobileLayout>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/layout/mobile/mobile-layout.tsx`
|
||
|
||
- [ ] **Step 1: Create the mobile layout shell**
|
||
|
||
```tsx
|
||
// src/components/layout/mobile/mobile-layout.tsx
|
||
'use client';
|
||
|
||
import { useState, type ReactNode } from 'react';
|
||
|
||
import { MobileLayoutProvider } from './mobile-layout-provider';
|
||
import { MobileTopbar } from './mobile-topbar';
|
||
import { MobileBottomTabs } from './mobile-bottom-tabs';
|
||
import { MoreSheet } from './more-sheet';
|
||
|
||
/**
|
||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||
* bar. Renders only when CSS reveals it (data-shell="mobile") — both shells
|
||
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
|
||
* derive the active port slug from the URL themselves, so this layout takes
|
||
* no portSlug prop.
|
||
*/
|
||
export function MobileLayout({ children }: { children: ReactNode }) {
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
|
||
return (
|
||
<div data-shell="mobile" className="min-h-screen bg-background">
|
||
<MobileLayoutProvider>
|
||
<MobileTopbar />
|
||
<main className="pt-[calc(52px+env(safe-area-inset-top))] pb-[calc(56px+env(safe-area-inset-bottom))] min-h-screen">
|
||
{children}
|
||
</main>
|
||
<MobileBottomTabs onMoreClick={() => setMoreOpen(true)} />
|
||
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
|
||
</MobileLayoutProvider>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/layout/mobile/mobile-layout.tsx
|
||
git commit -m "feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Wire `<MobileLayout>` into the dashboard layout
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/app/(dashboard)/layout.tsx`
|
||
|
||
- [ ] **Step 1: Wrap the existing shell in a `data-shell="desktop"` div, render `<MobileLayout>` alongside it**
|
||
|
||
Change the return JSX:
|
||
|
||
```tsx
|
||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||
|
||
// ...
|
||
|
||
return (
|
||
<QueryProvider>
|
||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||
<PermissionsProvider>
|
||
<SocketProvider>
|
||
{/* Desktop shell — hidden by CSS on mobile */}
|
||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||
<Sidebar
|
||
portRoles={portRoles}
|
||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||
user={{
|
||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||
email: session.user.email,
|
||
}}
|
||
/>
|
||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||
<Topbar
|
||
ports={ports}
|
||
user={{
|
||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||
email: session.user.email,
|
||
}}
|
||
/>
|
||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile shell — hidden by CSS on desktop */}
|
||
<MobileLayout>{children}</MobileLayout>
|
||
</SocketProvider>
|
||
</PermissionsProvider>
|
||
</PortProvider>
|
||
</QueryProvider>
|
||
);
|
||
```
|
||
|
||
Note: `children` is rendered TWICE (once in each shell). React handles this fine because only one is visible. `<PortProvider>` keeps both shells in sync via context.
|
||
|
||
- [ ] **Step 2: Remove the legacy mobile-drawer hamburger from `<Sidebar>`**
|
||
|
||
The existing `<Sidebar>` component renders both the desktop sidebar (`hidden md:flex`) and a mobile drawer with a hamburger button (`md:hidden fixed top-3 left-3`). With the new mobile shell, the mobile drawer is dead weight — there's no `md:hidden` zone visible anymore (we hide the entire desktop shell on mobile via `data-form-factor`).
|
||
|
||
Open `src/components/layout/sidebar.tsx`. Find the `<Sheet>` block at the end of the component (the one with `<SheetTrigger asChild>` + `Menu` icon — currently lines ~384-407). Delete that entire block plus the surrounding `<>` fragment — leaving only the `<aside>` desktop sidebar as the component's return. Also remove the now-unused imports: `Sheet`, `SheetContent`, `SheetTrigger`, `Menu`, `Button`.
|
||
|
||
(Keep the rest of the file untouched — `<SidebarContent>` is still used by the desktop `<aside>`.)
|
||
|
||
- [ ] **Step 3: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Verify desktop still works**
|
||
|
||
Open `http://localhost:3000/port-nimara` in a desktop browser. Expected: same desktop shell as before — sidebar on left, topbar on top.
|
||
|
||
- [ ] **Step 5: Verify mobile shell appears**
|
||
|
||
Open the same URL in Chrome devtools with iPhone 14 Pro emulation (393×852). Expected: bottom tabs visible at the bottom, compact topbar at the top, no desktop sidebar visible. Tap "More" — bottom sheet drawer slides up.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add 'src/app/(dashboard)/layout.tsx' src/components/layout/sidebar.tsx
|
||
git commit -m "feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Create `<PageHeader>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/page-header.tsx`
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```tsx
|
||
// src/components/shared/page-header.tsx
|
||
import type { ReactNode } from 'react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
/**
|
||
* Page header used on top of every dashboard page. Truncates the title to one
|
||
* line. On mobile, hides the subtitle when an actions row is present (to
|
||
* preserve vertical space) and stacks actions below the title; on desktop
|
||
* (sm+), title and actions sit on one line with subtitle below.
|
||
*/
|
||
export function PageHeader({
|
||
title,
|
||
subtitle,
|
||
actions,
|
||
className,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
actions?: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<header
|
||
className={cn(
|
||
'mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-start sm:justify-between',
|
||
className,
|
||
)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<h1 className="truncate text-xl font-semibold text-foreground sm:text-2xl">{title}</h1>
|
||
{subtitle ? (
|
||
<p className={cn('mt-1 text-sm text-muted-foreground', actions && 'hidden sm:block')}>
|
||
{subtitle}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/page-header.tsx
|
||
git commit -m "feat(mobile): add PageHeader primitive with mobile-aware action stacking + subtitle hiding"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Create `<ActionRow>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/action-row.tsx`
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```tsx
|
||
// src/components/shared/action-row.tsx
|
||
import type { ReactNode } from 'react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
/**
|
||
* Chip-row action group used on detail-page headers. On mobile (`< sm`), the
|
||
* row scrolls horizontally with snap-x, no overflow clipping. On desktop, it
|
||
* wraps freely.
|
||
*/
|
||
export function ActionRow({ children, className }: { children: ReactNode; className?: string }) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex gap-2',
|
||
'overflow-x-auto snap-x snap-mandatory scroll-smooth -mx-3 px-3 sm:mx-0 sm:px-0',
|
||
'sm:flex-wrap sm:overflow-visible',
|
||
'[&>*]:snap-start [&>*]:shrink-0 sm:[&>*]:snap-none',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/action-row.tsx
|
||
git commit -m "feat(mobile): add ActionRow with horizontal-scroll-snap on mobile, wrap on desktop"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Create `<DetailPageShell>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/detail-page-shell.tsx`
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```tsx
|
||
// src/components/shared/detail-page-shell.tsx
|
||
'use client';
|
||
|
||
import type { ReactNode } from 'react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
/**
|
||
* Wrapper for entity detail pages (clients, yachts, companies, etc.). Renders:
|
||
* - sticky compact header (entity name + status pill)
|
||
* - the children (existing tab dropdown selector + tab body)
|
||
* - optional sticky bottom action shelf, pinned above the bottom tab bar on
|
||
* mobile (`pb-[calc(56px+env(safe-area-inset-bottom))]` content padding).
|
||
*/
|
||
export function DetailPageShell({
|
||
entityName,
|
||
status,
|
||
children,
|
||
bottomActions,
|
||
className,
|
||
}: {
|
||
entityName: string;
|
||
status?: ReactNode;
|
||
children: ReactNode;
|
||
bottomActions?: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div className={cn('flex flex-col min-h-full', className)}>
|
||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 sm:px-6">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<h2 className="truncate text-lg font-semibold text-foreground">{entityName}</h2>
|
||
{status ? <div className="ml-auto shrink-0">{status}</div> : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn('flex-1 px-4 py-4 sm:px-6 sm:py-6', bottomActions && 'pb-24 sm:pb-6')}>
|
||
{children}
|
||
</div>
|
||
|
||
{bottomActions ? (
|
||
<div
|
||
className={cn(
|
||
'sm:hidden',
|
||
'fixed inset-x-0 bottom-[calc(56px+env(safe-area-inset-bottom))] z-30',
|
||
'border-t border-border bg-background/95 backdrop-blur px-4 py-3',
|
||
'flex items-center gap-2',
|
||
)}
|
||
>
|
||
{bottomActions}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/detail-page-shell.tsx
|
||
git commit -m "feat(mobile): add DetailPageShell with sticky header + mobile sticky-action shelf"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: Create `<DataView>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/data-view.tsx`
|
||
|
||
- [ ] **Step 1: Create the data view**
|
||
|
||
```tsx
|
||
// src/components/shared/data-view.tsx
|
||
'use client';
|
||
|
||
import type { ReactNode } from 'react';
|
||
import { flexRender, type Table as TanstackTable, type Row } from '@tanstack/react-table';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
|
||
/**
|
||
* Renders the same TanStack `Table` instance as a desktop table on `lg+` and
|
||
* as a card list on mobile, using `cardRender(row)` for the per-row card body.
|
||
*
|
||
* Filters and sort live above the rendered rows; callers pass them as
|
||
* `headerSlot`. On desktop the rows are sortable via column header clicks
|
||
* (TanStack default); on mobile, sort is exposed via a `<Drawer>` opened by
|
||
* the caller's headerSlot — this primitive doesn't enforce a sort UI.
|
||
*/
|
||
export function DataView<TData>({
|
||
table,
|
||
cardRender,
|
||
headerSlot,
|
||
emptyState,
|
||
className,
|
||
}: {
|
||
table: TanstackTable<TData>;
|
||
cardRender: (row: Row<TData>) => ReactNode;
|
||
headerSlot?: ReactNode;
|
||
emptyState?: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
const rows = table.getRowModel().rows;
|
||
const isEmpty = rows.length === 0;
|
||
|
||
return (
|
||
<div className={cn('flex flex-col gap-3', className)}>
|
||
{headerSlot ? <div>{headerSlot}</div> : null}
|
||
|
||
{/* Desktop: TanStack table */}
|
||
<div className="hidden lg:block">
|
||
<Table>
|
||
<TableHeader>
|
||
{table.getHeaderGroups().map((group) => (
|
||
<TableRow key={group.id}>
|
||
{group.headers.map((header) => (
|
||
<TableHead key={header.id}>
|
||
{header.isPlaceholder
|
||
? null
|
||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isEmpty ? (
|
||
<TableRow>
|
||
<TableCell colSpan={table.getAllColumns().length} className="text-center py-8">
|
||
{emptyState ?? 'No results.'}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
rows.map((row) => (
|
||
<TableRow key={row.id}>
|
||
{row.getVisibleCells().map((cell) => (
|
||
<TableCell key={cell.id}>
|
||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* Mobile: card list */}
|
||
<ul className="lg:hidden flex flex-col gap-2">
|
||
{isEmpty ? (
|
||
<li className="rounded-md border border-border bg-card p-4 text-center text-sm text-muted-foreground">
|
||
{emptyState ?? 'No results.'}
|
||
</li>
|
||
) : (
|
||
rows.map((row) => (
|
||
<li key={row.id} className="rounded-md border border-border bg-card p-3 shadow-xs">
|
||
{cardRender(row)}
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors. (The `Table`/`TableHead`/etc. imports already exist at `src/components/ui/table.tsx`.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/data-view.tsx
|
||
git commit -m "feat(mobile): add DataView (TanStack table on lg+, card list below) with cardRender callback"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Create `<FilterChips>`
|
||
|
||
**Files:**
|
||
|
||
- Create: `src/components/shared/filter-chips.tsx`
|
||
|
||
- [ ] **Step 1: Create the chip-row filter UI**
|
||
|
||
```tsx
|
||
// src/components/shared/filter-chips.tsx
|
||
'use client';
|
||
|
||
import { X, Filter } from 'lucide-react';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export type ActiveFilter = {
|
||
id: string;
|
||
label: string;
|
||
/** Compact value rendered on the chip after the label (e.g. ": Active"). */
|
||
value?: string;
|
||
};
|
||
|
||
/**
|
||
* Horizontal chip row for active filters, with an "Add filter" trigger that
|
||
* the caller wires to a `<Drawer>`. Active chips are dismissable via the X
|
||
* button. Scrolls horizontally on mobile when there are many filters.
|
||
*/
|
||
export function FilterChips({
|
||
active,
|
||
onRemove,
|
||
onAddClick,
|
||
className,
|
||
}: {
|
||
active: ActiveFilter[];
|
||
onRemove: (id: string) => void;
|
||
onAddClick: () => void;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex items-center gap-2 overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 sm:flex-wrap sm:overflow-visible',
|
||
className,
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={onAddClick}
|
||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-input bg-background px-3 h-9 text-sm font-medium text-foreground hover:bg-accent"
|
||
>
|
||
<Filter className="size-4" aria-hidden />
|
||
Add filter
|
||
</button>
|
||
|
||
{active.map((filter) => (
|
||
<span
|
||
key={filter.id}
|
||
className="shrink-0 inline-flex items-center gap-1 rounded-full bg-secondary text-secondary-foreground px-3 h-9 text-sm font-medium"
|
||
>
|
||
<span>
|
||
{filter.label}
|
||
{filter.value ? `: ${filter.value}` : ''}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(filter.id)}
|
||
aria-label={`Remove ${filter.label} filter`}
|
||
className="-mr-1 inline-flex size-6 items-center justify-center rounded-full hover:bg-secondary-foreground/10"
|
||
>
|
||
<X className="size-3.5" aria-hidden />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/shared/filter-chips.tsx
|
||
git commit -m "feat(mobile): add FilterChips primitive (horizontal chip row with Add-filter trigger)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Add anchor device descriptors fixture
|
||
|
||
**Files:**
|
||
|
||
- Create: `tests/e2e/fixtures/devices.ts`
|
||
|
||
- [ ] **Step 1: Create the shared fixture**
|
||
|
||
```ts
|
||
// tests/e2e/fixtures/devices.ts
|
||
/**
|
||
* Shared device descriptors for mobile/tablet Playwright runs.
|
||
*
|
||
* Anchors per docs/superpowers/specs/2026-04-29-mobile-optimization-design.md §2.1:
|
||
* narrowest, common, current Pro, current Pro Max.
|
||
*/
|
||
export const IPHONE_DEVICES = {
|
||
iphoneSe: {
|
||
name: 'iPhone SE 3 (375×667)',
|
||
viewport: { width: 375, height: 667 },
|
||
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',
|
||
},
|
||
iphone16: {
|
||
name: 'iPhone 15/16 (393×852)',
|
||
viewport: { width: 393, height: 852 },
|
||
deviceScaleFactor: 3,
|
||
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',
|
||
},
|
||
iphone16Pro: {
|
||
name: 'iPhone 16/17 Pro (402×874)',
|
||
viewport: { width: 402, height: 874 },
|
||
deviceScaleFactor: 3,
|
||
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',
|
||
},
|
||
iphoneProMax: {
|
||
name: 'iPhone 16/17 Pro Max (440×956)',
|
||
viewport: { width: 440, height: 956 },
|
||
deviceScaleFactor: 3,
|
||
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',
|
||
},
|
||
} as const;
|
||
```
|
||
|
||
- [ ] **Step 2: Refactor the audit spec to use the fixture**
|
||
|
||
In `tests/e2e/audit/mobile.spec.ts`, replace the inline `VIEWPORTS` array with imports from the fixture:
|
||
|
||
```ts
|
||
import { IPHONE_DEVICES } from '../fixtures/devices';
|
||
|
||
const VIEWPORTS = [
|
||
{ name: 'iphone-se', ...IPHONE_DEVICES.iphoneSe },
|
||
{ name: 'iphone-16', ...IPHONE_DEVICES.iphone16 },
|
||
{ name: 'iphone-16-pro', ...IPHONE_DEVICES.iphone16Pro },
|
||
{ name: 'iphone-pro-max', ...IPHONE_DEVICES.iphoneProMax },
|
||
] as const;
|
||
```
|
||
|
||
(Adjust the `context` creation in the audit spec to spread the descriptor: `await browser.newContext({ ...vp, viewport: vp.viewport });` — the existing field-by-field spread will need cleanup; the fixture has all fields the descriptor needs.)
|
||
|
||
- [ ] **Step 3: Verify typecheck**
|
||
|
||
Run: `pnpm exec tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Smoke-check the spec still loads**
|
||
|
||
Run: `pnpm exec playwright test tests/e2e/audit/mobile.spec.ts --project=mobile-audit --list 2>&1 | head -10`
|
||
Expected: Playwright lists the audit test ("mobile audit — every page at iPhone viewports") without import errors. Full re-run happens in Task 24 — no need to burn ~14 min twice.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/e2e/fixtures/devices.ts tests/e2e/audit/mobile.spec.ts
|
||
git commit -m "feat(test): extract anchor iPhone device descriptors to shared fixture"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 23: Visual snapshot for the mobile shell
|
||
|
||
**Files:**
|
||
|
||
- Create: `tests/e2e/visual/mobile-shell.spec.ts`
|
||
|
||
This locks in the mobile shell appearance so future per-page work doesn't regress the chrome.
|
||
|
||
- [ ] **Step 1: Create the visual spec**
|
||
|
||
```ts
|
||
// tests/e2e/visual/mobile-shell.spec.ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { IPHONE_DEVICES } from '../fixtures/devices';
|
||
import { USERS } from '../smoke/global-setup';
|
||
|
||
test.use({
|
||
...IPHONE_DEVICES.iphone16,
|
||
});
|
||
|
||
test('mobile shell renders bottom tabs and compact topbar on dashboard', async ({ page }) => {
|
||
// Sign in via the public login page
|
||
await page.goto('/login');
|
||
await page.fill('input[type="email"], input[name="email"]', USERS.super_admin.email);
|
||
await page.fill('input[type="password"], input[name="password"]', USERS.super_admin.password);
|
||
await page.click('button[type="submit"]');
|
||
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||
|
||
// Navigate to a known dashboard page
|
||
await page.goto('/port-nimara/dashboard');
|
||
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
||
await page.waitForTimeout(800);
|
||
|
||
await expect(page).toHaveScreenshot('mobile-shell-dashboard.png', { fullPage: true });
|
||
});
|
||
|
||
test('More sheet opens from bottom tabs', async ({ page }) => {
|
||
await page.goto('/login');
|
||
await page.fill('input[type="email"], input[name="email"]', USERS.super_admin.email);
|
||
await page.fill('input[type="password"], input[name="password"]', USERS.super_admin.password);
|
||
await page.click('button[type="submit"]');
|
||
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||
|
||
await page.goto('/port-nimara/dashboard');
|
||
await page.click('button:has-text("More")');
|
||
await page.waitForTimeout(500); // vaul slide animation
|
||
|
||
await expect(page).toHaveScreenshot('mobile-shell-more-sheet.png', { fullPage: true });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Generate baselines**
|
||
|
||
Run: `pnpm exec playwright test tests/e2e/visual/mobile-shell.spec.ts --project=visual --update-snapshots`
|
||
Expected: 2 snapshots generated under `tests/e2e/visual/mobile-shell.spec.ts-snapshots/`.
|
||
|
||
- [ ] **Step 3: Re-run to verify diffs are clean**
|
||
|
||
Run: `pnpm exec playwright test tests/e2e/visual/mobile-shell.spec.ts --project=visual`
|
||
Expected: 2 tests pass with no diffs.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/e2e/visual/mobile-shell.spec.ts tests/e2e/visual/mobile-shell.spec.ts-snapshots/
|
||
git commit -m "test(visual): add mobile shell snapshot baselines (dashboard + more sheet)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: Re-run full mobile audit to verify foundation results
|
||
|
||
**Files:** none (validation only)
|
||
|
||
- [ ] **Step 1: Run the audit headed so user can watch**
|
||
|
||
Run: `pnpm exec playwright test --project=mobile-audit 2>&1 | tee .audit/mobile/run.log`
|
||
Expected: ~14 min headed run, 4 viewports, all routes captured to `.audit/mobile/{iphone-se,iphone-16,iphone-16-pro,iphone-pro-max}/`. Index regenerated at `.audit/mobile/index.md`.
|
||
|
||
- [ ] **Step 2: Verify topbar overflow is gone on a known broken page**
|
||
|
||
Open `.audit/mobile/iphone-16/dash-overview.png`. Compare to the pre-foundation version (you saw the broken sign-out clip earlier). Expected: bottom tab bar visible at the bottom, compact topbar at the top, no clipped sign-out button, no horizontal scroll.
|
||
|
||
- [ ] **Step 3: Verify the bundle size is in the expected range**
|
||
|
||
Run: `pnpm build 2>&1 | tee .audit/mobile/build.log`
|
||
Expected: build succeeds. Look at the per-route First Load JS values for `(dashboard)` routes — should be no more than ~50KB larger than pre-foundation. If materially higher, flag in PR description for bundle-budget conversation.
|
||
|
||
- [ ] **Step 4: Commit run log + final touchups**
|
||
|
||
The audit screenshots are gitignored, so just commit any incidental cleanup the audit revealed (e.g., a TypeScript warning that surfaced).
|
||
|
||
```bash
|
||
git status
|
||
git add <any incidental fixes>
|
||
git commit -m "chore(mobile): foundation PR validation pass — full audit + build verification"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
After implementation, verify the following before opening the PR:
|
||
|
||
- [ ] `pnpm exec tsc --noEmit` is clean
|
||
- [ ] `pnpm exec eslint .` is clean
|
||
- [ ] `pnpm exec vitest run` passes (existing 826 + 9 new = 835 tests)
|
||
- [ ] `pnpm exec playwright test --project=visual` passes (existing baselines + 2 new mobile shell shots)
|
||
- [ ] `pnpm exec playwright test --project=smoke` still passes (no per-page regression at desktop viewport)
|
||
- [ ] `pnpm build` succeeds with no new warnings
|
||
- [ ] `.audit/mobile/iphone-16/*.png` shows no clipped topbar or horizontal scroll on any page
|
||
- [ ] Manual desktop sanity check: open the app at 1440×900, every page looks identical to pre-foundation
|
||
|
||
## Dependencies between tasks
|
||
|
||
Tasks 1–8 are independent infrastructure — can be done in any order or parallel. Tasks 9–22 depend on those (provide hook + primitives + vaul). Task 23 depends on the mobile shell being mounted (Task 16). Task 24 is the final validation — depends on everything.
|
||
|
||
Recommended commit order matches the task order above, since each step is self-contained and TDD-friendly.
|