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 (
<Dialog open={open} onOpenChange={handleClose}>
<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="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
<Camera className="h-8 w-8 text-[#ff0040]" />

View file

@ -56,6 +56,9 @@ import { NewsletterOverlay } from '@/components/NewsletterOverlay';
interface LayoutProps {
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
@ -332,7 +335,7 @@ function MobileNav({
);
}
export function Layout({ children }: LayoutProps) {
export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showStatusDialog, setShowStatusDialog] = useState(false);
@ -755,7 +758,8 @@ export function Layout({ children }: LayoutProps) {
<BackupReminder />
{/* 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}
</main>

View file

@ -10,7 +10,7 @@ declare global {
import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
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 { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
@ -232,6 +232,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<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")}>
<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">
Sign up or log in to continue
</DialogDescription>

View file

@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@ -27,6 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<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">
{children}
</Command>

View file

@ -348,6 +348,19 @@
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 {
padding-left: env(safe-area-inset-left, 0px);
}

View file

@ -66,6 +66,8 @@ import {
Navigation,
ExternalLink,
MapPin,
LocateFixed,
LocateOff,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -389,8 +391,8 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label:
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
{/* Menu — fixed so it never clips behind the 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">
{/* Menu — fixed, centered, never clips behind Sheet stacking context */}
<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">
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
</div>
@ -582,18 +584,57 @@ export default function MapPage() {
// Near Me fly-to state
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [nearMeLoading, setNearMeLoading] = useState(false);
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
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);
navigator.geolocation.getCurrentPosition(
(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 });
setNearMeLoading(false);
setNearMeError(null);
},
() => setNearMeLoading(false),
{ enableHighAccuracy: true, timeout: 8000 }
(err) => {
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);
return (
<Layout>
{/* Account for mobile header (56px) + bottom nav (~72px+safe-area) on mobile, desktop header (64px) only on desktop */}
{/* Using 100dvh for mobile to handle iOS Safari URL bar correctly */}
<div className="h-[calc(100dvh-3.5rem-72px)] md:h-[calc(100vh-4rem)] flex flex-col">
<Layout noPadding>
{/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */}
{/* 100dvh handles iOS Safari URL bar shrink correctly */}
<div className="h-[calc(100dvh-var(--header-mobile)-var(--bottom-nav))] md:h-[calc(100vh-4rem)] flex flex-col">
{/* Toolbar */}
<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">
@ -1401,13 +1442,21 @@ export default function MapPage() {
onClick={handleNearMe}
disabled={nearMeLoading}
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"
title="Near me"
className={cn(
"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 ? (
<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>
{/* Map attribution */}
@ -1424,8 +1473,8 @@ export default function MapPage() {
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
<div className={cn(
"absolute bottom-24 right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-opacity duration-150",
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
"absolute bottom-20 right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-opacity duration-150",
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-100"
)}>
{/* Layer filter pills — compact icon-only */}
{[
@ -1438,12 +1487,14 @@ export default function MapPage() {
key={key}
onClick={toggle}
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
? `${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}
aria-label={`Toggle ${key}`}
>
{label}
</button>

View file

@ -165,12 +165,15 @@ export default function SpotDetailPage() {
};
const handleDirections = () => {
if (coordinates) {
window.open(
`https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`,
'_blank'
);
}
if (!coordinates) return;
const { lat, lng } = coordinates;
const label = encodeURIComponent(spot?.title ?? 'Skate Spot');
// 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 () => {
@ -217,11 +220,11 @@ export default function SpotDetailPage() {
return (
<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 */}
<Link
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" />
Back to Map
@ -331,7 +334,7 @@ export default function SpotDetailPage() {
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</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" />
</Button>
{/* More options menu */}