# 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 `