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

59 KiB
Raw Blame History

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

// 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
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):

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

// 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
// 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:

import { headers } from 'next/headers';
import { classifyFormFactor } from '@/lib/form-factor';

Change the RootLayout to async and read the form factor:

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
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:

/* ─── 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
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

// 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
// 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
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
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:

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:

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:

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
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:

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
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.

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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
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:

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

// 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
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

// 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
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

// 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
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

// 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
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

// 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
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

// 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:

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
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
// 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
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).

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.