# Mobile Responsive Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Make the frontend fully responsive with mobile-first bottom sheet + swipeable cards pattern, preserving all desktop functionality. **Architecture:** Mobile layout uses a full-screen map with a vaul-based bottom sheet (3 snap points) containing stats and property cards. All heavy features (filters, POI, tasks) use Sheet drawers. Desktop layout is untouched — all mobile changes gated behind `useIsMobile()`. New components: `MobileBottomSheet`, `SwipeableCardRow`, `PropertyCardCompact`, `MobileMenu`. **Tech Stack:** React 19, TypeScript, Tailwind CSS 4, vaul (new), Radix UI, Mapbox GL, react-virtuoso --- ### Task 1: Install vaul dependency **Files:** - Modify: `frontend/package.json` **Step 1: Install vaul** Run: `cd frontend && npm install vaul` **Step 2: Verify installation** Run: `cd frontend && node -e "require('vaul'); console.log('vaul OK')"` Expected: `vaul OK` **Step 3: Commit** ```bash git add frontend/package.json frontend/package-lock.json git commit -m "feat: add vaul drawer library for mobile bottom sheet" ``` --- ### Task 2: Create PropertyCardCompact component A ~280px fixed-width card for horizontal swipe browsing. Shows thumbnail, price, beds, sqm, price/sqm badge. **Files:** - Create: `frontend/src/components/PropertyCardCompact.tsx` **Step 1: Create the component** ```tsx import { Bed, Maximize2 } from 'lucide-react'; import type { PropertyProperties } from '@/types'; interface PropertyCardCompactProps { property: PropertyProperties; isActive?: boolean; isHighlighted?: boolean; avgPricePerSqm?: number; onClick?: () => void; } export function PropertyCardCompact({ property, isActive = false, isHighlighted = false, avgPricePerSqm, onClick, }: PropertyCardCompactProps) { const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1; const priceIndicator = isGoodDeal ? { color: 'text-green-600 bg-green-50', label: 'Good deal' } : isExpensive ? { color: 'text-red-600 bg-red-50', label: 'Above avg' } : null; return (
{/* Thumbnail */}
{property.photo_thumbnail && ( Property )}
{/* Details */}
£{property.total_price.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )}
{priceIndicator && ( {priceIndicator.label} )}
{property.rooms} {property.qm} m² £{property.qmprice}/m²
); } ``` **Step 2: Commit** ```bash git add frontend/src/components/PropertyCardCompact.tsx git commit -m "feat: add PropertyCardCompact for mobile swipeable cards" ``` --- ### Task 3: Create SwipeableCardRow component Horizontal scroll-snap container that renders `PropertyCardCompact` items. Tracks which card is centered and emits that index. **Files:** - Create: `frontend/src/components/SwipeableCardRow.tsx` **Step 1: Create the component** ```tsx import { useRef, useEffect, useCallback } from 'react'; import { PropertyCardCompact } from './PropertyCardCompact'; import type { PropertyFeature } from '@/types'; interface SwipeableCardRowProps { features: PropertyFeature[]; activeIndex: number; onActiveIndexChange: (index: number) => void; avgPricePerSqm: number; highlightedPropertyUrl?: string | null; onCardClick?: (feature: PropertyFeature) => void; } export function SwipeableCardRow({ features, activeIndex, onActiveIndexChange, avgPricePerSqm, highlightedPropertyUrl, onCardClick, }: SwipeableCardRowProps) { const scrollRef = useRef(null); const isScrollingProgrammatically = useRef(false); // Scroll to active index when it changes externally (e.g., from map marker tap) useEffect(() => { const container = scrollRef.current; if (!container) return; const cardWidth = 280 + 12; // card width + gap const targetScroll = activeIndex * cardWidth - (container.clientWidth - 280) / 2; isScrollingProgrammatically.current = true; container.scrollTo({ left: targetScroll, behavior: 'smooth' }); // Reset flag after scroll completes const timer = setTimeout(() => { isScrollingProgrammatically.current = false; }, 500); return () => clearTimeout(timer); }, [activeIndex]); // Detect which card is centered after user scrolls const handleScroll = useCallback(() => { if (isScrollingProgrammatically.current) return; const container = scrollRef.current; if (!container) return; const cardWidth = 280 + 12; const centerX = container.scrollLeft + container.clientWidth / 2; const newIndex = Math.round((centerX - 280 / 2) / cardWidth); const clampedIndex = Math.max(0, Math.min(newIndex, features.length - 1)); if (clampedIndex !== activeIndex) { onActiveIndexChange(clampedIndex); } }, [activeIndex, features.length, onActiveIndexChange]); // Debounced scroll handler const scrollTimerRef = useRef(null); const debouncedScroll = useCallback(() => { if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current); scrollTimerRef.current = window.setTimeout(handleScroll, 100); }, [handleScroll]); if (features.length === 0) return null; return (
{features.map((feature, index) => ( onCardClick?.(feature)} /> ))}
); } ``` **Step 2: Commit** ```bash git add frontend/src/components/SwipeableCardRow.tsx git commit -m "feat: add SwipeableCardRow with scroll-snap and map sync" ``` --- ### Task 4: Create MobileBottomSheet component vaul-based drawer with 3 snap points. Contains compact stats, swipeable cards (peek), and full list view (expanded). **Files:** - Create: `frontend/src/components/MobileBottomSheet.tsx` **Step 1: Create the component** ```tsx import { useState, useMemo, useCallback } from 'react'; import { Drawer } from 'vaul'; import { MapPin, PoundSterling } from 'lucide-react'; import { SwipeableCardRow } from './SwipeableCardRow'; import { ListView } from './ListView'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types'; interface MobileBottomSheetProps { listingData: GeoJSONFeatureCollection | null; onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void; highlightedPropertyUrl?: string | null; onActiveListingChange?: (feature: PropertyFeature | null) => void; poiMetricSelection?: { poiId: number; travelMode: string } | null; } function formatCurrency(value: number): string { if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`; return `£${Math.round(value)}`; } export function MobileBottomSheet({ listingData, onPropertyClick, highlightedPropertyUrl, onActiveListingChange, poiMetricSelection, }: MobileBottomSheetProps) { const [snap, setSnap] = useState("148px"); const [activeCardIndex, setActiveCardIndex] = useState(0); const features = listingData?.features ?? []; const stats = useMemo(() => { if (features.length === 0) return { count: 0, avgPrice: 0 }; const validPrices = features .map((f) => f.properties.total_price) .filter((p): p is number => typeof p === 'number' && p > 0); const avgPrice = validPrices.length > 0 ? validPrices.reduce((a, b) => a + b, 0) / validPrices.length : 0; return { count: features.length, avgPrice }; }, [features]); const avgPricePerSqm = useMemo(() => { const validPrices = features .map((f) => f.properties.qmprice) .filter((p): p is number => typeof p === 'number' && p > 0); return validPrices.length > 0 ? validPrices.reduce((a, b) => a + b, 0) / validPrices.length : 0; }, [features]); const handleActiveIndexChange = useCallback((index: number) => { setActiveCardIndex(index); if (features[index]) { onActiveListingChange?.(features[index]); } }, [features, onActiveListingChange]); const handleCardClick = useCallback((feature: PropertyFeature) => { window.open(feature.properties.url, '_blank', 'noopener,noreferrer'); onPropertyClick?.(feature.properties, feature.geometry.coordinates); }, [onPropertyClick]); const isExpanded = snap === "0.85"; return ( {/* Drag handle */}
{/* Compact stats (always visible) */}
{stats.count.toLocaleString()} listings
{stats.avgPrice > 0 && (
Avg: {formatCurrency(stats.avgPrice)}
)}
{/* Swipeable cards (visible at peek snap) */} {!isExpanded && features.length > 0 && ( )} {/* Full list view (visible at expanded snap) */} {isExpanded && listingData && features.length > 0 && (
)} ); } ``` **Step 2: Commit** ```bash git add frontend/src/components/MobileBottomSheet.tsx git commit -m "feat: add MobileBottomSheet with vaul drawer and 3 snap points" ``` --- ### Task 5: Create MobileMenu component Hamburger menu that opens a Sheet containing health indicator, task indicator, user email, and logout button. **Files:** - Create: `frontend/src/components/MobileMenu.tsx` **Step 1: Create the component** ```tsx import type { AuthUser } from '@/auth/types'; import type { TaskState } from '@/types'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet'; import { Button } from './ui/button'; import { Menu, LogOut } from 'lucide-react'; import { logout } from '@/auth/authService'; import { clearPasskeyUser } from '@/auth/passkeyService'; import { HealthIndicator } from './HealthIndicator'; import { TaskIndicator } from './TaskIndicator'; import { Separator } from './ui/separator'; interface MobileMenuProps { user: AuthUser; tasks: Record; activeTaskId: string | null; isConnected: boolean; onCancelTask: (taskId: string) => Promise; onClearAllTasks: () => Promise; onTaskCompleted?: () => void; } export function MobileMenu({ user, tasks, activeTaskId, isConnected, onCancelTask, onClearAllTasks, onTaskCompleted, }: MobileMenuProps) { const handleLogout = async () => { if (user.provider === 'passkey') { clearPasskeyUser(); window.location.reload(); } else { await logout(); } }; return ( Menu
{/* User info */}
{user.email}
{/* Health */}
System Health
{/* Tasks */}
Tasks
{/* Logout */}
); } ``` **Step 2: Commit** ```bash git add frontend/src/components/MobileMenu.tsx git commit -m "feat: add MobileMenu hamburger component for mobile header" ``` --- ### Task 6: Update Header for mobile layout On mobile, hide health indicator, task indicator, and user email/logout — replace with hamburger menu. **Files:** - Modify: `frontend/src/components/Header.tsx` **Step 1: Modify Header.tsx** Import `MobileMenu` and `useIsMobile`. On mobile, render just logo + MobileMenu. On desktop, keep existing layout. ```tsx import type { AuthUser } from '@/auth/types'; import type { TaskState } from '@/types'; import { Button } from './ui/button'; import { Separator } from './ui/separator'; import { LogOut, Home } from 'lucide-react'; import { logout } from '@/auth/authService'; import { clearPasskeyUser } from '@/auth/passkeyService'; import { HealthIndicator } from './HealthIndicator'; import { TaskIndicator } from './TaskIndicator'; import { MobileMenu } from './MobileMenu'; import { useIsMobile } from '@/hooks/use-mobile'; interface HeaderProps { user: AuthUser; activeFilterCount?: number; isLoading?: boolean; onToggleFilters?: () => void; showFilterToggle?: boolean; // Task progress (unified) tasks: Record; activeTaskId: string | null; isConnected: boolean; onCancelTask: (taskId: string) => Promise; onClearAllTasks: () => Promise; onTaskCompleted?: () => void; } export function Header({ user, tasks, activeTaskId, isConnected, onCancelTask, onClearAllTasks, onTaskCompleted, }: HeaderProps) { const isMobile = useIsMobile(); const handleLogout = async () => { if (user.provider === 'passkey') { clearPasskeyUser(); window.location.reload(); } else { await logout(); } }; return (
{/* Logo / Brand */}
Wrongmove
{/* Desktop-only items */} {!isMobile && ( <> )} {/* Spacer */}
{/* Mobile: hamburger menu */} {isMobile && ( )} {/* Desktop: user menu */} {!isMobile && (
{user.email}
)}
); } ``` **Step 2: Commit** ```bash git add frontend/src/components/Header.tsx git commit -m "feat: add mobile hamburger menu to Header, hide desktop items on mobile" ``` --- ### Task 7: Update App.tsx with mobile layout The main layout change. On mobile: full-screen map + MobileBottomSheet + FABs. On desktop: unchanged. **Files:** - Modify: `frontend/src/App.tsx` **Step 1: Modify App.tsx** Add imports for `useIsMobile`, `MobileBottomSheet`, and `MapPin` icon. Add state for `activeCardFeature`. Add a `renderMobileLayout()` function. In the return, conditionally render mobile vs desktop layout. Key changes to `App.tsx`: 1. Add imports at top: ```tsx import { useIsMobile } from '@/hooks/use-mobile'; import { MobileBottomSheet } from './components/MobileBottomSheet'; import { MapPin as MapPinIcon } from 'lucide-react'; ``` 2. Inside `App()`, after existing state declarations, add: ```tsx const isMobile = useIsMobile(); ``` 3. Add a handler for when the active card changes in the bottom sheet (for map sync): ```tsx const [activeCardFeature, setActiveCardFeature] = useState(null); const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => { setActiveCardFeature(feature); }, []); ``` 4. Add mobile layout renderer after `renderMainContent`: ```tsx const renderMobileLayout = () => ( <> {/* Full-screen map */}
{/* Streaming Progress Bar */}
{processedListingData && processedListingData.features.length > 0 ? ( ) : (
{isLoading ? ( <>
🏠

Loading...

) : ( <>
🏠

Property Explorer

Use the filter button to find properties.

)}
)}
{/* FABs - above bottom sheet */}
{/* Bottom Sheet */} {processedListingData && processedListingData.features.length > 0 && ( )} ); ``` 5. Replace the return statement to branch on `isMobile`: ```tsx return (
{/* Header */}
{ const result = await clearAllTasks(); if (result) handleTaskCancelled(); return result; }} onTaskCompleted={handleTaskCompleted} /> {isMobile ? ( renderMobileLayout() ) : ( /* Existing desktop layout (the current flex-1 flex div) */
{/* Desktop filter sidebar */}
{/* ... existing desktop filter panel ... */}
{/* Main View Area */}
{renderMainContent()}
{processedListingData && processedListingData.features.length > 0 && (
)}
)}
); ``` Note: The desktop path should keep the existing content from lines 416-506 of the current `App.tsx` exactly. The mobile filter FAB (currently at lines 447-482) should be removed from the desktop path since mobile now uses `renderMobileLayout()`. **Step 2: Commit** ```bash git add frontend/src/App.tsx git commit -m "feat: add mobile layout with full-screen map, bottom sheet, and FABs" ``` --- ### Task 8: Update Map legend position for mobile Move legend to top-left on mobile to avoid overlapping with FABs (which are bottom-right). **Files:** - Modify: `frontend/src/assets/Map.css` **Step 1: Update Map.css mobile media query** Replace the existing `@media (max-width: 768px)` block: ```css @media (max-width: 768px) { #legend { width: 70px; padding: 6px; min-height: 200px; top: 10px; right: auto; left: 10px; font-size: 10px; } .mapboxgl-popup-content { max-width: 90vw !important; } .mapboxgl-popup-close-button { font-size: 24px; padding: 6px 10px; min-width: 44px; min-height: 44px; } } ``` **Step 2: Commit** ```bash git add frontend/src/assets/Map.css git commit -m "feat: reposition map legend to top-left on mobile, enlarge close button" ``` --- ### Task 9: Update TaskProgressDrawer for mobile On mobile, render the task progress drawer from the bottom instead of the right. **Files:** - Modify: `frontend/src/components/TaskProgressDrawer.tsx` **Step 1: Modify TaskProgressDrawer.tsx** Import `useIsMobile` and conditionally set the Sheet side: Add import: ```tsx import { useIsMobile } from '@/hooks/use-mobile'; ``` Inside the `TaskProgressDrawer` component, add: ```tsx const isMobile = useIsMobile(); ``` Change the `SheetContent` to: ```tsx ``` **Step 2: Commit** ```bash git add frontend/src/components/TaskProgressDrawer.tsx git commit -m "feat: render TaskProgressDrawer from bottom on mobile" ``` --- ### Task 10: Add scrollbar-none utility and verify build Tailwind v4 may not include `scrollbar-none` by default. Add it as a custom utility if needed, then verify the full build compiles. **Files:** - Modify: `frontend/src/index.css` (if needed) **Step 1: Check if scrollbar-none works in Tailwind v4** Run: `cd frontend && npx vite build 2>&1 | tail -20` If build succeeds, skip adding the utility. If there's a warning about `scrollbar-none`, add to `index.css`: ```css @utility scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; &::-webkit-scrollbar { display: none; } } ``` **Step 2: Verify build passes** Run: `cd frontend && npx vite build` Expected: Build completes with no errors. **Step 3: Commit if changes were needed** ```bash git add frontend/src/index.css git commit -m "feat: add scrollbar-none utility for hidden scrollbars on swipeable cards" ``` --- ### Task 11: Verify and fix TypeScript errors Run type checking and fix any issues. **Step 1: Run TypeScript check** Run: `cd frontend && npx tsc --noEmit 2>&1 | head -40` **Step 2: Fix any type errors found** Fix issues if any arise from the new components or modified files. **Step 3: Commit fixes** ```bash git add -A git commit -m "fix: resolve TypeScript errors from mobile responsive changes" ``` --- ### Task 12: Manual testing checklist Verify the implementation works correctly. This is a review task, not code. **Desktop verification:** - [ ] Desktop layout unchanged — sidebar, map, list, split view all work as before - [ ] StatsBar view mode toggle works - [ ] Filter panel visible in sidebar - [ ] Task progress drawer opens from right **Mobile verification (use browser devtools responsive mode at 375px width):** - [ ] Header shows logo + hamburger menu - [ ] Hamburger menu opens Sheet with health, tasks, user email, logout - [ ] Map is full-screen behind bottom sheet - [ ] Bottom sheet has drag handle - [ ] Bottom sheet shows listing count and avg price - [ ] Swiping cards horizontally works with snap behavior - [ ] Dragging sheet up expands to full list view - [ ] Filter FAB opens filter Sheet from left - [ ] Legend is positioned top-left and doesn't overlap FABs - [ ] Map popup close buttons are large enough for touch - [ ] Task progress opens from bottom on mobile