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>
59 KiB
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 hooksrc/lib/form-factor.ts— pure UA-classification function (unit-testable)src/components/layout/mobile/mobile-layout.tsxsrc/components/layout/mobile/mobile-topbar.tsxsrc/components/layout/mobile/mobile-bottom-tabs.tsxsrc/components/layout/mobile/more-sheet.tsxsrc/components/layout/mobile/mobile-layout-provider.tsxsrc/components/shared/drawer.tsx— vaul wrapper (was<Sheet>in spec)src/components/shared/data-view.tsxsrc/components/shared/page-header.tsxsrc/components/shared/action-row.tsxsrc/components/shared/detail-page-shell.tsxsrc/components/shared/filter-chips.tsxtests/unit/lib/form-factor.test.ts— vitesttests/unit/hooks/use-is-mobile.test.ts— vitesttests/e2e/fixtures/devices.ts— anchor device descriptorstests/e2e/visual/mobile-shell.spec.ts— playwright visual snapshot for the mobile shellpublic/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— addviewportexport, theme-color, body data-form-factor, apple-mobile-web-app metassrc/app/(dashboard)/layout.tsx— render<MobileLayout>alongside the existing<Sidebar>/<Topbar>; CSS hides the inactive shellsrc/app/globals.css— add[data-form-factor]reveal/hide rules + media-query fallbacktailwind.config.ts— addsafespacing utilities (pt-safe/pb-safe/etc.)src/components/ui/button.tsx— bumpsize: defaultfromh-9toh-11(andsm/lg/iconproportionally)src/components/ui/input.tsx— bump fromh-9toh-11, dropmd:text-sm(keep 16px to prevent iOS zoom)src/components/ui/textarea.tsx— dropmd:text-sm(keep 16px)src/components/ui/dialog.tsx— adjustDialogContentto render full-screen on mobile (inset-0 max-w-full sm:inset-auto sm:max-w-lg)package.json— addvauldependency
Task 1: Add viewport export, theme-color, and PWA metas to root layout
Files:
-
Modify:
src/app/layout.tsx -
Step 1: Add the
viewportexport 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(viapnpm 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 --noEmitis cleanpnpm exec eslint .is cleanpnpm exec vitest runpasses (existing 826 + 9 new = 835 tests)pnpm exec playwright test --project=visualpasses (existing baselines + 2 new mobile shell shots)pnpm exec playwright test --project=smokestill passes (no per-page regression at desktop viewport)pnpm buildsucceeds with no new warnings.audit/mobile/iphone-16/*.pngshows no clipped topbar or horizontal scroll on any page- Manual desktop sanity check: open the app at 1440×900, every page looks identical to pre-foundation
Dependencies between tasks
Tasks 1–8 are independent infrastructure — can be done in any order or parallel. Tasks 9–22 depend on those (provide hook + primitives + vaul). Task 23 depends on the mobile shell being mounted (Task 16). Task 24 is the final validation — depends on everything.
Recommended commit order matches the task order above, since each step is self-contained and TDD-friendly.