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

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

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

1919 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 18 are independent infrastructure — can be done in any order or parallel. Tasks 922 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.