From fbb1f1f366d00f338b64e6ebc2ec2ef761871ea6 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 29 Apr 2026 13:49:38 +0200 Subject: [PATCH] =?UTF-8?q?scaffold(mobile):=20branch=20setup=20=E2=80=94?= =?UTF-8?q?=20audit=20harness,=20spec,=20plan,=20gitignore=20+=20client-po?= =?UTF-8?q?rtal=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 6 + client-portal | 1 - .../plans/2026-04-29-mobile-foundation.md | 1918 +++++++++++++++++ .../2026-04-29-mobile-optimization-design.md | 189 ++ playwright.config.ts | 16 + tests/e2e/audit/mobile.spec.ts | 380 ++++ 6 files changed, 2509 insertions(+), 1 deletion(-) delete mode 160000 client-portal create mode 100644 docs/superpowers/plans/2026-04-29-mobile-foundation.md create mode 100644 docs/superpowers/specs/2026-04-29-mobile-optimization-design.md create mode 100644 tests/e2e/audit/mobile.spec.ts diff --git a/.gitignore b/.gitignore index f13acf0..69158e0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ eoi/ # Ad-hoc screenshots / scratch artifacts at repo root /*.png + +# Legacy Nuxt portal — kept on disk for reference, not tracked here +/client-portal/ + +# Mobile audit screenshots — generated locally, regenerable +/.audit/ diff --git a/client-portal b/client-portal deleted file mode 160000 index 84f89f9..0000000 --- a/client-portal +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 84f89f9409c9510d628585b69c3a1acf3c9d8e75 diff --git a/docs/superpowers/plans/2026-04-29-mobile-foundation.md b/docs/superpowers/plans/2026-04-29-mobile-foundation.md new file mode 100644 index 0000000..d02732a --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-mobile-foundation.md @@ -0,0 +1,1918 @@ +# 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 (``, ``, ``, ``, ``, ``) 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 `` but `src/components/ui/sheet.tsx` already exists (shadcn slide-from-side using Radix Dialog). The plan uses `` 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 `` everywhere existing `` 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 `` 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 `` alongside the existing ``/``; 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 ( + + + {children} + + + + ); +} +``` + +(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: `` and `` are present in ``. + +- [ ] **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 ( + + + {children} + + + + ); +} +``` + +- [ ] **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 `` element. Expected: `` (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 `` (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) => void; +}; + +const MobileChromeContext = createContext(null); + +export function MobileLayoutProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + title: null, + primaryAction: null, + showBackButton: false, + }); + + const value = useMemo( + () => ({ + ...state, + setChrome: (next) => setState((prev) => ({ ...prev, ...next })), + }), + [state], + ); + + return {children}; +} + +/** + * Page-level hook to push a title / back-button / primary action into the + * mobile topbar. The provider is only mounted by ``, 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 '); + } + 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 `` + +**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 ( +
+ {showBackButton ? ( + + ) : ( +
+ )} + +

+ {title ?? fallbackTitle} +

+ +
{primaryAction}
+
+ ); +} +``` + +- [ ] **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 `` + +**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]/, 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 ( + + ); +} +``` + +- [ ] **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 `` (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) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = VaulDrawer.Trigger; +const DrawerPortal = VaulDrawer.Portal; +const DrawerClose = VaulDrawer.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = 'DrawerOverlay'; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = 'DrawerTitle'; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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 `` + +**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 ( + + + + More + +
    + {MORE_ITEMS.map((item) => { + const Icon = item.icon; + return ( +
  • + + + + {item.label} + + +
  • + ); + })} +
+
+
+ ); +} +``` + +- [ ] **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 `` + +**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 ( +
+ + +
+ {children} +
+ setMoreOpen(true)} /> + +
+
+ ); +} +``` + +- [ ] **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 `` into the dashboard layout + +**Files:** + +- Modify: `src/app/(dashboard)/layout.tsx` + +- [ ] **Step 1: Wrap the existing shell in a `data-shell="desktop"` div, render `` alongside it** + +Change the return JSX: + +```tsx +import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; + +// ... + +return ( + + + + + {/* Desktop shell — hidden by CSS on mobile */} +
+ +
+ +
{children}
+
+
+ + {/* Mobile shell — hidden by CSS on desktop */} + {children} +
+
+
+
+); +``` + +Note: `children` is rendered TWICE (once in each shell). React handles this fine because only one is visible. `` keeps both shells in sync via context. + +- [ ] **Step 2: Remove the legacy mobile-drawer hamburger from ``** + +The existing `` 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 `` block at the end of the component (the one with `` + `Menu` icon — currently lines ~384-407). Delete that entire block plus the surrounding `<>` fragment — leaving only the `