wrongmove/docs/plans/2026-02-21-mobile-responsive-plan.md
Viktor Barzin a744b33578
feat: make frontend fully responsive with mobile-first layout
Add mobile-responsive design with full feature parity:
- Bottom sheet (vaul) with 3 snap points for map+list coexistence
- Swipeable property cards with horizontal scroll-snap
- Hamburger menu with health, tasks, user info
- Full-screen map with repositioned legend (top-left on mobile)
- Filter FAB opening Sheet drawer
- TaskProgressDrawer from bottom on mobile
- All changes gated behind useIsMobile() hook (768px breakpoint)
- Desktop layout completely untouched

New components: MobileBottomSheet, SwipeableCardRow,
PropertyCardCompact, MobileMenu

Also fixes: idempotent longitude migration, React hooks order
2026-02-21 11:34:53 +00:00

33 KiB

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

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

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 (
        <div
            className={`w-[280px] shrink-0 snap-center rounded-lg border bg-background shadow-sm overflow-hidden cursor-pointer transition-all ${
                isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
            } ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
            onClick={onClick}
        >
            {/* Thumbnail */}
            <div className="h-28 w-full bg-muted">
                {property.photo_thumbnail && (
                    <img
                        src={property.photo_thumbnail}
                        alt="Property"
                        className="w-full h-full object-cover"
                    />
                )}
            </div>

            {/* Details */}
            <div className="p-3">
                <div className="flex items-start justify-between gap-2">
                    <div className="font-semibold text-base">
                        £{property.total_price.toLocaleString()}
                        {property.listing_type !== 'BUY' && (
                            <span className="text-muted-foreground font-normal text-sm">/mo</span>
                        )}
                    </div>
                    {priceIndicator && (
                        <span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
                            {priceIndicator.label}
                        </span>
                    )}
                </div>

                <div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
                    <span className="flex items-center gap-1">
                        <Bed className="h-3.5 w-3.5" />
                        {property.rooms}
                    </span>
                    <span className="flex items-center gap-1">
                        <Maximize2 className="h-3.5 w-3.5" />
                        {property.qm} m²
                    </span>
                    <span>£{property.qmprice}/m²</span>
                </div>
            </div>
        </div>
    );
}

Step 2: Commit

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

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<HTMLDivElement>(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<number | null>(null);
    const debouncedScroll = useCallback(() => {
        if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
        scrollTimerRef.current = window.setTimeout(handleScroll, 100);
    }, [handleScroll]);

    if (features.length === 0) return null;

    return (
        <div
            ref={scrollRef}
            onScroll={debouncedScroll}
            className="flex gap-3 overflow-x-auto snap-x snap-mandatory px-4 py-2 scrollbar-none"
            style={{ WebkitOverflowScrolling: 'touch' }}
        >
            {features.map((feature, index) => (
                <PropertyCardCompact
                    key={feature.properties.url}
                    property={feature.properties}
                    isActive={index === activeIndex}
                    isHighlighted={feature.properties.url === highlightedPropertyUrl}
                    avgPricePerSqm={avgPricePerSqm}
                    onClick={() => onCardClick?.(feature)}
                />
            ))}
        </div>
    );
}

Step 2: Commit

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

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<string | number>("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 (
        <Drawer.Root
            open
            snapPoints={["80px", "148px", 0.85]}
            activeSnapPoint={snap}
            setActiveSnapPoint={setSnap}
            modal={false}
        >
            <Drawer.Portal>
                <Drawer.Content
                    className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
                    style={{ maxHeight: '85vh' }}
                >
                    {/* Drag handle */}
                    <div className="flex justify-center pt-2 pb-1">
                        <div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
                    </div>

                    {/* Compact stats (always visible) */}
                    <div className="flex items-center gap-4 px-4 pb-2 text-sm text-muted-foreground">
                        <div className="flex items-center gap-1.5">
                            <MapPin className="h-4 w-4" />
                            <span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
                            <span>listings</span>
                        </div>
                        {stats.avgPrice > 0 && (
                            <div className="flex items-center gap-1.5">
                                <PoundSterling className="h-4 w-4" />
                                <span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
                            </div>
                        )}
                    </div>

                    {/* Swipeable cards (visible at peek snap) */}
                    {!isExpanded && features.length > 0 && (
                        <SwipeableCardRow
                            features={features}
                            activeIndex={activeCardIndex}
                            onActiveIndexChange={handleActiveIndexChange}
                            avgPricePerSqm={avgPricePerSqm}
                            highlightedPropertyUrl={highlightedPropertyUrl}
                            onCardClick={handleCardClick}
                        />
                    )}

                    {/* Full list view (visible at expanded snap) */}
                    {isExpanded && listingData && features.length > 0 && (
                        <div className="flex-1 min-h-0 overflow-hidden">
                            <ListView
                                listingData={listingData}
                                onPropertyClick={onPropertyClick}
                                highlightedPropertyUrl={highlightedPropertyUrl}
                                poiMetricSelection={poiMetricSelection}
                            />
                        </div>
                    )}
                </Drawer.Content>
            </Drawer.Portal>
        </Drawer.Root>
    );
}

Step 2: Commit

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

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<string, TaskState>;
    activeTaskId: string | null;
    isConnected: boolean;
    onCancelTask: (taskId: string) => Promise<boolean>;
    onClearAllTasks: () => Promise<boolean>;
    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 (
        <Sheet>
            <SheetTrigger asChild>
                <Button variant="ghost" size="sm" className="h-10 w-10 p-0">
                    <Menu className="h-5 w-5" />
                </Button>
            </SheetTrigger>
            <SheetContent side="right" className="w-72">
                <SheetHeader>
                    <SheetTitle>Menu</SheetTitle>
                </SheetHeader>

                <div className="flex flex-col gap-4 px-4">
                    {/* User info */}
                    <div className="text-sm text-muted-foreground">
                        {user.email}
                    </div>

                    <Separator />

                    {/* Health */}
                    <div className="flex items-center gap-2">
                        <span className="text-sm font-medium">System Health</span>
                        <HealthIndicator />
                    </div>

                    <Separator />

                    {/* Tasks */}
                    <div className="flex items-center gap-2">
                        <span className="text-sm font-medium">Tasks</span>
                        <TaskIndicator
                            tasks={tasks}
                            activeTaskId={activeTaskId}
                            isConnected={isConnected}
                            onCancelTask={onCancelTask}
                            onClearAllTasks={onClearAllTasks}
                            onTaskCompleted={onTaskCompleted}
                        />
                    </div>

                    <Separator />

                    {/* Logout */}
                    <Button
                        variant="outline"
                        onClick={handleLogout}
                        className="w-full gap-2"
                    >
                        <LogOut className="h-4 w-4" />
                        Logout
                    </Button>
                </div>
            </SheetContent>
        </Sheet>
    );
}

Step 2: Commit

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.

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<string, TaskState>;
    activeTaskId: string | null;
    isConnected: boolean;
    onCancelTask: (taskId: string) => Promise<boolean>;
    onClearAllTasks: () => Promise<boolean>;
    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 (
        <header className={`flex shrink-0 items-center gap-3 border-b bg-background px-4 ${isMobile ? 'h-12' : 'h-14'}`}>
            {/* Logo / Brand */}
            <div className="flex items-center gap-2">
                <Home className="h-5 w-5 text-primary" />
                <span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
            </div>

            {/* Desktop-only items */}
            {!isMobile && (
                <>
                    <Separator orientation="vertical" className="h-6" />
                    <HealthIndicator />
                    <TaskIndicator
                        tasks={tasks}
                        activeTaskId={activeTaskId}
                        isConnected={isConnected}
                        onCancelTask={onCancelTask}
                        onClearAllTasks={onClearAllTasks}
                        onTaskCompleted={onTaskCompleted}
                    />
                </>
            )}

            {/* Spacer */}
            <div className="flex-1" />

            {/* Mobile: hamburger menu */}
            {isMobile && (
                <MobileMenu
                    user={user}
                    tasks={tasks}
                    activeTaskId={activeTaskId}
                    isConnected={isConnected}
                    onCancelTask={onCancelTask}
                    onClearAllTasks={onClearAllTasks}
                    onTaskCompleted={onTaskCompleted}
                />
            )}

            {/* Desktop: user menu */}
            {!isMobile && (
                <div className="flex items-center gap-3">
                    <span className="text-sm text-muted-foreground">
                        {user.email}
                    </span>
                    <Button
                        variant="ghost"
                        size="sm"
                        onClick={handleLogout}
                        className="gap-2"
                    >
                        <LogOut className="h-4 w-4" />
                        Logout
                    </Button>
                </div>
            )}
        </header>
    );
}

Step 2: Commit

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:
import { useIsMobile } from '@/hooks/use-mobile';
import { MobileBottomSheet } from './components/MobileBottomSheet';
import { MapPin as MapPinIcon } from 'lucide-react';
  1. Inside App(), after existing state declarations, add:
const isMobile = useIsMobile();
  1. Add a handler for when the active card changes in the bottom sheet (for map sync):
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);

const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
    setActiveCardFeature(feature);
}, []);
  1. Add mobile layout renderer after renderMainContent:
const renderMobileLayout = () => (
    <>
        {/* Full-screen map */}
        <div className="flex-1 relative min-h-0">
            {/* Streaming Progress Bar */}
            <div className="absolute top-0 left-0 right-0 z-10">
                <StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
            </div>

            {processedListingData && processedListingData.features.length > 0 ? (
                <Map
                    listingData={processedListingData}
                    queryParameters={queryParameters}
                    effectiveMetric={effectiveMetric}
                    onPropertyClick={handlePropertyClick}
                    pois={userPOIs}
                    isPickingPOI={poiPickerActive}
                    onPoiLocationPick={handlePoiLocationPick}
                    onCancelPoiPicking={handleCancelPoiPicking}
                />
            ) : (
                <div className="flex-1 flex items-center justify-center bg-muted/20 h-full">
                    <div className="text-center p-8 max-w-md">
                        {isLoading ? (
                            <>
                                <div className="text-4xl mb-4 animate-pulse">🏠</div>
                                <h2 className="text-lg font-semibold mb-2">Loading...</h2>
                            </>
                        ) : (
                            <>
                                <div className="text-4xl mb-4">🏠</div>
                                <h2 className="text-lg font-semibold mb-2">Property Explorer</h2>
                                <p className="text-muted-foreground text-sm">
                                    Use the filter button to find properties.
                                </p>
                            </>
                        )}
                    </div>
                </div>
            )}
        </div>

        {/* FABs - above bottom sheet */}
        <div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
            <Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
                <SheetTrigger asChild>
                    <Button size="lg" className="rounded-full shadow-lg h-14 w-14">
                        <Filter className="h-6 w-6" />
                    </Button>
                </SheetTrigger>
                <SheetContent side="left" className="w-80 p-0">
                    <div className="h-full flex flex-col">
                        <div className="flex-1 min-h-0">
                            <FilterPanel
                                onSubmit={onSubmit}
                                currentMetric={currentMetric}
                                isLoading={isLoading}
                                listingCount={processedListingData?.features.length}
                                user={user}
                                onTaskCreated={handlePOITaskCreated}
                                onStartPoiPicking={handleStartPoiPicking}
                                pickedPoiLocation={pickedPoiLocation}
                                userPOIs={userPOIs}
                                poiTravelFilters={poiTravelFilters}
                                onPoiTravelFiltersChange={setPoiTravelFilters}
                            />
                        </div>
                        <div className="shrink-0 p-4">
                            <VisualizationCard
                                metric={currentMetric}
                                onMetricChange={handleMetricChange}
                                userPOIs={userPOIs}
                                onPoiMetricChange={setPoiMetricSelection}
                            />
                        </div>
                    </div>
                </SheetContent>
            </Sheet>
        </div>

        {/* Bottom Sheet */}
        {processedListingData && processedListingData.features.length > 0 && (
            <MobileBottomSheet
                listingData={processedListingData}
                onPropertyClick={handlePropertyClick}
                highlightedPropertyUrl={highlightedProperty}
                onActiveListingChange={handleActiveListingChange}
                poiMetricSelection={poiMetricSelection}
            />
        )}
    </>
);
  1. Replace the return statement to branch on isMobile:
return (
    <div className="h-screen flex flex-col overflow-hidden">
        {/* Header */}
        <Header
            user={user}
            tasks={tasks}
            activeTaskId={activeTaskId}
            isConnected={isConnected}
            onCancelTask={cancelTask}
            onClearAllTasks={async () => {
                const result = await clearAllTasks();
                if (result) handleTaskCancelled();
                return result;
            }}
            onTaskCompleted={handleTaskCompleted}
        />

        {isMobile ? (
            renderMobileLayout()
        ) : (
            /* Existing desktop layout (the current flex-1 flex div) */
            <div className="flex-1 flex overflow-hidden min-h-0">
                {/* Desktop filter sidebar */}
                <div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
                    {/* ... existing desktop filter panel ... */}
                </div>

                {/* Main View Area */}
                <div className="flex-1 flex flex-col overflow-hidden min-h-0">
                    <div className="relative shrink-0">
                        <StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
                    </div>
                    <div className="flex-1 flex overflow-hidden min-h-0">
                        {renderMainContent()}
                    </div>
                    {processedListingData && processedListingData.features.length > 0 && (
                        <div className="shrink-0">
                            <StatsBar
                                listingData={processedListingData}
                                viewMode={viewMode}
                                onViewModeChange={setViewMode}
                            />
                        </div>
                    )}
                </div>
            </div>
        )}

        <AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
    </div>
);

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

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:

@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

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:

import { useIsMobile } from '@/hooks/use-mobile';

Inside the TaskProgressDrawer component, add:

const isMobile = useIsMobile();

Change the SheetContent to:

<SheetContent
    side={isMobile ? "bottom" : "right"}
    className={`flex flex-col ${isMobile ? 'h-[85vh] rounded-t-xl' : 'w-full sm:!max-w-lg'}`}
>

Step 2: Commit

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:

@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

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

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