UX audit round 2: Near Me fix, map height, touch targets, iOS dirs, a11y, mobile layout

CRITICAL:
- Near Me button: completely rewritten — was silently discarding ALL errors. Now shows
  toast for denied, timeout, unavailable with actionable copy. Button shows red LocateOff
  icon when denied so user sees the state at a glance. Raised to 44×44px (Apple minimum).
- Map height on mobile: switched to CSS variable system (--header-mobile, --bottom-nav)
  for pixel-accurate height. Added Layout noPadding prop so the map page gets full bleed
  without the 80px bottom-padding fighting the map container calc.
- Layer toggle pills: raised from 28×28 to 40×40px — was below touch target minimum
- Right-side control column: now hides when spot bottom sheet is open (was always visible)

iOS/Directions:
- SpotDetailPage directions button: now detects Apple platform and opens Apple Maps on
  iOS/macOS, Google Maps everywhere else (was always Google Maps)
- Added aria-label to directions button

Accessibility:
- LoginDialog: was missing DialogTitle, causing console errors on every login open
- CameraCapture: fallback dialog was missing DialogTitle (sr-only)
- CommandDialog: added sr-only DialogTitle

Layout:
- SpotDetailPage: reduced top padding mobile pt-4 (was py-8 = 32px wasted space)
- Directions popover: centered in viewport (top-1/2 left-1/2) with zoom-in animation,
  never clips behind Sheet stacking context

CSS:
- Added --header-mobile and --bottom-nav CSS variables for consistent calculations
- Added .page-fullbleed helper

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 21:56:11 +07:00
parent dd85644fca
commit a1cd936bc4
7 changed files with 106 additions and 30 deletions

View file

@ -211,6 +211,7 @@ export function CameraCapture({
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50"> <DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
<DialogTitle className="sr-only">Opening Camera</DialogTitle>
<div className="flex flex-col items-center justify-center py-12 gap-4"> <div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center"> <div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
<Camera className="h-8 w-8 text-[#ff0040]" /> <Camera className="h-8 w-8 text-[#ff0040]" />

View file

@ -56,6 +56,9 @@ import { NewsletterOverlay } from '@/components/NewsletterOverlay';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** If true, removes the bottom padding that prevents bottom nav overlap.
* Use for full-bleed pages like the map that manage their own height. */
noPadding?: boolean;
} }
// Nav item keys that map to translation keys // Nav item keys that map to translation keys
@ -332,7 +335,7 @@ function MobileNav({
); );
} }
export function Layout({ children }: LayoutProps) { export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showStatusDialog, setShowStatusDialog] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false);
@ -755,7 +758,8 @@ export function Layout({ children }: LayoutProps) {
<BackupReminder /> <BackupReminder />
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */} {/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
<main className="pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"> {/* noPadding: full-bleed pages (map) manage their own height */}
<main className={noPadding ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
{children} {children}
</main> </main>

View file

@ -10,7 +10,7 @@ declare global {
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react'; import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -232,6 +232,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}> <DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}> <DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
<DialogTitle className="text-center font-black uppercase tracking-wide text-xl">
Welcome to SkateSpots
</DialogTitle>
<DialogDescription className="text-center text-sm text-gray-400"> <DialogDescription className="text-center text-sm text-gray-400">
Sign up or log in to continue Sign up or log in to continue
</DialogDescription> </DialogDescription>

View file

@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
const Command = React.forwardRef< const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>, React.ElementRef<typeof CommandPrimitive>,
@ -27,6 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg"> <DialogContent className="overflow-hidden p-0 shadow-lg">
<DialogTitle className="sr-only">Command Palette</DialogTitle>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children} {children}
</Command> </Command>

View file

@ -348,6 +348,19 @@
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
} }
/* Bottom navigation height token — used for full-bleed pages (map, etc.) */
:root {
/* Mobile header: 3.5rem (56px) */
--header-mobile: 3.5rem;
/* Mobile bottom nav: 58px base + safe area */
--bottom-nav: calc(3.625rem + env(safe-area-inset-bottom, 0px));
}
/* Full-bleed page — removes Layout bottom padding so the page can manage its own height */
.page-fullbleed > main {
padding-bottom: 0 !important;
}
.safe-area-left { .safe-area-left {
padding-left: env(safe-area-inset-left, 0px); padding-left: env(safe-area-inset-left, 0px);
} }

View file

@ -66,6 +66,8 @@ import {
Navigation, Navigation,
ExternalLink, ExternalLink,
MapPin, MapPin,
LocateFixed,
LocateOff,
} from 'lucide-react'; } from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture'; import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
@ -389,8 +391,8 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label:
<> <>
{/* Backdrop */} {/* Backdrop */}
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
{/* Menu — fixed so it never clips behind the Sheet stacking context */} {/* Menu — fixed, centered, never clips behind Sheet stacking context */}
<div className="fixed bottom-[30vh] left-1/2 -translate-x-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-72 backdrop-blur"> <div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-[min(18rem,calc(100vw-2rem))] backdrop-blur animate-in fade-in zoom-in-95 duration-150">
<div className="px-4 py-3 border-b border-white/8"> <div className="px-4 py-3 border-b border-white/8">
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p> <p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
</div> </div>
@ -582,18 +584,57 @@ export default function MapPage() {
// Near Me fly-to state // Near Me fly-to state
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null); const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [nearMeLoading, setNearMeLoading] = useState(false); const [nearMeLoading, setNearMeLoading] = useState(false);
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
const handleNearMe = () => { const handleNearMe = () => {
if (!navigator.geolocation) return; // Clear previous error
setNearMeError(null);
if (!navigator.geolocation) {
setNearMeError('unavailable');
toast({
title: 'Location not available',
description: 'Your browser does not support geolocation. Try Chrome or Safari.',
variant: 'destructive',
});
return;
}
setNearMeLoading(true); setNearMeLoading(true);
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }); setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 });
setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude }); setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude });
setNearMeLoading(false); setNearMeLoading(false);
setNearMeError(null);
}, },
() => setNearMeLoading(false), (err) => {
{ enableHighAccuracy: true, timeout: 8000 } setNearMeLoading(false);
if (err.code === err.PERMISSION_DENIED) {
setNearMeError('denied');
toast({
title: 'Location access denied',
description: 'To find spots near you, allow location access in your browser settings, then try again.',
variant: 'destructive',
});
} else if (err.code === err.TIMEOUT) {
setNearMeError('timeout');
toast({
title: 'Location timed out',
description: 'Could not get your location in time. Check your signal and try again.',
variant: 'destructive',
});
} else {
setNearMeError('unavailable');
toast({
title: 'Location unavailable',
description: 'Your location could not be determined. Make sure location services are enabled.',
variant: 'destructive',
});
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }
); );
}; };
@ -842,10 +883,10 @@ export default function MapPage() {
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
return ( return (
<Layout> <Layout noPadding>
{/* Account for mobile header (56px) + bottom nav (~72px+safe-area) on mobile, desktop header (64px) only on desktop */} {/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
{/* Using 100dvh for mobile to handle iOS Safari URL bar correctly */} {/* 100dvh handles iOS Safari URL bar shrink correctly */}
<div className="h-[calc(100dvh-3.5rem-72px)] md:h-[calc(100vh-4rem)] flex flex-col"> <div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
{/* Toolbar */} {/* Toolbar */}
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container px-3 sm:px-4 py-2 sm:py-3"> <div className="container px-3 sm:px-4 py-2 sm:py-3">
@ -1401,13 +1442,21 @@ export default function MapPage() {
onClick={handleNearMe} onClick={handleNearMe}
disabled={nearMeLoading} disabled={nearMeLoading}
size="sm" size="sm"
className="h-10 w-10 rounded-full bg-black/80 hover:bg-black border border-white/20 shadow-lg backdrop-blur p-0" className={cn(
title="Near me" "h-11 w-11 rounded-full shadow-lg backdrop-blur p-0 border transition-all active:scale-90 touch-manipulation",
nearMeError === 'denied'
? "bg-red-900/80 hover:bg-red-800/90 border-red-500/40"
: "bg-black/80 hover:bg-black/95 border-white/20"
)}
title={nearMeError === 'denied' ? 'Location access denied — tap for help' : 'Find spots near me'}
aria-label="Find spots near my location"
> >
{nearMeLoading ? ( {nearMeLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" /> <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
) : nearMeError === 'denied' ? (
<LocateOff className="h-4 w-4 text-red-400" />
) : ( ) : (
<Target className="h-4 w-4 text-[#ccff00]" /> <LocateFixed className="h-4 w-4 text-[#ccff00]" />
)} )}
</Button> </Button>
{/* Map attribution */} {/* Map attribution */}
@ -1424,8 +1473,8 @@ export default function MapPage() {
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */} {/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
<div className={cn( <div className={cn(
"absolute bottom-24 right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-opacity duration-150", "absolute bottom-20 right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-opacity duration-150",
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100" mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-100"
)}> )}>
{/* Layer filter pills — compact icon-only */} {/* Layer filter pills — compact icon-only */}
{[ {[
@ -1438,12 +1487,14 @@ export default function MapPage() {
key={key} key={key}
onClick={toggle} onClick={toggle}
className={cn( className={cn(
'h-7 w-7 rounded-full text-xs flex items-center justify-center shadow-md border transition-all', // 40×40 ensures 44pt Apple / 48dp Material touch target compliance
'h-10 w-10 rounded-full text-base flex items-center justify-center shadow-md border transition-all touch-manipulation active:scale-90',
active active
? `${activeColor} border-white/20 shadow-black/30` ? `${activeColor} border-white/20 shadow-black/30`
: 'bg-black/60 border-white/8 opacity-35' : 'bg-black/60 border-white/8 opacity-40'
)} )}
title={key} title={key}
aria-label={`Toggle ${key}`}
> >
{label} {label}
</button> </button>

View file

@ -165,12 +165,15 @@ export default function SpotDetailPage() {
}; };
const handleDirections = () => { const handleDirections = () => {
if (coordinates) { if (!coordinates) return;
window.open( const { lat, lng } = coordinates;
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, const label = encodeURIComponent(spot?.title ?? 'Skate Spot');
'_blank' // Use Apple Maps on iOS/macOS, Google Maps everywhere else
); const isApple = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent);
} const url = isApple
? `https://maps.apple.com/?ll=${lat},${lng}&q=${label}&dirflg=d`
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
}; };
const handleDelete = async () => { const handleDelete = async () => {
@ -217,11 +220,11 @@ export default function SpotDetailPage() {
return ( return (
<Layout> <Layout>
<div className="container px-4 py-8 overflow-x-hidden"> <div className="container px-4 pt-4 pb-8 sm:py-8 overflow-x-hidden">
{/* Back link */} {/* Back link */}
<Link <Link
to="/map" to="/map"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Map Back to Map
@ -331,7 +334,7 @@ export default function SpotDetailPage() {
<Button variant="outline" size="icon" onClick={handleShare}> <Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" /> <Share2 className="h-4 w-4" />
</Button> </Button>
<Button variant="outline" size="icon" onClick={handleDirections}> <Button variant="outline" size="icon" onClick={handleDirections} aria-label="Get directions">
<Navigation className="h-4 w-4" /> <Navigation className="h-4 w-4" />
</Button> </Button>
{/* More options menu */} {/* More options menu */}