# 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 && (
)}
{/* 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 */}
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}
Logout
)}
);
}
```
**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 (
);
```
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