From 0d4f0489d181778d7403fd459fb0198646c0648b Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:13:34 -0400 Subject: [PATCH 01/10] chore(web): make dev API proxy target configurable TIGERMAP_API_PROXY points the Vite /api proxy at another backend (e.g. production feeds) and defaults to the local server on port 3001. --- apps/web/vite.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 3d71ef6..a5924c1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,7 +7,10 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": "http://localhost:3001", + "/api": { + target: process.env.TIGERMAP_API_PROXY || "http://localhost:3001", + changeOrigin: true, + }, }, }, }); From 3293da17d9a67ee99a0ed0816ccbaa7acf06a465 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:14:43 -0400 Subject: [PATCH 02/10] feat(web): add favicon, fonts, and page metadata Adds the TigerMap favicon, theme color, description, and Bricolage Grotesque/Manrope fonts, and re-enables pinch zoom for accessibility. --- apps/web/index.html | 7 ++++++- apps/web/public/tiger.svg | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 apps/web/public/tiger.svg diff --git a/apps/web/index.html b/apps/web/index.html index 09e9354..c39a0b1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,10 +4,15 @@ TigerMap — Princeton Campus Map + + + + +
diff --git a/apps/web/public/tiger.svg b/apps/web/public/tiger.svg new file mode 100644 index 0000000..c66c5e1 --- /dev/null +++ b/apps/web/public/tiger.svg @@ -0,0 +1 @@ + \ No newline at end of file From 690ca82917ba7155300db6c6bfe22b71d3a0a9f3 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:15:31 -0400 Subject: [PATCH 03/10] feat(web): restyle campus basemap cartography polishMapStyle recolors fills, lines, and labels to the warm campus palette and hides noisy symbol layers while preserving sources, filters, and zoom visibility rules. Covered by a style-spec validation test. --- apps/web/src/utils/mapStyle.ts | 100 ++++++++++++++++++++++++++++++++ apps/web/tests/mapStyle.test.ts | 43 ++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 apps/web/src/utils/mapStyle.ts create mode 100644 apps/web/tests/mapStyle.test.ts diff --git a/apps/web/src/utils/mapStyle.ts b/apps/web/src/utils/mapStyle.ts new file mode 100644 index 0000000..a19eacc --- /dev/null +++ b/apps/web/src/utils/mapStyle.ts @@ -0,0 +1,100 @@ +import type { StyleSpecification } from "mapbox-gl"; + +const FILL_COLORS: Record = { + water: "#b9d2d1", + "road-polygon": "#faf7ef", + "detail-polygon": "#d4decb", + "buildings-shadow": "rgba(70, 61, 48, 0.08)", + underpass: "#ddd5c7", +}; + +export function polishMapStyle(style: StyleSpecification): StyleSpecification { + for (const layer of style.layers) { + if (layer.type === "background") layer.paint = { "background-color": "#eae9df" }; + if (layer.type === "fill") { + if (layer.id === "Constuction Fill") { + layer.paint = { "fill-color": "#e2dccb", "fill-opacity": 0.7 }; + } else if (FILL_COLORS[layer.id]) { + layer.paint = { ...layer.paint, "fill-color": FILL_COLORS[layer.id] }; + } else if (layer.id === "landuse copy") { + layer.paint = { + ...layer.paint, + "fill-color": [ + "match", + ["get", "class"], + "grass", + "#dce5d0", + "pitch", + "#d1dec5", + "sand", + "#e8dfcb", + "#e6eadb", + ], + }; + } else if (layer.id === "buildings") { + layer.paint = { + ...layer.paint, + "fill-color": [ + "match", + ["get", "style_rank"], + [1], + "#e5cbb2", + [2], + "#d6cfc3", + [3], + "#e2ddd3", + "rgba(0,0,0,0)", + ], + }; + } + } + if (layer.type === "line") { + const color = + layer.id.includes("buildings") || layer.id.includes("underpass") + ? "#b4aa99" + : layer.id.includes("road") + ? "#f7f3e9" + : "#b9c3ac"; + layer.paint = { ...layer.paint, "line-color": color }; + if (layer.id === "Inclines") layer.paint["line-opacity"] = 0.25; + } + if (layer.type === "symbol") { + if ( + ["public-art", "princeton_line", "bus_stops", "place-label", "quarter-label"].includes( + layer.id, + ) + ) { + layer.layout = { ...layer.layout, visibility: "none" }; + continue; + } + layer.paint = { + ...layer.paint, + "text-color": layer.id === "greenspace_label" ? "#647459" : "#5c6055", + "text-halo-color": "#f5f3e9", + "text-halo-width": 1.2, + }; + if (layer.id === "poi-label") { + layer.layout = { + ...layer.layout, + "icon-image": "", + "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 16, 11, 19, 15], + "text-transform": "none", + "text-anchor": "center", + "text-justify": "center", + "text-field": ["get", "name"], + "text-padding": 4, + "text-max-width": 8, + }; + } else if (["road-label", "greenspace_label", "waterway-label"].includes(layer.id)) { + layer.layout = { + ...layer.layout, + "text-size": ["interpolate", ["linear"], ["zoom"], 14, 8, 19, 13], + "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"], + "text-letter-spacing": 0.06, + }; + } + } + } + return style; +} diff --git a/apps/web/tests/mapStyle.test.ts b/apps/web/tests/mapStyle.test.ts new file mode 100644 index 0000000..b2eb84a --- /dev/null +++ b/apps/web/tests/mapStyle.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import type { StyleSpecification } from "mapbox-gl"; +import spec from "mapbox-gl/dist/style-spec/index.cjs"; +import { polishMapStyle } from "../src/utils/mapStyle"; + +test("campus restyling stays valid and preserves map data and label visibility rules", () => { + const style: StyleSpecification = { + version: 8, + glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf", + sources: { campus: { type: "geojson", data: { type: "FeatureCollection", features: [] } } }, + layers: [ + { id: "background", type: "background" }, + { id: "buildings", type: "fill", source: "campus" }, + { id: "road-polygon", type: "fill", source: "campus" }, + { + id: "Constuction Fill", + type: "fill", + source: "campus", + paint: { "fill-pattern": "construction" }, + }, + { + id: "poi-label", + type: "symbol", + source: "campus", + minzoom: 14, + filter: ["==", ["get", "style_rank"], 1], + paint: { "text-opacity": ["step", ["zoom"], 0, 16, 1] }, + layout: { "icon-image": "landmark", "text-field": ["get", "name"] }, + }, + { id: "public-art", type: "symbol", source: "campus" }, + ], + }; + const sources = structuredClone(style.sources); + const filter = structuredClone(style.layers[4].filter); + const result = polishMapStyle(style); + expect(spec.validate(result).map((error: { message: string }) => error.message)).toEqual([]); + expect(result.sources).toEqual(sources); + expect(result.layers[4].filter).toEqual(filter); + expect(result.layers[4].minzoom).toBe(14); + expect(result.layers[4].paint?.["text-opacity"]).toEqual(["step", ["zoom"], 0, 16, 1]); + expect(result.layers[3].paint).not.toHaveProperty("fill-pattern"); + expect(result.layers[5].layout?.visibility).toBe("none"); +}); From 38290e3307e06fe7b5f9124d419e70f4d810d910 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:16:49 -0400 Subject: [PATCH 04/10] feat(web): redesign map interface with shared detail sheets - DetailPanel owns focus, resize animation, mobile sheet gestures, and the Directions footer for place, dining, free food, club, and directions views - Map markers and panels share a single Selection type; CampusMap renders all markers through PlaceMarker - New discovery panel, quick filters, and filter panel with select all and clear - Shared JSON fetch and list parsing helpers in utils/api - Browser checks for mobile sheet gestures and panel resizing --- apps/web/src/App.tsx | 338 +++++--- apps/web/src/components/CampusMap.tsx | 790 +++++++++---------- apps/web/src/components/CategorySidebar.tsx | 74 +- apps/web/src/components/DetailPanel.tsx | 204 +++++ apps/web/src/components/DiningDetail.tsx | 119 ++- apps/web/src/components/DirectionsSheet.tsx | 273 +++---- apps/web/src/components/EatingClubDetail.tsx | 225 +++--- apps/web/src/components/FreefoodDetail.tsx | 111 +-- apps/web/src/components/POIDetail.tsx | 120 ++- apps/web/src/components/PlaceCarousel.tsx | 22 + apps/web/src/components/SearchBar.tsx | 176 +++-- apps/web/src/index.css | 642 +++++++-------- apps/web/src/types.ts | 7 + apps/web/src/utils/api.ts | 15 + apps/web/src/utils/categories.ts | 130 +-- apps/web/tests/mobileSheets.browser.js | 91 +++ apps/web/tests/panelResize.browser.js | 24 + 17 files changed, 1808 insertions(+), 1553 deletions(-) create mode 100644 apps/web/src/components/DetailPanel.tsx create mode 100644 apps/web/src/components/PlaceCarousel.tsx create mode 100644 apps/web/src/utils/api.ts create mode 100644 apps/web/tests/mobileSheets.browser.js create mode 100644 apps/web/tests/panelResize.browser.js diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5fe8d45..76797ac 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,4 @@ +import { ArrowUpRight, ChevronDown, MapPin, SlidersHorizontal, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CampusMap } from "./components/CampusMap"; import { CategorySidebar } from "./components/CategorySidebar"; @@ -6,18 +7,14 @@ import { DirectionsSheet, type DirectionsState } from "./components/DirectionsSh import { EatingClubDetail } from "./components/EatingClubDetail"; import { FreefoodDetail } from "./components/FreefoodDetail"; import { POIDetail } from "./components/POIDetail"; +import { PlaceCarousel } from "./components/PlaceCarousel"; import { SearchBar } from "./components/SearchBar"; -import type { DiningHallMenu, EatingClub, FreefoodPost, POI } from "./types"; +import type { DiningHallMenu, EatingClub, FreefoodPost, POI, Selection } from "./types"; +import { getJSON } from "./utils/api"; +import { QUICK_CATEGORIES, getCategoryIcon } from "./utils/categories"; import { type LatLng, type TravelProfile, fetchRoutes } from "./utils/directions"; import { distanceM, getRecentFix, locate } from "./utils/geolocation"; -type DetailPanel = - | { kind: "poi"; data: POI } - | { kind: "freefood"; data: FreefoodPost } - | { kind: "club"; data: EatingClub } - | { kind: "dining"; data: DiningHallMenu } - | null; - type Destination = DirectionsState["dest"]; // Map furniture that shouldn't appear as search destinations @@ -25,8 +22,12 @@ const EXCLUDED_SEARCH_CATS = new Set(["Steps", "steps", "Ramp", "Entrance"]); export function App() { const [pois, setPois] = useState([]); + const [placesError, setPlacesError] = useState(false); + const [feedError, setFeedError] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [activeCategories, setActiveCategories] = useState>(new Set(["Restaurant"])); + const [activeCategories, setActiveCategories] = useState>( + new Set(["@dining", "@freefood", "@clubs"]), + ); const [sidebarOpen, setSidebarOpen] = useState(false); const [freefoodPosts, setFreefoodPosts] = useState([]); const [eatingClubs, setEatingClubs] = useState([]); @@ -34,19 +35,14 @@ export function App() { const [diningCurrentMeal, setDiningCurrentMeal] = useState("Lunch"); // Single active detail panel — only one can be open at a time - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState(null); - const selectPOI = useCallback((poi: POI | null) => { - setDetail(poi ? { kind: "poi", data: poi } : null); - }, []); - const selectFreefood = useCallback((post: FreefoodPost | null) => { - setDetail(post ? { kind: "freefood", data: post } : null); - }, []); - const selectClub = useCallback((club: EatingClub | null) => { - setDetail(club ? { kind: "club", data: club } : null); - }, []); - const selectDining = useCallback((menu: DiningHallMenu | null) => { - setDetail(menu ? { kind: "dining", data: menu } : null); + const select = useCallback((next: Selection) => { + setDetail(next); + if (next) { + setSidebarOpen(false); + setDirections(null); + } }, []); const closeDetail = useCallback(() => setDetail(null), []); @@ -118,16 +114,20 @@ export function App() { const pickSearchResult = useCallback( (poi: POI) => { - setSearchQuery(poi.name); - startDirections({ name: poi.name, lat: poi.lat, lng: poi.lng, cat: poi.cat }); + setSearchQuery(""); + select({ kind: "poi", data: poi }); }, - [startDirections], + [select], ); - const directionsFromPOI = useCallback( - (poi: POI) => startDirections({ name: poi.name, lat: poi.lat, lng: poi.lng, cat: poi.cat }), - [startDirections], - ); + const directionsToDetail = () => { + if (detail?.kind === "freefood") { + const { location_name: name, lat, lng } = detail.data; + startDirections({ name, lat, lng }); + } else if (detail) { + startDirections(detail.kind === "dining" ? detail.data.hall : detail.data); + } + }; const searchResults = useMemo(() => { const q = searchQuery.trim().toLowerCase(); @@ -147,35 +147,47 @@ export function App() { return scored.slice(0, 7).map((s) => s.poi); }, [pois, searchQuery]); + const loadPlaces = useCallback(() => { + setPlacesError(false); + getJSON("/data/pois.json") + .then(setPois) + .catch(() => setPlacesError(true)); + }, []); + useEffect(() => { + loadPlaces(); + }, [loadPlaces]); useEffect(() => { - fetch("/data/pois.json") - .then((r) => r.json()) - .then((data: POI[]) => setPois(data)); + const dismiss = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSidebarOpen(false); + setDetail(null); + setDirections(null); + } + }; + window.addEventListener("keydown", dismiss); + return () => window.removeEventListener("keydown", dismiss); }, []); useEffect(() => { - fetch("/api/eating-clubs") - .then((r) => r.json()) + getJSON<{ clubs?: EatingClub[] }>("/api/eating-clubs") .then((data) => setEatingClubs(data.clubs ?? [])) - .catch(() => {}); + .catch(() => setFeedError(true)); }, []); useEffect(() => { - fetch("/api/dining/today") - .then((r) => r.json()) + getJSON<{ menus?: DiningHallMenu[]; currentMeal?: string }>("/api/dining/today") .then((data) => { setDiningMenus(data.menus ?? []); setDiningCurrentMeal(data.currentMeal ?? "Lunch"); }) - .catch(() => {}); + .catch(() => setFeedError(true)); }, []); useEffect(() => { const load = () => - fetch("/api/freefood/feed?hours=9") - .then((r) => r.json()) + getJSON<{ emails?: FreefoodPost[] }>("/api/freefood/feed?hours=9") .then((data) => setFreefoodPosts(data.emails ?? [])) - .catch(() => {}); + .catch(() => setFeedError(true)); load(); const id = setInterval(load, 2 * 60 * 1000); return () => clearInterval(id); @@ -184,34 +196,39 @@ export function App() { const categories = useMemo(() => { const cats = new Map(); for (const poi of pois) { + if (!poi.name || EXCLUDED_SEARCH_CATS.has(poi.cat || "")) continue; const cat = poi.cat || "Other"; cats.set(cat, (cats.get(cat) ?? 0) + 1); } - return Array.from(cats.entries()) + const placeCategories = Array.from(cats.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, count]) => ({ name, count })); - }, [pois]); + return [ + { name: "@dining", count: diningMenus.length }, + { name: "@freefood", count: freefoodPosts.length }, + { name: "@clubs", count: eatingClubs.length }, + ...placeCategories, + ]; + }, [pois, diningMenus, freefoodPosts, eatingClubs]); - const filteredPOIs = useMemo(() => { - if (activeCategories.size === 0 && !searchQuery.trim()) return []; - let result = pois; - if (activeCategories.size > 0) { - result = result.filter((p) => activeCategories.has(p.cat || "Other")); - } - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (p) => - p.name?.toLowerCase().includes(q) || - p.alt?.toLowerCase().includes(q) || - p.desc?.toLowerCase().includes(q) || - p.cat?.toLowerCase().includes(q), - ); - } - return result; - }, [pois, activeCategories, searchQuery]); + const filteredPOIs = useMemo( + () => + pois.filter( + (poi) => + activeCategories.has(poi.cat || "Other") && + poi.name && + !EXCLUDED_SEARCH_CATS.has(poi.cat || ""), + ), + [pois, activeCategories], + ); + const popularPlaces = [193, 1126, 347, 359].flatMap((id) => { + const poi = pois.find((p) => p.id === id); + return poi ? [poi] : []; + }); const toggleCategory = useCallback((name: string) => { + setDetail(null); + setDirections(null); setActiveCategories((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); @@ -221,80 +238,179 @@ export function App() { }, []); return ( -
+
- - {/* Header */} -
-
- ☤ PRINCETON UNIVERSITY -
-
- - {/* Search + categories */} -
- -
- + by TigerApps + + +
+ +
+
+ {QUICK_CATEGORIES.map(({ name, label }) => { + const Icon = getCategoryIcon(name); + return ( + + ); + })} +
+ +
+ {sidebarOpen ? ( + setActiveCategories(new Set())} + onSelectAll={() => setActiveCategories(new Set(categories.map(({ name }) => name)))} + onClose={() => setSidebarOpen(false)} + /> + ) : ( +
+ +

+ + Find your next stop. + + + +

+
+ + {!pois.length && !placesError && ( +
+ + + +
+ )} + {placesError && ( +
+ Campus places couldn’t load.{" "} + +
+ )} + {feedError &&
Some live updates are unavailable
} +
+ )}
- - {sidebarOpen && ( - setSidebarOpen(false)} + {detail?.kind === "poi" && ( + )} - - {/* Single detail panel — only one renders at a time */} - {detail?.kind === "poi" && ( - + {detail?.kind === "freefood" && ( + + )} + {detail?.kind === "club" && ( + )} - {detail?.kind === "freefood" && } - {detail?.kind === "club" && } {detail?.kind === "dining" && ( - + )} - {directions && ( )} -
+
); } diff --git a/apps/web/src/components/CampusMap.tsx b/apps/web/src/components/CampusMap.tsx index f452457..db9b5c0 100644 --- a/apps/web/src/components/CampusMap.tsx +++ b/apps/web/src/components/CampusMap.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { Coffee, LocateFixed, MapPin, Pizza, RotateCcw, Users, Utensils } from "lucide-react"; +import type { Marker as MapboxMarker, StyleSpecification } from "mapbox-gl"; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { GeolocateControl, Layer, @@ -6,31 +15,76 @@ import { type MapRef, Marker, NavigationControl, - Popup, Source, } from "react-map-gl/mapbox"; -import type { DiningHallMenu, EatingClub, FreefoodPost, POI } from "../types"; +import type { DiningHallMenu, EatingClub, FreefoodPost, POI, Selection } from "../types"; import { getCategoryColor, getCategoryIcon } from "../utils/categories"; import type { LatLng, RouteInfo } from "../utils/directions"; import { rememberFix } from "../utils/geolocation"; +import { polishMapStyle } from "../utils/mapStyle"; const CAMPUS_MAP_TOKEN = import.meta.env.VITE_CAMPUS_MAP_TOKEN; const CAMPUS_MAP_STYLE = import.meta.env.VITE_CAMPUS_MAP_STYLE; const TIGERAPPS_TOKEN = import.meta.env.VITE_TIGERAPPS_MAPBOX_TOKEN; +const CAMPUS_CENTER: [number, number] = [-74.6554, 40.3473]; +const motionDuration = (duration: number) => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : duration; + +function presentMarker(marker: MapboxMarker | null) { + const element = marker?.getElement(); + if (!element) return; + element.setAttribute("role", "presentation"); + element.removeAttribute("tabindex"); + element.removeAttribute("aria-label"); +} + +function PlaceMarker({ + at, + label, + tooltip, + className = "", + color, + selected, + onSelect, + children, +}: { + at: LatLng; + label: string; + tooltip: string; + className?: string; + color?: string; + selected: boolean; + onSelect: () => void; + children: ReactNode; +}) { + return ( + + + + ); +} interface CampusMapProps { pois: POI[]; - selectedPOI: POI | null; - onSelectPOI: (poi: POI | null) => void; + allPOIs: POI[]; freefoodPosts: FreefoodPost[]; - selectedFreefood: FreefoodPost | null; - onSelectFreefood: (post: FreefoodPost | null) => void; eatingClubs: EatingClub[]; - selectedClub: EatingClub | null; - onSelectClub: (club: EatingClub | null) => void; diningMenus: DiningHallMenu[]; - selectedDining: DiningHallMenu | null; - onSelectDining: (menu: DiningHallMenu | null) => void; + selected: Selection; + onSelect: (selection: Selection) => void; route: RouteInfo | null; routeOrigin: LatLng | null; routeDest: { name: string; lat: number; lng: number } | null; @@ -38,469 +92,337 @@ interface CampusMapProps { export function CampusMap({ pois, - selectedPOI, - onSelectPOI, + allPOIs, freefoodPosts, - selectedFreefood, - onSelectFreefood, eatingClubs, - selectedClub, - onSelectClub, diningMenus, - selectedDining, - onSelectDining, + selected, + onSelect, route, routeOrigin, routeDest, }: CampusMapProps) { const mapRef = useRef(null); + const [mapStyle, setMapStyle] = useState(); + const [mapFailed, setMapFailed] = useState(false); + const [loaded, setLoaded] = useState(false); - // Frame the route (or fly to the destination while routes are loading) - useEffect(() => { - const map = mapRef.current; - if (!map || !routeDest) return; - if (route) { - const coords: [number, number][] = [...route.geometry.coordinates]; - if (routeOrigin) coords.push([routeOrigin.lng, routeOrigin.lat]); - coords.push([routeDest.lng, routeDest.lat]); - let minLng = Number.POSITIVE_INFINITY; - let minLat = Number.POSITIVE_INFINITY; - let maxLng = Number.NEGATIVE_INFINITY; - let maxLat = Number.NEGATIVE_INFINITY; - for (const [lng, lat] of coords) { - if (lng < minLng) minLng = lng; - if (lat < minLat) minLat = lat; - if (lng > maxLng) maxLng = lng; - if (lat > maxLat) maxLat = lat; - } - const mobile = window.innerWidth < 640; - map.fitBounds( - [ - [minLng, minLat], - [maxLng, maxLat], - ], - { - padding: mobile - ? { top: 130, bottom: 250, left: 45, right: 70 } - : { top: 120, bottom: 240, left: 90, right: 100 }, - duration: 700, - maxZoom: 17.5, - }, + const loadStyle = useCallback(async (signal?: AbortSignal) => { + setMapFailed(false); + setMapStyle(undefined); + setLoaded(false); + try { + const styleId = ( + CAMPUS_MAP_STYLE || "mapbox://styles/applied-information-group/cld1kdenc001001m89xs12zvl" + ).replace("mapbox://styles/", ""); + const response = await fetch( + `https://api.mapbox.com/styles/v1/${styleId}?access_token=${CAMPUS_MAP_TOKEN}`, + { signal }, ); - } else { - map.flyTo({ center: [routeDest.lng, routeDest.lat], zoom: 16.5, duration: 500 }); - } - }, [route, routeOrigin, routeDest]); - - // Each handler only calls its own setter — App's single `detail` state - // handles mutual exclusion, so clearing others would overwrite with null. - const handleMarkerClick = useCallback( - (poi: POI) => { - onSelectPOI(poi); - mapRef.current?.flyTo({ center: [poi.lng, poi.lat], zoom: 17, duration: 500 }); - }, - [onSelectPOI], - ); - - const handleFoodClick = useCallback( - (post: FreefoodPost) => { - onSelectFreefood(post); - mapRef.current?.flyTo({ center: [post.lng, post.lat], zoom: 17, duration: 500 }); - }, - [onSelectFreefood], - ); - - const handleClubClick = useCallback( - (club: EatingClub) => { - onSelectClub(club); - mapRef.current?.flyTo({ center: [club.lng, club.lat], zoom: 17, duration: 500 }); - }, - [onSelectClub], - ); - - const handleDiningClick = useCallback( - (menu: DiningHallMenu) => { - onSelectDining(menu); - mapRef.current?.flyTo({ center: [menu.hall.lng, menu.hall.lat], zoom: 17, duration: 500 }); - }, - [onSelectDining], - ); - - // Fetch Princeton's style, inject TigerApps tileset sources + custom layers - const [mapStyle, setMapStyle] = useState(undefined); - - useEffect(() => { - const styleUrl = `https://api.mapbox.com/styles/v1/applied-information-group/cld1kdenc001001m89xs12zvl?access_token=${CAMPUS_MAP_TOKEN}`; - fetch(styleUrl) - .then((r) => r.json()) - .then((style) => { - // Add TigerApps tilesets as a separate source (different token) - style.sources["tigerapps"] = { + if (!response.ok) throw new Error("Map unavailable"); + const style: StyleSpecification = await response.json(); + if (TIGERAPPS_TOKEN) { + style.sources.tigerapps = { url: "mapbox://tigerapps.downtown-princeton-buildings,tigerapps.8mfuncwk", type: "vector", }; - - // Find insertion point: before first symbol layer - const firstSymIdx = style.layers.findIndex((l: { type: string }) => l.type === "symbol"); - const insertAt = firstSymIdx >= 0 ? firstSymIdx : style.layers.length; - - // Downtown buildings (campus-green style, before labels) - const overlayLayers = [ - { - id: "downtown-buildings-shadow", - type: "fill", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "fill-color": "rgba(0,0,0,0.08)", "fill-translate": [2, 2] }, - }, + const beforeLabels = style.layers.findIndex((layer) => layer.type === "symbol"); + style.layers.splice( + beforeLabels < 0 ? style.layers.length : beforeLabels, + 0, { id: "downtown-buildings", type: "fill", source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "fill-color": "#b8c8b0", "fill-opacity": 0.85 }, + "source-layer": "Downtown Princeton Buildings", + paint: { "fill-color": "#ddd5c7", "fill-outline-color": "#b4aa99" }, }, { - id: "downtown-buildings-outline", - type: "line", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "line-color": "#8fa087", "line-width": 0.5, "line-opacity": 0.6 }, - }, - // Highlighted restaurants (light red) - { - id: "highlighted-restaurants-fill", + id: "highlighted-restaurants", type: "fill", source: "tigerapps", "source-layer": "highlighted-restaurants-3jo06s", - paint: { "fill-color": "#e8b0b0", "fill-opacity": 0.85 }, + paint: { "fill-color": "#e5cbb2", "fill-outline-color": "#c7a98e" }, }, - { - id: "highlighted-restaurants-outline", - type: "line", - source: "tigerapps", - "source-layer": "highlighted-restaurants-3jo06s", - paint: { "line-color": "#c49090", "line-width": 1 }, - }, - ]; + ); + } + setMapStyle(polishMapStyle(style)); + } catch { + if (!signal?.aborted) setMapFailed(true); + } + }, []); - style.layers.splice(insertAt, 0, ...overlayLayers); + useEffect(() => { + const controller = new AbortController(); + loadStyle(controller.signal); + return () => controller.abort(); + }, [loadStyle]); - // Labels (at the top) - style.layers.push( - { - id: "downtown-buildings-labels", - type: "symbol", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - filter: ["has", "name"], - layout: { - "text-field": ["get", "name"], - "text-size": 11, - "text-font": ["Open Sans SemiBold", "Arial Unicode MS Regular"], - "text-max-width": 8, - "text-anchor": "center", - }, - paint: { - "text-color": "#3d3d3d", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1, - }, - }, - { - id: "highlighted-restaurants-labels", - type: "symbol", - source: "tigerapps", - "source-layer": "highlighted-restaurants-3jo06s", - filter: ["has", "name"], - layout: { - "text-field": ["get", "name"], - "text-size": 12, - "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"], - "text-max-width": 7, - "text-anchor": "center", - "text-allow-overlap": true, - }, - paint: { - "text-color": "#002e2c", - "text-halo-color": "#ffffff", - "text-halo-width": 2, - }, - }, - ); + const focus = selected?.kind === "dining" ? selected.data.hall : selected?.data; + const selectedPOI = selected?.kind === "poi" ? selected.data : null; + useEffect(() => { + if (!loaded || routeDest) return; + if (!focus) { + mapRef.current?.easeTo({ + padding: { top: 0, bottom: 0, left: 0, right: 0 }, + duration: motionDuration(300), + }); + return; + } + const mobile = window.innerWidth < 700; + mapRef.current?.flyTo({ + center: [focus.lng, focus.lat], + zoom: 17, + padding: mobile + ? { + top: 210, + bottom: Math.min(window.innerHeight * 0.65, window.innerHeight - 220) + 16, + left: 36, + right: 36, + } + : { top: 180, bottom: 100, left: 80, right: 460 }, + duration: motionDuration(650), + }); + }, [focus, loaded, routeDest]); - setMapStyle(style); - }) - .catch(() => { - // Fallback to the original Princeton style URL if fetch fails - setMapStyle(CAMPUS_MAP_STYLE); + useEffect(() => { + const map = mapRef.current; + if (!map || !routeDest || !loaded) return; + const mobile = window.innerWidth < 700; + if (route) { + const coords = [...route.geometry.coordinates, [routeDest.lng, routeDest.lat]]; + if (routeOrigin) coords.push([routeOrigin.lng, routeOrigin.lat]); + const lngs = coords.map((point) => point[0]); + const lats = coords.map((point) => point[1]); + map.fitBounds( + [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)], + ], + { + padding: mobile + ? { top: 190, bottom: 310, left: 40, right: 70 } + : { top: 240, bottom: 100, left: 80, right: 460 }, + duration: motionDuration(700), + maxZoom: 17.5, + }, + ); + } else { + map.flyTo({ + center: [routeDest.lng, routeDest.lat], + zoom: 16.5, + duration: motionDuration(500), }); - }, []); + } + }, [route, routeOrigin, routeDest, loaded]); - if (!mapStyle) return
; + const visiblePOIs = + selectedPOI && !pois.some((poi) => poi.id === selectedPOI.id) ? [...pois, selectedPOI] : pois; return ( - { - // Route TigerApps tileset requests through the TigerApps token - if (url.includes("tigerapps.") && TIGERAPPS_TOKEN) { - return { - url: url.replace(/access_token=[^&]+/, `access_token=${TIGERAPPS_TOKEN}`), - } as any; - } - return { url } as any; - }} - initialViewState={{ longitude: -74.6554, latitude: 40.3473, zoom: 16, pitch: 0, bearing: 0 }} - minZoom={14} - maxZoom={19} - maxBounds={[ - [-74.675, 40.335], - [-74.645, 40.358], - ]} - mapStyle={mapStyle} - style={{ width: "100%", height: "100%" }} - reuseMaps - onClick={() => onSelectPOI(null)} - > - - - rememberFix({ - lat: e.coords.latitude, - lng: e.coords.longitude, - accuracy: e.coords.accuracy, - at: Date.now(), - }) - } - /> - - {/* Active route (orange over a white casing for contrast) */} - {route && ( - - - - - )} - {routeOrigin && ( - -
- +
+ {(!loaded || mapFailed) && ( +
+
+ +
+ {mapFailed ? ( + <> +

The campus map couldn’t load.

+ + + ) : ( + Loading campus map + )} +
)} - {routeDest && } - - {pois.map((poi) => ( - { - e.originalEvent.stopPropagation(); - handleMarkerClick(poi); + {mapStyle && ( + ({ + url: + url.includes("tigerapps.") && TIGERAPPS_TOKEN + ? url.replace(/access_token=[^&]+/, `access_token=${TIGERAPPS_TOKEN}`) + : url, + })} + initialViewState={{ + longitude: CAMPUS_CENTER[0], + latitude: CAMPUS_CENTER[1], + zoom: 15.9, + pitch: 0, + bearing: 0, }} - > -
- {getCategoryIcon(poi.cat)} -
-
- ))} - - {/* Free food markers */} - {freefoodPosts.map((post) => ( - { - e.originalEvent.stopPropagation(); - handleFoodClick(post); + minZoom={14} + maxZoom={19} + maxBounds={[ + [-74.675, 40.335], + [-74.645, 40.358], + ]} + mapStyle={mapStyle} + style={{ width: "100%", height: "100%" }} + interactiveLayerIds={["poi-label"]} + onLoad={() => { + setLoaded(true); + setMapFailed(false); }} - > -
-
- 🍕 -
-
-
- - ))} - - {/* Eating club markers */} - {eatingClubs.map((club) => ( - { - e.originalEvent.stopPropagation(); - handleClubClick(club); + onError={() => { + if (!loaded) setMapFailed(true); + }} + onClick={(event) => { + if ( + event.originalEvent.target instanceof Element && + event.originalEvent.target.closest(".map-marker") + ) + return; + const id = event.features?.[0]?.properties?.id; + const poi = allPOIs.find((point) => point.id === Number(id)); + onSelect(poi ? { kind: "poi", data: poi } : null); }} > -
- {club.name} - {club.eventCount > 0 && {club.eventCount}} + + + rememberFix({ + lat: event.coords.latitude, + lng: event.coords.longitude, + accuracy: event.coords.accuracy, + at: Date.now(), + }) + } + /> +
+
- - ))} - - {/* Eating club popup */} - {selectedClub && ( - onSelectClub(null)} - maxWidth="320px" - > -
-
- {selectedClub.name} + -
-

{selectedClub.name}

- Eating Club -
-
- {selectedClub.recentEvents.length > 0 ? ( -
-

- Recent Events -

-
- {selectedClub.recentEvents.map((event) => ( -
-

- {event.subject} -

-

- {new Date(event.date).toLocaleDateString()} · {event.type || "Event"} -

-
- ))} -
-
- ) : ( -

No recent events

- )} -
-
- )} - - {/* Dining hall markers — residential (large) and retail (small) */} - {diningMenus.map((menu) => { - const isResidential = menu.hall.category === "residential"; - const isSelected = selectedDining?.hall.id === menu.hall.id; - return ( - { - e.originalEvent.stopPropagation(); - handleDiningClick(menu); - }} - > -
+ + )} + {routeOrigin && ( + +
+ + )} + {routeDest && ( + - - {isResidential ? "🍽" : "☕"} + + -
-
- ); - })} - - {selectedPOI && ( - onSelectPOI(null)} - maxWidth="320px" - > -
- {selectedPOI.img && ( - {selectedPOI.name} - )} -

{selectedPOI.name}

- {selectedPOI.sub &&

{selectedPOI.sub}

} - {selectedPOI.cat && ( - + )} + {visiblePOIs.map((poi) => { + const Icon = getCategoryIcon(poi.cat); + return ( + onSelect({ kind: "poi", data: poi })} > - {selectedPOI.cat} - - )} - {selectedPOI.hours && ( -

- Hours: {selectedPOI.hours} -

- )} - {selectedPOI.desc && ( -

{selectedPOI.desc}

- )} -
-
+ + + ); + })} + {freefoodPosts.map((post) => ( + onSelect({ kind: "freefood", data: post })} + > + + + ))} + {eatingClubs.map((club) => ( + onSelect({ kind: "club", data: club })} + > + + {club.eventCount > 0 && } + + ))} + {diningMenus.map((menu) => { + const Icon = menu.hall.category === "residential" ? Utensils : Coffee; + return ( + onSelect({ kind: "dining", data: menu })} + > + + + ); + })} + )} - + + Princeton, New Jersey + +
); } diff --git a/apps/web/src/components/CategorySidebar.tsx b/apps/web/src/components/CategorySidebar.tsx index b2a25eb..51d6f7b 100644 --- a/apps/web/src/components/CategorySidebar.tsx +++ b/apps/web/src/components/CategorySidebar.tsx @@ -1,69 +1,73 @@ -import { X } from "lucide-react"; -import { getCategoryColor } from "../utils/categories"; +import { Check, X } from "lucide-react"; +import { getCategoryIcon, getCategoryLabel } from "../utils/categories"; interface CategorySidebarProps { categories: { name: string; count: number }[]; activeCategories: Set; onToggle: (name: string) => void; + onClear: () => void; + onSelectAll: () => void; onClose: () => void; } -const HIDDEN = new Set(["Steps", "steps", "Entrance", "Ramp"]); - export function CategorySidebar({ categories, activeCategories, onToggle, + onClear, + onSelectAll, onClose, }: CategorySidebarProps) { - const display = categories.filter((c) => !HIDDEN.has(c.name)); - + const allSelected = categories.every((category) => activeCategories.has(category.name)); return ( -
-
-

Categories

-
-
- {display.map(({ name, count }) => { - const isActive = activeCategories.has(name); - const color = getCategoryColor(name); +
+ {categories.map(({ name, count }) => { + const active = activeCategories.has(name); + const Icon = getCategoryIcon(name); return ( ); })}
- {activeCategories.size > 0 && ( -
+
+ {activeCategories.size} selected +
+
- )} -
+
+ ); } diff --git a/apps/web/src/components/DetailPanel.tsx b/apps/web/src/components/DetailPanel.tsx new file mode 100644 index 0000000..e90e41c --- /dev/null +++ b/apps/web/src/components/DetailPanel.tsx @@ -0,0 +1,204 @@ +import { ArrowLeft, type LucideIcon, Navigation, X } from "lucide-react"; +import { + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; + +const isMobile = () => window.matchMedia("(max-width: 699px)").matches; +const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export function DetailPanel({ + title, + category, + icon: Icon, + onClose, + onBack, + onDirections, + children, + className = "", + expanded: controlledExpanded, + onExpandedChange, +}: { + title: string; + category: string; + icon: LucideIcon; + onClose: () => void; + onBack?: () => void; + onDirections?: () => void; + children: ReactNode; + className?: string; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}) { + const panelRef = useRef(null); + const [localExpanded, setLocalExpanded] = useState(false); + const [offset, setOffset] = useState(0); + const [entering, setEntering] = useState(true); + const expanded = controlledExpanded ?? localExpanded; + const drag = useRef<{ + y: number; + delta: number; + origin: number; + target: Element; + id: number; + } | null>(null); + const skipHandleClick = useRef(false); + const closing = useRef(null); + const setExpanded = (value: boolean) => { + setLocalExpanded(value); + onExpandedChange?.(value); + }; + const close = () => { + const panel = panelRef.current; + if (!panel || closing.current) return; + if (reducedMotion()) { + onClose(); + return; + } + closing.current = panel.animate( + [ + { transform: getComputedStyle(panel).transform, opacity: 1 }, + isMobile() + ? { transform: "translateY(100%)", opacity: 1 } + : { transform: "translateY(16px)", opacity: 0 }, + ], + { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }, + ); + closing.current.finished.then(onClose, () => {}); + }; + const startDrag = (event: ReactPointerEvent) => { + if (!isMobile() || !event.isPrimary || event.button !== 0 || drag.current || closing.current) + return; + const target = event.target as Element; + const button = target.closest("button, a"); + if (button && !button.matches(".sheet-handle-button")) return; + const capture = button ?? event.currentTarget; + capture.setPointerCapture(event.pointerId); + skipHandleClick.current = false; + const transform = getComputedStyle(panelRef.current as HTMLElement).transform; + const origin = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42; + setEntering(false); + setOffset(origin); + drag.current = { y: event.clientY, delta: 0, origin, target: capture, id: event.pointerId }; + }; + const moveDrag = (event: ReactPointerEvent) => { + if (!drag.current || drag.current.id !== event.pointerId) return; + drag.current.delta = event.clientY - drag.current.y; + setOffset( + drag.current.origin + + (drag.current.delta > 0 ? drag.current.delta : drag.current.delta * 0.2), + ); + }; + const endDrag = (event: ReactPointerEvent, cancelled = false) => { + const gesture = drag.current; + if (!gesture || gesture.id !== event.pointerId) return; + drag.current = null; + if (gesture.target.hasPointerCapture(gesture.id)) + gesture.target.releasePointerCapture(gesture.id); + skipHandleClick.current = Math.abs(gesture.delta) > 4; + if (!cancelled && gesture.delta > 56) { + if (expanded && gesture.delta < (panelRef.current?.offsetHeight ?? 0) * 0.35) + setExpanded(false); + else close(); + } else if (!cancelled && gesture.delta < -40) setExpanded(true); + setOffset(0); + }; + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + const previous = document.activeElement as HTMLElement | null; + panel.focus({ preventScroll: true }); + let height = panel.getBoundingClientRect().height; + let resize: Animation | undefined; + const observer = new ResizeObserver(() => { + if (resize?.playState === "running") return; + const nextHeight = panel.getBoundingClientRect().height; + if (Math.abs(nextHeight - height) < 1) return; + if (!reducedMotion()) { + resize = panel.animate( + { + height: [`${height}px`, `${nextHeight}px`], + maxHeight: [`${height}px`, `${nextHeight}px`], + }, + { duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, + ); + } + height = nextHeight; + }); + observer.observe(panel); + return () => { + observer.disconnect(); + closing.current?.cancel(); + resize?.cancel(); + if (previous?.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + return ( +
{ + if (event.target === event.currentTarget) setEntering(false); + }} + aria-label={title} + style={offset ? { transform: `translateY(${offset}px)` } : undefined} + > +
endDrag(event, true)} + onLostPointerCapture={(event) => endDrag(event, true)} + > + +
+ {onBack ? ( + + ) : ( + + + + )} +
+ {category} +

{title}

+
+ +
+
+ {children} + {onDirections && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/DiningDetail.tsx b/apps/web/src/components/DiningDetail.tsx index 86cfaeb..5f7709c 100644 --- a/apps/web/src/components/DiningDetail.tsx +++ b/apps/web/src/components/DiningDetail.tsx @@ -1,79 +1,70 @@ -import { X } from "lucide-react"; +import { Coffee, Utensils } from "lucide-react"; +import { useState } from "react"; import type { DiningHallMenu } from "../types"; +import { DetailPanel } from "./DetailPanel"; interface DiningDetailProps { menu: DiningHallMenu; currentMeal: string; onClose: () => void; + onDirections: () => void; } -export function DiningDetail({ menu, currentMeal, onClose }: DiningDetailProps) { - const sortedMeals = [...menu.meals].sort((a, b) => { - if (a.meal === currentMeal) return -1; - if (b.meal === currentMeal) return 1; - return 0; - }); - +export function DiningDetail({ menu, currentMeal, onClose, onDirections }: DiningDetailProps) { + const [selectedMeal, setSelectedMeal] = useState( + menu.meals.find((meal) => meal.meal === currentMeal)?.meal ?? menu.meals[0]?.meal, + ); + const active = menu.meals.find((meal) => meal.meal === selectedMeal); const isRetail = menu.hall.category === "retail"; - return ( -
- {/* Header */} -
- 🍽 -
- {menu.hall.name} - {isRetail ? "Retail" : currentMeal} + + {menu.meals.length > 1 && ( +
+ {menu.meals.map((meal) => ( + + ))} +
+ )} + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+
+ Today’s menu +
- -
- - {/* Content */} -
- {sortedMeals.length === 0 && ( -

No menu available for today.

+ {active ? ( + Object.entries(active.stations).map(([station, items]) => ( +
+

{station}

+
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )) + ) : ( +

No menu is available today.

)} - - {sortedMeals.map((meal) => ( -
- {!(isRetail && sortedMeals.length === 1) && ( -
- {meal.meal} - {meal.meal === currentMeal && !isRetail && ( - — now serving - )} -
- )} - -
- {Object.entries(meal.stations).map(([station, items]) => ( -
-

{station}

-
    - {items.map((item, i) => ( -
  • - {item} -
  • - ))} -
-
- ))} -
-
- ))}
-
+ ); } diff --git a/apps/web/src/components/DirectionsSheet.tsx b/apps/web/src/components/DirectionsSheet.tsx index 499c15b..64360d5 100644 --- a/apps/web/src/components/DirectionsSheet.tsx +++ b/apps/web/src/components/DirectionsSheet.tsx @@ -1,14 +1,9 @@ -/** - * Directions bottom sheet — slides up from the bottom when a destination is - * chosen. Dark panel, walking/cycling toggle with live ETAs, orange accent - * matching the route line. - */ - -import { Bike, Footprints, LocateFixed, MapPin, RefreshCw, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Bike, ChevronDown, Footprints, LoaderCircle, MapPin, RefreshCw } from "lucide-react"; +import { useState } from "react"; import type { LatLng, RouteInfo, TravelProfile } from "../utils/directions"; import { formatArrival, formatDistance, formatMinutes } from "../utils/directions"; import type { LocateError } from "../utils/geolocation"; +import { DetailPanel } from "./DetailPanel"; export interface DirectionsState { dest: { name: string; lat: number; lng: number; cat?: string }; @@ -22,7 +17,7 @@ export interface DirectionsState { const LOCATE_MESSAGES: Record = { denied: "Location is blocked for this site", - unavailable: "Couldn't get a fix on your location", + unavailable: "Your location couldn’t be found", unsupported: "This browser can't share your location", }; @@ -33,8 +28,6 @@ interface DirectionsSheetProps { onClose: () => void; } -const ORANGE = "#e77500"; - function ModeButton({ profile, route, @@ -55,22 +48,21 @@ function ModeButton({ type="button" onClick={onClick} disabled={loading || !route} - aria-label={route ? `${label}, ${formatMinutes(route.durationS)}` : label} - className={`flex items-center justify-center gap-2.5 rounded-xl py-3 transition-colors ${ - active && route - ? "bg-[#e77500] text-white" - : "bg-[#2c2c2e] text-gray-200 hover:bg-[#3a3a3c] disabled:hover:bg-[#2c2c2e]" - }`} + aria-pressed={active} + className={`travel-mode ${active && route ? "is-active" : ""}`} > - - - {loading ? ( - … - ) : route ? ( - formatMinutes(route.durationS) - ) : ( - — - )} + + + {label} + + {loading ? ( + + ) : route ? ( + formatMinutes(route.durationS) + ) : ( + "Unavailable" + )} + ); @@ -85,176 +77,89 @@ export function DirectionsSheet({ const { dest, routes, profile, status } = state; const active = routes?.[profile]; const loading = status === "locating" || status === "routing"; - - // Expanded state reveals turn-by-turn steps (mobile only; desktop always - // shows them). Swipe up on the sheet header expands, swipe down collapses - // or dismisses; tapping the handle toggles. const [expanded, setExpanded] = useState(false); - const [dragY, setDragY] = useState(0); - const touchStartY = useRef(null); - // Ref mirror of dragY: touchend must read the final value even if React - // hasn't committed the last touchmove's state update yet. - const lastDy = useRef(0); - - // New destination — start collapsed again - // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on dest - useEffect(() => setExpanded(false), [dest]); - - const onTouchStart = (e: React.TouchEvent) => { - touchStartY.current = e.touches[0].clientY; - }; - const onTouchMove = (e: React.TouchEvent) => { - if (touchStartY.current === null) return; - const dy = e.touches[0].clientY - touchStartY.current; - // Elastic feedback while dragging, capped so the sheet never flies away - const capped = Math.max(-28, Math.min(64, dy * 0.5)); - lastDy.current = capped; - setDragY(capped); - }; - const onTouchEnd = () => { - const dy = lastDy.current; - touchStartY.current = null; - lastDy.current = 0; - setDragY(0); - if (dy < -10) setExpanded(true); - else if (dy > 16) { - if (expanded) setExpanded(false); - else onClose(); - } - }; - - const subline = - status === "locating" + const subline = loading + ? status === "locating" ? "Finding your location…" - : status === "routing" - ? "Finding the fastest paths…" - : status === "ready" && active - ? `${formatDistance(active.distanceM)} · arrive ${formatArrival(active.durationS)}` - : status === "no-location" - ? LOCATE_MESSAGES[state.locateError ?? "unavailable"] - : "No route found"; + : "Planning your route…" + : status === "ready" && active + ? `${formatDistance(active.distanceM)} · Arrive at ${formatArrival(active.durationS)}` + : status === "no-location" + ? LOCATE_MESSAGES[state.locateError ?? "unavailable"] + : "A route couldn’t be found"; return ( -
-
- {/* Drag/tap header: handle + destination + close */} -
-
setExpanded((v) => !v)} - > -
- + +
+

+ {loading && } + {subline} +

+ {status === "no-location" || status === "error" ? ( +
+ {state.locateError !== "unsupported" && ( + + )} + {status === "no-location" && state.locateError === "denied" && ( +

Allow location in your browser’s site settings, then try again.

+ )} + {status === "error" &&

Check your connection or try another campus destination.

}
- - {/* Destination */} -
- -
-

- {dest.name} -

-

- {subline} -

-
+ ) : ( +
+ {(["walking", "cycling"] as const).map((mode) => ( + onSelectProfile(mode)} + /> + ))}
-
- - {/* Actions */} -
- {status === "no-location" && state.locateError === "denied" ? ( - <> - -

- Allow location for this site in your browser's site settings. On a Mac, your browser - also needs Location Services on under System Settings → Privacy & Security. -

- - ) : status === "no-location" && state.locateError === "unsupported" ? null : status === - "no-location" ? ( - - ) : status === "error" ? ( + )} + {status === "ready" && active && active.steps.length > 0 && ( + <> - ) : ( -
- onSelectProfile("walking")} - /> - onSelectProfile("cycling")} - /> +
+
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable route steps */} +
    + {active.steps.map((step, i) => ( +
  1. + {i + 1} + {step.instruction} + {step.distanceM > 0 && ( + {formatDistance(step.distanceM)} + )} +
  2. + ))} +
+
- )} -
- - {/* Turn-by-turn steps */} - {status === "ready" && active && active.steps.length > 0 && ( -
-
-
    - {active.steps.map((step, i) => ( -
  1. - {step.instruction} - {step.distanceM > 0 && ( - - {formatDistance(step.distanceM)} - - )} -
  2. - ))} -
-
-
+ )}
-
+
); } diff --git a/apps/web/src/components/EatingClubDetail.tsx b/apps/web/src/components/EatingClubDetail.tsx index 93952b6..2dce41a 100644 --- a/apps/web/src/components/EatingClubDetail.tsx +++ b/apps/web/src/components/EatingClubDetail.tsx @@ -1,18 +1,19 @@ -import { ChevronLeft, ExternalLink, X } from "lucide-react"; +import { ArrowUpRight, ExternalLink, Users } from "lucide-react"; import { useEffect, useState } from "react"; import type { EatingClub } from "../types"; +import { getJSON, parseList } from "../utils/api"; +import { DetailPanel } from "./DetailPanel"; interface EatingClubDetailProps { club: EatingClub; onClose: () => void; + onDirections: () => void; } interface FullEvent { id: number; - message_id: string; subject: string; author_name: string; - author_email: string; date: string; body_text: string; images: string; @@ -25,151 +26,119 @@ function formatDate(dateStr: string): string { return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } -function parseImages(event: FullEvent): string[] { - try { - return JSON.parse(event.images || "[]"); - } catch { - return []; - } -} - /** LISTSERV-hosted attachments need the API's login; other hosts load directly. */ function imageSrc(event: FullEvent, url: string, index: number): string { return url.includes("lists.princeton.edu") ? `/api/eating-clubs/image/${event.id}/${index}` : url; } -export function EatingClubDetail({ club, onClose }: EatingClubDetailProps) { +export function EatingClubDetail({ club, onClose, onDirections }: EatingClubDetailProps) { const [events, setEvents] = useState([]); const [selectedEvent, setSelectedEvent] = useState(null); - + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [retry, setRetry] = useState(0); useEffect(() => { - fetch(`/api/eating-clubs/${encodeURIComponent(club.name)}/events?limit=20`) - .then((r) => r.json()) - .then((data) => setEvents(data.events ?? [])) - .catch(() => {}); - }, [club.name]); + const controller = new AbortController(); + setStatus("loading"); + getJSON<{ events?: FullEvent[] }>( + `/api/eating-clubs/${encodeURIComponent(club.name)}/events?limit=20`, + { signal: controller.signal, cache: retry ? "reload" : "default" }, + ) + .then((data) => { + setEvents(data.events ?? []); + setStatus("ready"); + }) + .catch(() => { + if (!controller.signal.aborted) setStatus("error"); + }); + return () => controller.abort(); + }, [club.name, retry]); return ( -
- {/* Header */} -
- {selectedEvent ? ( - - ) : ( - - )} - - {selectedEvent ? "Event Detail" : club.name} - - -
- - {/* Content */} -
+ setSelectedEvent(null) : undefined} + > + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
{selectedEvent ? ( - // ── Full event view ── -
-

- {selectedEvent.subject} -

-
- {selectedEvent.event_type && ( - - {selectedEvent.event_type} - - )} - {formatDate(selectedEvent.date)} + <> +
+ {selectedEvent.event_type || "Club event"} +
- - {selectedEvent.body_text && ( -

- {selectedEvent.body_text} -

- )} - - {parseImages(selectedEvent).length > 0 && ( -
- {parseImages(selectedEvent).map((url, i) => ( - - ))} -
- )} - -
-

- From: {selectedEvent.author_name} -

+ {selectedEvent.body_text &&

{selectedEvent.body_text}

} + {parseList(selectedEvent.images).map((url, i) => ( + {`Event + ))} +
+

From {selectedEvent.author_name}

{selectedEvent.listserv_url && ( - - - View on LISTSERV + + + Original post )}
-
+ ) : ( - // ── Event list view ── -
-

- Recent Events ({events.length}) -

- {events.length > 0 ? ( -
- {events.map((event) => ( - - ))} + <> +
+ Recent events + {status === "ready" ? events.length : ""} +
+ {status === "loading" && ( +
+ + +
- ) : ( -

No recent events found

)} -
+ {status === "error" && ( +

+ Events couldn’t load.{" "} + +

+ )} + {status === "ready" && !events.length && ( +

No recent events have been posted.

+ )} + {status === "ready" && + events.map((event) => ( + + ))} + )}
-
+ ); } diff --git a/apps/web/src/components/FreefoodDetail.tsx b/apps/web/src/components/FreefoodDetail.tsx index f137842..2038e4d 100644 --- a/apps/web/src/components/FreefoodDetail.tsx +++ b/apps/web/src/components/FreefoodDetail.tsx @@ -1,95 +1,60 @@ -import { ExternalLink, MapPin, X } from "lucide-react"; +import { ExternalLink, MapPin, Pizza } from "lucide-react"; import type { FreefoodPost } from "../types"; +import { parseList } from "../utils/api"; +import { DetailPanel } from "./DetailPanel"; interface FreefoodDetailProps { post: FreefoodPost; onClose: () => void; + onDirections: () => void; } function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60_000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; + const mins = Math.max(0, Math.floor((Date.now() - new Date(dateStr).getTime()) / 60_000)); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins} min ago`; const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; + return hrs < 24 ? `${hrs} hr ago` : `${Math.floor(hrs / 24)} days ago`; } -export function FreefoodDetail({ post, onClose }: FreefoodDetailProps) { - const images: string[] = (() => { - try { - return JSON.parse(post.images); - } catch { - return []; - } - })(); - +export function FreefoodDetail({ post, onClose, onDirections }: FreefoodDetailProps) { return ( -
- {/* Header */} -
- 🍕 - FREE FOOD - -
- - {/* Content */} -
-

{post.subject}

- -
- - + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+
+ + {post.location_name} - {timeAgo(post.date)} +
- - {post.body_text && ( -

- {post.body_text.split("-----")[0].trim()} -

- )} - - {images.length > 0 && ( -
- {images.map((_, i) => ( - - ))} -
- )} - -
-

- From: {post.author_name} -

+ {post.body_text &&

{post.body_text.split("-----")[0].trim()}

} + {parseList(post.images).map((_, i) => ( + {`Food + ))} +
+

From {post.author_name}

{post.listserv_url && ( - - - View on LISTSERV + + + Original post )}
-
+
); } diff --git a/apps/web/src/components/POIDetail.tsx b/apps/web/src/components/POIDetail.tsx index 2368741..500b1d1 100644 --- a/apps/web/src/components/POIDetail.tsx +++ b/apps/web/src/components/POIDetail.tsx @@ -1,101 +1,71 @@ -import { ExternalLink, MapPin, Navigation, Phone, X } from "lucide-react"; +import { Accessibility, ArrowUpRight, Clock, ExternalLink, MapPin, Phone } from "lucide-react"; import type { POI } from "../types"; -import { getCategoryColor } from "../utils/categories"; +import { getCategoryIcon } from "../utils/categories"; +import { DetailPanel } from "./DetailPanel"; interface POIDetailProps { poi: POI; onClose: () => void; - onDirections: (poi: POI) => void; + onDirections: () => void; } export function POIDetail({ poi, onClose, onDirections }: POIDetailProps) { - const color = getCategoryColor(poi.cat); - return ( -
- {poi.img && ( -
- {poi.name} - -
- )} -
- {!poi.img && ( -
- -
- )} -

{poi.name}

- {poi.sub &&

{poi.sub}

} - {poi.cat && ( - - {poi.cat} - + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+ {poi.img && ( + {poi.name} { + e.currentTarget.hidden = true; + }} + /> )} - - {poi.desc &&

{poi.desc}

} -
+ {poi.sub &&

{poi.sub}

} + {poi.desc &&

{poi.desc}

} +
{poi.hours && ( -
- Hours: - {poi.hours} +
+ + {poi.hours}
)} {poi.addr && ( -
- - {poi.addr} +
+ + {poi.addr}
)} {poi.phone && ( - - - {poi.phone} + + + {poi.phone} )} {poi.web && ( - - - Visit website + + + Visit website + )} + {poi.access === "true" && ( +
+ + Wheelchair accessible +
+ )}
- {poi.access === "true" && ( -
- Wheelchair Accessible -
- )}
-
+ ); } diff --git a/apps/web/src/components/PlaceCarousel.tsx b/apps/web/src/components/PlaceCarousel.tsx new file mode 100644 index 0000000..b51d212 --- /dev/null +++ b/apps/web/src/components/PlaceCarousel.tsx @@ -0,0 +1,22 @@ +import type { POI } from "../types"; +import { getCategoryIcon } from "../utils/categories"; + +export function PlaceCarousel({ places, onPick }: { places: POI[]; onPick: (poi: POI) => void }) { + return ( +
+ {places.map((poi) => { + const Icon = getCategoryIcon(poi.cat); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/SearchBar.tsx b/apps/web/src/components/SearchBar.tsx index d3db8ba..c6bec65 100644 --- a/apps/web/src/components/SearchBar.tsx +++ b/apps/web/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ -import { Search } from "lucide-react"; +import { ArrowUpRight, Search, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { POI } from "../types"; -import { getCategoryColor } from "../utils/categories"; +import { getCategoryIcon } from "../utils/categories"; interface SearchBarProps { value: string; @@ -15,82 +15,120 @@ export function SearchBar({ value, onChange, results, onPick }: SearchBarProps) const [activeIdx, setActiveIdx] = useState(0); const inputRef = useRef(null); - // Reset highlight whenever the result set changes - useEffect(() => setActiveIdx(0), [results]); + useEffect(() => { + const shortcut = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === "k") { + event.preventDefault(); + inputRef.current?.focus(); + } + }; + window.addEventListener("keydown", shortcut); + return () => window.removeEventListener("keydown", shortcut); + }, []); const pick = (poi: POI) => { setOpen(false); - inputRef.current?.blur(); onPick(poi); }; - - const showDropdown = open && value.trim().length >= 2 && results.length > 0; + const showDropdown = open && value.trim().length >= 2; return ( -
- { - onChange(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={(e) => { - if (!showDropdown) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIdx((i) => Math.min(i + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIdx((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - pick(results[activeIdx] ?? results[0]); - } else if (e.key === "Escape") { - setOpen(false); +
+
+
{showDropdown && ( -
    - {results.map((poi, i) => ( -
  • - -
  • - ))} -
+
+ Places on campus + {/* biome-ignore lint/a11y/useSemanticElements: custom combobox uses aria-activedescendant */} +
+ {results.map((poi, i) => { + const Icon = getCategoryIcon(poi.cat); + return ( + + ); + })} +
+ {!results.length && ( +

+ No places match “{value.trim()}”. Try a building or library name. +

+ )} +
)}
); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4fb05c1..6edbcc2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,328 +1,330 @@ @import "tailwindcss"; @import "mapbox-gl/dist/mapbox-gl.css"; -html, -body, -#root { - height: 100%; - margin: 0; - font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -/* Lock the page: the app is a fixed full-screen map — no document scrolling, - no iOS rubber-band bounce (which showed white past the page edges), and no - browser pinch-zoom of the UI. The Mapbox canvas manages its own gestures. */ -html { - background: #1c1c1e; - overflow: hidden; - overscroll-behavior: none; -} -body { - position: fixed; - inset: 0; - width: 100%; - overflow: hidden; - overscroll-behavior: none; - touch-action: pan-x pan-y; - -webkit-text-size-adjust: 100%; -} - -/* ── Shared detail panel ────────────────────────── */ -.detail-panel { - position: absolute; - bottom: 1rem; - right: 1rem; - z-index: 20; - width: 380px; - max-height: 70vh; - background: white; - border-radius: 0.75rem; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08); - border: 1px solid #e5e7eb; - overflow: hidden; - display: flex; - flex-direction: column; -} - -/* Mobile: full-width bottom sheet */ -@media (max-width: 640px) { - .detail-panel { - left: 0; - right: 0; - bottom: 0; - width: 100%; - max-height: 55vh; - border-radius: 1rem 1rem 0 0; - border-bottom: none; - } -} - -/* ── Directions sheet ──────────────────────────────── */ -.directions-sheet-wrap { - position: absolute; - left: 0; - right: 0; - bottom: 0; - z-index: 30; - display: flex; - justify-content: center; - pointer-events: none; -} - -.directions-sheet { - pointer-events: auto; - touch-action: none; - width: 100%; - background: #1c1c1e; - border-radius: 1rem 1rem 0 0; - padding: 0 1rem calc(1rem + env(safe-area-inset-bottom)); - box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35); - animation: sheet-up 300ms cubic-bezier(0.32, 0.72, 0.3, 1); - transition: transform 220ms cubic-bezier(0.32, 0.72, 0.3, 1); -} - -/* Turn-by-turn steps: collapsed on mobile until the sheet is expanded - (swipe up or tap the handle); always open on desktop. */ -.steps-wrap { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 320ms cubic-bezier(0.32, 0.72, 0.3, 1); -} -.steps-wrap.steps-open { - grid-template-rows: 1fr; -} -.steps-inner { - overflow: hidden; - min-height: 0; -} -.steps-list { - max-height: 40dvh; - overflow-y: auto; - overscroll-behavior: contain; - touch-action: pan-y; -} -@media (min-width: 640px) { - .steps-wrap { - grid-template-rows: 1fr; - } - .steps-list { - max-height: 13rem; - } -} -@media (prefers-reduced-motion: reduce) { - .steps-wrap, - .directions-sheet { - transition: none; - } -} +:root { + --surface: oklch(98.5% 0.009 85); + --surface-soft: oklch(96% 0.012 85); + --ink: oklch(28% 0.018 70); + --muted: oklch(49% 0.022 75); + --faint: oklch(65% 0.022 75); + --line: oklch(89% 0.018 85); + --accent: oklch(51% 0.133 44); + --accent-soft: oklch(94% 0.036 65); + --green: oklch(46% 0.051 145); + --shadow: 0 8px 32px oklch(28% 0.03 70 / 9%), 0 2px 6px oklch(28% 0.03 70 / 4%); + --ease: cubic-bezier(0.22, 1, 0.36, 1); + --space-sm: 8px; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-synthesis: none; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +html, body, #root { height: 100%; margin: 0; } +html { background: var(--surface-soft); overflow: hidden; overscroll-behavior: none; } +body { position: fixed; inset: 0; overflow: hidden; overscroll-behavior: none; -webkit-text-size-adjust: 100%; } +button, input { font: inherit; } +button, a, input { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: 0.55; } +button, a { transition: color 160ms var(--ease), background-color 160ms var(--ease), border-color 160ms var(--ease), transform 160ms var(--ease); } +button:active:not(:disabled) { transform: scale(0.97); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.detail-panel:focus { outline: none; } +h1, h2, h3, p { margin: 0; } +h1, h2, .brand-name { font-family: "Bricolage Grotesque", sans-serif; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +svg { flex-shrink: 0; } +::selection { background: var(--accent-soft); } +* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; } +.map-app, .campus-map { position: relative; width: 100%; height: 100%; } +.map-app { overflow: hidden; } +.campus-map { background: #eae9df; } +.campus-map > .mapboxgl-map { opacity: 0; transition: opacity 500ms var(--ease); } +.campus-map.is-loaded > .mapboxgl-map { opacity: 1; } +.map-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; color: var(--muted); font-size: 14px; } +.map-loading .primary-button { width: auto; min-width: 120px; } +.map-loading-symbol { color: var(--accent); animation: loading-breathe 1.6s ease-in-out infinite; } +.map-context { position: absolute; top: 28px; right: 28px; display: flex; align-items: center; gap: 8px; border: 1px solid oklch(89% 0.018 85 / 75%); border-radius: 24px; background: var(--surface); padding: 10px 16px; color: var(--muted); font-size: 11px; font-weight: 600; pointer-events: none; } +.map-workspace { position: absolute; top: 24px; left: 24px; width: 360px; z-index: 10; display: flex; flex-direction: column; gap: 16px; max-height: calc(100dvh - 68px); pointer-events: none; animation: arrive 550ms var(--ease) both; } +.map-workspace > * { pointer-events: auto; } +.app-brand { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); } +.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; background: var(--accent); color: var(--surface); border-radius: 13px; } +.brand-name { display: block; font-size: 25px; font-weight: 700; line-height: 1.1; letter-spacing: -1px; } +.brand-period { color: var(--accent); } +.brand-subtitle { display: block; font-size: 10px; color: var(--muted); margin-top: 4px; letter-spacing: 0.03em; } +.brand-byline { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; gap: 3px; min-height: 44px; font-size: 10px; color: var(--muted); white-space: nowrap; } +.brand-byline:hover { color: var(--accent); } +.map-search-tools { position: relative; display: flex; flex-direction: column; gap: 10px; } +.search-bar { position: relative; z-index: 3; } +.search-field { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 4px 8px 4px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); color: var(--accent); transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease); } +.search-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 12%, transparent), var(--shadow); } +.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 15px; font-weight: 500; } +.search-field input::placeholder { color: var(--muted); } +.search-field input::-webkit-search-cancel-button { display: none; } +kbd { display: inline-flex; gap: 4px; padding: 5px 7px; margin-right: 8px; color: var(--muted); font-family: inherit; font-size: 10px; border: 1px solid var(--line); border-radius: 5px; white-space: nowrap; } +.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; border: 0; border-radius: 10px; background: transparent; color: var(--muted); } +.icon-button:hover { background: var(--surface-soft); color: var(--ink); } +.icon-button.is-active { color: var(--accent); background: var(--accent-soft); } +.search-dropdown { position: absolute; left: 0; right: 0; top: calc(100% + 8px); background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 16px 8px 8px; box-shadow: var(--shadow); max-height: min(480px, 65dvh); overflow-y: auto; animation: arrive 220ms var(--ease); } +.search-dropdown > .eyebrow { padding: 0 12px; } +.search-dropdown [role="listbox"] { padding-top: 8px; } +.eyebrow { display: flex; align-items: center; gap: 6px; font-size: 9px; line-height: 1.5; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); } +.filter-strip { display: flex; align-items: center; gap: 6px; } +.quick-filters { display: flex; align-items: center; gap: 6px; flex: 1; overflow-x: auto; padding: 4px 1px; scrollbar-width: none; } +.quick-filters::-webkit-scrollbar { display: none; } +.filter-chip { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; min-height: 38px; padding: 0 12px; border-radius: 20px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; } +.filter-chip:hover { border-color: var(--faint); color: var(--ink); } +.filter-chip.is-active { background: var(--ink); border-color: var(--ink); color: var(--surface); } +.chip-count { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 3px; background: var(--accent); color: var(--surface); border-radius: 8px; font-size: 9px; } +.filter-more { width: 40px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 2px 5px oklch(28% 0.03 70 / 5%); } +.discovery-panel, .category-panel { overflow: hidden; min-height: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); animation: arrive 350ms var(--ease) both; } +.discovery-panel { overflow-y: auto; } +.app-brand, .map-search-tools { flex-shrink: 0; } +.discovery-intro { display: flex; align-items: center; min-height: 56px; padding: 12px 20px; cursor: pointer; list-style: none; } +.discovery-intro::-webkit-details-marker { display: none; } +.discovery-intro:hover { background: var(--surface-soft); } +.discovery-chevron { color: var(--muted); transition: transform 240ms var(--ease); } +.discovery-panel:not([open]) .discovery-chevron { transform: rotate(-90deg); } +.discovery-title { display: grid; flex: 1; align-items: center; text-align: left; } +.discovery-title > span { grid-area: 1 / 1; transition: opacity 150ms var(--ease), transform 150ms var(--ease), visibility 150ms; } +.discovery-title-compact { display: inline-flex; align-items: center; gap: 8px; font-family: "Manrope", sans-serif; font-size: 14px; font-weight: 650; line-height: 24px; letter-spacing: 0; } +.discovery-panel[open] .discovery-title-compact, .discovery-panel:not([open]) .discovery-title-expanded { opacity: 0; visibility: hidden; transform: translateY(-4px); } +.discovery-panel[open] .discovery-title-expanded, .discovery-panel:not([open]) .discovery-title-compact { opacity: 1; visibility: visible; transform: translateY(0); } +.popular-places .place-row { min-height: 52px; padding-top: 8px; padding-bottom: 8px; } +.has-detail .discovery-panel, .is-searching .discovery-panel { display: none; } +@supports (interpolate-size: allow-keywords) { + .discovery-panel::details-content { interpolate-size: allow-keywords; overflow: hidden; block-size: 0; transition: block-size 260ms var(--ease), content-visibility 260ms; transition-behavior: allow-discrete; } + .discovery-panel[open]::details-content { block-size: auto; } +} +.discovery-intro h1 { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 26px; font-weight: 600; letter-spacing: -1px; line-height: 1.15; } +.popular-places { padding: 8px 12px 12px; } +.place-row { width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px; border: 0; border-radius: 10px; background: transparent; text-align: left; } +.place-row:hover, .place-row.is-highlighted { background: var(--surface-soft); } +.place-icon { display: grid; place-items: center; flex-shrink: 0; width: 36px; height: 40px; color: var(--muted); } +.place-row-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; } +.place-row-copy strong { font-size: 12px; line-height: 1.4; font-weight: 650; color: var(--ink); } +.place-row-copy > span { font-size: 10px; color: var(--muted); } +.row-arrow { color: var(--faint); transition: transform 200ms var(--ease), color 200ms var(--ease); } +.place-row:hover .row-arrow { transform: translate(2px, -2px); color: var(--accent); } +.discovery-footer { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--line); padding: 16px 24px; color: var(--muted); font-size: 9px; } +.section-heading { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 16px 24px; gap: 10px; border-bottom: 1px solid var(--line); } +.section-heading h2 { font-size: 21px; margin-top: 0; letter-spacing: -0.5px; } +.category-panel { display: flex; flex-direction: column; } +.category-list { display: flex; flex-direction: column; gap: var(--space-sm); overflow-y: auto; padding: 12px; overscroll-behavior: contain; } +.category-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 46px; padding: 8px 12px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 12px; text-align: left; } +.category-row:hover { background: var(--surface-soft); } +.category-row.is-active { background: var(--accent-soft); color: var(--accent); } +.category-row > span:nth-child(2) { flex: 1; } +.category-count { font-size: 10px; font-variant-numeric: tabular-nums; } +.category-check { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid var(--line); border-radius: 5px; } +.category-check svg { animation: check-arrive 160ms var(--ease); } +.category-actions { display: flex; align-items: center; gap: 16px; } +.category-actions .text-button { min-height: 44px; } +.is-active .category-check { background: var(--accent); border-color: var(--accent); color: var(--surface); } +.category-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } +.text-button { background: transparent; border: 0; color: var(--accent); font-size: inherit; font-weight: 700; padding: 12px 0; } +.text-button:hover { text-decoration: underline; } +.detail-panel { position: absolute; right: 88px; bottom: 32px; z-index: 20; display: flex; flex-direction: column; width: 380px; max-height: calc(100dvh - 112px); background: var(--surface); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; box-shadow: var(--shadow); transition: transform 250ms var(--ease); } +.detail-panel.is-entering { animation: panel-arrive 350ms var(--ease) backwards; } +.detail-header, .detail-actions { flex-shrink: 0; } +.detail-header { display: flex; align-items: flex-start; gap: 12px; padding: 24px 16px 20px 24px; } +.detail-header > .icon-button { margin-top: -7px; margin-right: -4px; } +.detail-category-icon { color: var(--accent); padding-top: 6px; } +.detail-heading { flex: 1; min-width: 0; } +.detail-heading h2 { font-size: 23px; font-weight: 600; line-height: 1.2; letter-spacing: -0.5px; margin-top: 5px; overflow-wrap: anywhere; } +.detail-content { overflow-y: auto; min-height: 0; flex: 1; padding: 0 24px 24px; overscroll-behavior: contain; animation: arrive 250ms var(--ease); } +.detail-subtitle { font-size: 13px; color: var(--muted); margin-bottom: 16px; } +.detail-description, .event-body { font-size: 13px; line-height: 1.8; color: var(--muted); overflow-wrap: anywhere; } +.place-photo { display: block; width: 100%; aspect-ratio: 16/10; object-fit: cover; border-radius: 12px; margin-bottom: 20px; } +.place-photo[hidden] { display: none; } +.place-facts { display: flex; flex-direction: column; gap: 16px; margin-top: 20px; font-size: 12px; } +.place-facts > div, .place-facts > a { display: flex; align-items: flex-start; gap: 12px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; } +.place-facts > a { color: var(--accent); min-height: 28px; } +.place-facts svg { margin-top: 2px; } +.accessible-note { color: var(--green) !important; } +.detail-actions { padding: 16px 24px 20px; border-top: 1px solid var(--line); background: var(--surface); } +.primary-button { width: 100%; min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 12px 20px; border: 1px solid transparent; border-radius: 11px; background: var(--accent); color: var(--surface); font-size: 13px; font-weight: 700; } +.primary-button:hover { background: color-mix(in oklch, var(--accent) 85%, var(--ink)); } +.meal-tabs { display: flex; gap: 4px; margin: 0 24px 20px; padding: 4px; border: 0; border-radius: 10px; background: var(--surface-soft); flex-shrink: 0; overflow-x: auto; } +.meal-tabs button { flex: 1; padding: 10px 12px; min-height: 40px; border: 0; background: transparent; border-radius: 7px; color: var(--muted); font-size: 11px; font-weight: 600; } +.meal-tabs button.is-active { background: var(--surface); color: var(--ink); box-shadow: 0 1px 4px oklch(28% 0.03 70 / 9%); } +.menu-date { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; padding: 2px 0 18px; } +.menu-station { padding: 16px 0; border-top: 1px solid var(--line); } +.menu-station h3 { color: var(--green); font-size: 12px; font-weight: 700; margin-bottom: 10px; } +.menu-station ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.menu-station li { font-size: 13px; line-height: 1.5; color: var(--muted); } +.event-meta { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px 16px; color: var(--muted); font-size: 11px; margin-bottom: 20px; } +.event-meta > span { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); } +.event-body { white-space: pre-line; } +.event-photo { display: block; width: 100%; height: auto; border-radius: 10px; margin-top: 16px; } +.event-source { border-top: 1px solid var(--line); margin-top: 24px; padding-top: 16px; font-size: 11px; color: var(--muted); } +.event-source a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; } +.event-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 8px; text-align: left; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; } +.event-row:hover { background: var(--surface-soft); } +.event-row > span { display: flex; flex-direction: column; gap: 8px; } +.event-row strong { color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1.5; } +.event-row span span { color: var(--muted); font-size: 11px; } +.event-row svg { color: var(--accent); } +.empty-state { font-size: 13px; color: var(--muted); line-height: 1.7; padding: 16px 12px; } +.detail-content > .empty-state { padding: 8px 0 16px; } +.places-skeleton { padding: 16px 24px; display: flex; flex-direction: column; gap: 16px; } +.places-skeleton span { height: 28px; border-radius: 6px; background: var(--surface-soft); animation: loading-breathe 1.6s ease-in-out infinite; } +.places-skeleton span:nth-child(2) { width: 80%; animation-delay: 100ms; } +.places-skeleton span:nth-child(3) { width: 65%; animation-delay: 200ms; } +.sheet-handle, .sheet-handle-button { display: none; } +.detail-panel.is-dragging { transition: none; } +.directions-sheet { z-index: 30; } +.directions-content { padding: 0 24px 24px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.route-summary { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; line-height: 1.6; margin-bottom: 20px; } +.travel-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.travel-mode { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--muted); text-align: left; } +.travel-mode > span { display: flex; flex-direction: column; gap: 4px; font-size: 10px; } +.travel-mode strong { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } +.travel-mode.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } +.travel-mode:hover:not(:disabled) { border-color: var(--accent); } +.route-error > p { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--muted); } +.steps-toggle { display: none; } +.steps-wrap { display: grid; grid-template-rows: 1fr; } +.steps-inner { min-height: 0; overflow: hidden; } +.steps-list { padding: 0; margin: 20px 0 0; list-style: none; max-height: 32dvh; overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y; } +.steps-list li { display: flex; align-items: baseline; gap: 10px; padding: 14px 0; border-top: 1px solid var(--line); font-size: 12px; line-height: 1.6; color: var(--muted); } +.step-number { width: 20px; flex-shrink: 0; font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums; } +.step-distance { margin-left: auto; flex-shrink: 0; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; } +.loading-spinner { animation: spin 900ms linear infinite; } +.map-marker { position: relative; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; background: transparent; color: var(--marker-color, var(--accent)); } +.marker-disc { position: relative; display: grid; place-items: center; width: 29px; height: 29px; background: var(--surface); border: 1.5px solid color-mix(in oklch, currentColor 35%, var(--surface)); border-radius: 50%; box-shadow: 0 2px 5px oklch(28% 0.03 70 / 12%); transition: transform 200ms var(--ease), background-color 200ms var(--ease), box-shadow 200ms var(--ease); } +.map-marker:hover .marker-disc, .map-marker:focus-visible .marker-disc { transform: translateY(-2px); box-shadow: 0 4px 12px oklch(28% 0.03 70 / 16%); } +.map-marker[aria-pressed="true"] .marker-disc { background: currentColor; outline: 4px solid color-mix(in oklch, currentColor 18%, transparent); transform: scale(1.1); } +.map-marker[aria-pressed="true"] svg { color: var(--surface); } +.food-marker { color: var(--accent); } +.food-marker .marker-disc { width: 34px; height: 34px; background: var(--accent); border-color: var(--surface); } +.food-marker svg { color: var(--surface); } +.dining-marker { color: var(--green); } +.club-marker { color: #796249; } +.marker-activity { position: absolute; top: -2px; right: -1px; width: 7px; height: 7px; border: 1.5px solid var(--surface); background: var(--accent); border-radius: 50%; } +.marker-tooltip { position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, 4px); opacity: 0; pointer-events: none; padding: 7px 10px; background: var(--ink); color: var(--surface); border-radius: 7px; font: 600 10px "Manrope", sans-serif; white-space: nowrap; transition: transform 180ms var(--ease), opacity 180ms var(--ease); } +.map-marker:hover .marker-tooltip, .map-marker:focus-visible .marker-tooltip { opacity: 1; transform: translate(-50%, 0); } +.mapboxgl-marker:has(.map-marker:hover), .mapboxgl-marker:has(.map-marker[aria-pressed="true"]) { z-index: 2; } +.route-origin-dot { width: 16px; height: 16px; background: #4e6679; border: 3px solid var(--surface); border-radius: 50%; box-shadow: 0 0 0 5px #4e667925; } +.route-destination { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; background: var(--accent); color: var(--surface); border: 3px solid var(--surface); box-shadow: var(--shadow); } +.mapboxgl-ctrl-bottom-right { right: 24px; bottom: 32px; } +.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { margin: 0 0 8px; } +.mapboxgl-ctrl-group { border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow) !important; border-radius: 12px; overflow: hidden; } +.mapboxgl-ctrl-group button { width: 42px; height: 44px; } +.mapboxgl-ctrl-group button + button { border-top: 1px solid var(--line); } +.mapboxgl-ctrl-group button:hover { background: var(--surface-soft); } +.mapboxgl-ctrl-group button:focus-visible { outline-offset: -4px; } +.mapboxgl-ctrl-icon { opacity: 0.68; } +.reset-map-control { position: absolute; right: 24px; bottom: 194px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); } +.reset-map-control .icon-button { width: 42px; } +.mapboxgl-ctrl-attrib { background: var(--surface) !important; color: var(--muted); font-size: 9px; } +.mapboxgl-ctrl-logo { opacity: 0.65; } +.t-icon-swap { display: inline-grid; } +.t-icon-swap .t-icon { grid-area: 1 / 1; display: grid; place-items: center; transition: opacity 250ms ease-in-out, filter 250ms ease-in-out, transform 250ms ease-in-out; } +.t-icon-swap[data-state="a"] .t-icon[data-icon="b"], .t-icon-swap[data-state="b"] .t-icon[data-icon="a"] { opacity: 0; filter: blur(2px); transform: scale(0.25); } +@keyframes check-arrive { from { opacity: 0; transform: scale(0.65); } to { opacity: 1; transform: scale(1); } } +@keyframes arrive { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes sheet-arrive { from { transform: translateY(100%); } to { transform: translateY(0); } } +@keyframes panel-arrive { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes loading-breathe { 50% { opacity: 0.4; } } +@keyframes spin { to { transform: rotate(360deg); } } -@media (min-width: 640px) { - .directions-sheet { - width: 420px; - margin-bottom: 1.5rem; - border-radius: 1rem; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.25); - } -} +@media (max-height: 720px) and (min-width: 700px) { + .map-workspace { gap: 12px; top: 16px; } -@keyframes sheet-up { - from { - transform: translateY(110%); - } - to { - transform: translateY(0); - } + .place-row { padding-top: 8px; padding-bottom: 8px; } + .discovery-footer { padding-top: 12px; padding-bottom: 12px; } +} +@media (min-width: 700px) and (max-width: 1050px) { + .map-workspace { width: 320px; left: 16px; } + .detail-panel { width: 340px; right: 76px; } + .filter-chip { padding: 0 8px; } + .map-context { right: 16px; } + .mapboxgl-ctrl-bottom-right, .reset-map-control { right: 16px; } +} +@media (max-width: 699px) { + .map-workspace { top: max(12px, env(safe-area-inset-top)); left: 12px; right: 12px; width: auto; height: calc(100dvh - max(12px, env(safe-area-inset-top)) - 12px); max-height: none; gap: 12px; } + .app-brand { padding: 9px 12px; border-radius: 14px; } + .brand-mark { width: 36px; height: 36px; border-radius: 11px; } + .brand-mark svg { width: 21px; } + .brand-name { font-size: 23px; } + .brand-subtitle { font-size: 9px; margin-top: 2px; } + .search-field { min-height: 54px; } + .search-field input { font-size: 16px; } + kbd { display: none; } + .map-search-tools { gap: 6px; } + .filter-chip { min-height: 40px; font-size: 12px; } + .filter-more { width: 44px; } + .map-context { display: none; } + .discovery-panel { position: absolute; left: 0; right: 0; bottom: calc(12px + env(safe-area-inset-bottom)); } + .discovery-intro { padding: 12px 18px; } + .discovery-intro h1 { font-size: 23px; } + .discovery-footer { display: none; } + .popular-places { display: flex; gap: var(--space-sm); overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding-inline: 12px; padding: 0 12px 12px; scrollbar-width: none; } + .popular-places .place-row { flex: 0 0 auto; width: auto; scroll-snap-align: start; gap: 8px; padding: 8px 10px; } + .popular-places .place-icon { height: 32px; } + .place-row-copy strong { font-size: 13px; } + .place-row-copy > span { font-size: 11px; } + .place-icon { width: 32px; } + .category-panel { max-height: calc(100dvh - 228px); } + .category-row { min-height: 48px; font-size: 14px; } + .category-count { font-size: 12px; } + .category-footer { font-size: 13px; } + .section-heading h2 { font-size: 22px; } + .detail-panel { left: 0; right: 0; bottom: 0; width: 100%; max-height: min(65dvh, calc(100dvh - 220px)); border-radius: 22px 22px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom); } + .detail-panel.is-entering { animation: sheet-arrive 350ms var(--ease) backwards; } + .detail-panel.is-expanded { max-height: min(86dvh, calc(100dvh - 100px)); } + .detail-header { padding: 20px 16px 20px 24px; } + .detail-heading h2 { font-size: 25px; } + .detail-heading .eyebrow { font-size: 10px; } + .detail-content { font-size: 14px; } + .detail-description, .event-body, .menu-station li, .event-row strong { font-size: 14px; } + .place-facts, .detail-subtitle { font-size: 14px; } + .place-facts > a { min-height: 44px; } + .primary-button { min-height: 48px; font-size: 14px; } + .menu-date, .event-meta { font-size: 12px; } + .menu-station h3 { font-size: 14px; } + .meal-tabs button { font-size: 12px; min-height: 44px; } + .sheet-drag-area { touch-action: none; flex-shrink: 0; cursor: grab; user-select: none; } + .is-dragging .sheet-drag-area { cursor: grabbing; } + .sheet-handle-button { display: grid; place-items: center; width: 100%; height: 44px; border: 0; background: transparent; } + .sheet-handle-button .sheet-handle { display: block; width: 32px; height: 4px; background: var(--line); border-radius: 4px; } + .sheet-drag-area .detail-header { padding-top: 4px; } + .route-summary { font-size: 13px; } + .travel-mode > span { font-size: 12px; } + .travel-mode strong { font-size: 17px; } + .route-error > p { font-size: 13px; } + .steps-wrap { grid-template-rows: 0fr; visibility: hidden; } + .steps-wrap.steps-open { grid-template-rows: 1fr; visibility: visible; } + .steps-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; border: 0; background: transparent; color: var(--muted); font-size: 13px; margin-top: 8px; } + .steps-toggle svg { transition: transform 200ms var(--ease); } + .steps-toggle svg.is-expanded { transform: rotate(180deg); } + .steps-list { max-height: 24dvh; margin-top: 4px; } + .steps-list li { font-size: 13px; } + .mapboxgl-ctrl-bottom-right { right: 12px; bottom: calc(176px + env(safe-area-inset-bottom)); } + .reset-map-control { right: 12px; bottom: calc(338px + env(safe-area-inset-bottom)); } + .has-detail .mapboxgl-ctrl-bottom-right { bottom: auto; top: 204px; display: flex; align-items: center; gap: 8px; } + .has-detail .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-group { display: flex; } + .has-detail .mapboxgl-ctrl-group button + button { border-top: 0; border-left: 1px solid var(--line); } + .has-detail .reset-map-control { display: none; } + .mapboxgl-ctrl-attrib { bottom: 0; } +} +@media (max-width: 699px) and (max-height: 550px) { + .app-brand { padding: 4px 8px; } + .brand-name { font-size: 20px; } + .brand-subtitle { display: none; } + .brand-mark { width: 28px; height: 28px; } + .brand-mark svg { width: 18px; } + .detail-panel { left: auto; right: 12px; bottom: 12px; width: 46%; max-height: calc(100dvh - 32px); border-radius: 16px; } + .detail-panel.is-expanded { max-height: calc(100dvh - 32px); } + .map-workspace { right: auto; width: 46%; } + .category-panel { max-height: calc(100dvh - 130px); } + .mapboxgl-ctrl-bottom-right { bottom: 16px; } + .reset-map-control { bottom: 178px; } + .has-detail .mapboxgl-ctrl-bottom-right { display: flex; left: 12px; right: auto; top: auto; bottom: 12px; } } - @media (prefers-reduced-motion: reduce) { - .directions-sheet { - animation: none; - } -} - -/* User's position at the start of a route */ -.route-origin-dot { - width: 15px; - height: 15px; - border-radius: 50%; - background: #2563eb; - border: 3px solid white; - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); -} - -/* Clean up MapBox popup defaults */ -.mapboxgl-popup-content { - padding: 0 !important; - border-radius: 12px !important; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06) !important; - overflow: hidden; -} -.mapboxgl-popup-close-button { - font-size: 18px; - padding: 4px 8px; - color: #94a3b8; -} -.mapboxgl-popup-close-button:hover { - color: #1e293b; - background: transparent; -} -.mapboxgl-popup-tip { - border-top-color: white !important; -} - -/* ── Free food markers ────────────────────────────── */ -.freefood-marker-wrapper { - display: flex; - flex-direction: column; - align-items: center; - cursor: pointer; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); -} - -.freefood-marker { - width: 36px; - height: 36px; - border-radius: 50%; - background: linear-gradient(135deg, #f97316, #f59e0b); - border: 2.5px solid white; - display: flex; - align-items: center; - justify-content: center; - animation: food-pulse 2.5s ease-in-out infinite; - transition: transform 0.15s ease; -} - -.freefood-marker-wrapper:hover .freefood-marker { - transform: scale(1.2); -} - -.freefood-marker-selected { - transform: scale(1.2); - box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.4); - animation: none; -} - -.freefood-marker-stem { - width: 2px; - height: 6px; - background: white; - border-radius: 0 0 1px 1px; -} - -@keyframes food-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.45); - } - 50% { - box-shadow: 0 0 0 10px rgba(249, 115, 22, 0); - } -} - -/* ── Eating club markers ─────────────────────────── */ -.eating-club-marker { - position: relative; - width: 28px; - height: 28px; - border-radius: 50%; - background: white; - border: 2px solid #d4a574; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); - transition: transform 0.15s ease, border-color 0.15s ease; -} - -.eating-club-marker:hover { - transform: scale(1.15); - border-color: #c4844a; -} - -.eating-club-marker-selected { - transform: scale(1.2); - border-color: #c4844a; - box-shadow: 0 0 0 3px rgba(212, 165, 116, 0.4); -} - -.eating-club-badge { - position: absolute; - top: -4px; - right: -4px; - min-width: 16px; - height: 16px; - border-radius: 8px; - background: #ef4444; - color: white; - font-size: 10px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - padding: 0 4px; - line-height: 1; -} - -/* ── Dining hall markers — residential (main dhalls) ── */ -.dining-marker-residential { - width: 38px; - height: 38px; - border-radius: 10px; - background: linear-gradient(135deg, #059669, #047857); - border: 2.5px solid white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25)); - transition: transform 0.15s ease; - position: relative; -} - -.dining-marker-residential::after { - content: ""; - position: absolute; - bottom: -5px; - left: 50%; - transform: translateX(-50%); - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid white; -} - -.dining-marker-residential:hover { - transform: scale(1.15); -} - -.dining-marker-residential.dining-marker-selected { - transform: scale(1.15); - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.4); -} - -/* ── Dining hall markers — retail (cafés) ──────────── */ -.dining-marker-retail { - width: 26px; - height: 26px; - border-radius: 50%; - background: white; - border: 2px solid #059669; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); - transition: transform 0.15s ease; -} - -.dining-marker-retail:hover { - transform: scale(1.15); -} - -.dining-marker-retail.dining-marker-selected { - transform: scale(1.15); - border-color: #047857; - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.3); + *, *::before, *::after, *::details-content { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index af8fc69..03914fe 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -74,3 +74,10 @@ export interface FreefoodPost { lat: number; lng: number; } + +export type Selection = + | { kind: "poi"; data: POI } + | { kind: "freefood"; data: FreefoodPost } + | { kind: "club"; data: EatingClub } + | { kind: "dining"; data: DiningHallMenu } + | null; diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts new file mode 100644 index 0000000..77ab587 --- /dev/null +++ b/apps/web/src/utils/api.ts @@ -0,0 +1,15 @@ +export async function getJSON(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) throw new Error(`${url} returned ${response.status}`); + return response.json(); +} + +// image lists arrive as JSON strings from the mail feeds +export function parseList(json: string | null | undefined): string[] { + try { + const parsed = JSON.parse(json || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} diff --git a/apps/web/src/utils/categories.ts b/apps/web/src/utils/categories.ts index 563f820..559b426 100644 --- a/apps/web/src/utils/categories.ts +++ b/apps/web/src/utils/categories.ts @@ -1,67 +1,77 @@ -const CATEGORY_COLORS: Record = { - Building: "#4a7c59", - University: "#4a7c59", - "Academic Department": "#2563eb", - "Administrative Department": "#6366f1", - College: "#b91c1c", - Dormitory: "#9333ea", - Library: "#0891b2", - "Dining Hall": "#ea580c", - Cafe: "#d97706", - Restaurant: "#dc2626", - "Food Court": "#ea580c", - "Convenience Store": "#65a30d", - "Parking Lot": "#3b82f6", - "Bus Stop": "#0ea5e9", - "Train Station": "#0284c7", - "Emergency Phone": "#dc2626", - "Public Art": "#ec4899", - "Events Venue": "#8b5cf6", - Theater: "#a855f7", - Gallery: "#d946ef", - Museum: "#c026d3", - "Sports Center": "#16a34a", - "Fitness Center": "#22c55e", - "Swimming Pool": "#06b6d4", - Park: "#15803d", - Entrance: "#64748b", - Ramp: "#64748b", - Steps: "#94a3b8", - Information: "#0ea5e9", - Shop: "#8b5cf6", -}; +import { + BookOpen, + Building2, + Bus, + Coffee, + Dumbbell, + GraduationCap, + House, + Info, + Landmark, + type LucideIcon, + MapPin, + Palette, + ParkingCircle, + Phone, + Pizza, + Shield, + ShoppingBag, + TrainFront, + Trees, + Users, + Utensils, + Waves, +} from "lucide-react"; -const CATEGORY_ICONS: Record = { - Building: "🏛", - University: "🏛", - "Academic Department": "📚", - "Administrative Department": "🏢", - College: "🛡", - Dormitory: "🏠", - Library: "📖", - "Dining Hall": "🍽", - Cafe: "☕", - Restaurant: "🍴", - "Parking Lot": "P", - "Bus Stop": "🚌", - "Train Station": "🚂", - "Emergency Phone": "📞", - "Public Art": "🎨", - "Events Venue": "🎭", - Theater: "🎭", - Gallery: "🖼", - Museum: "🏛", - "Sports Center": "⚽", - "Fitness Center": "💪", - Park: "🌳", - Entrance: "🚪", - Information: "ℹ", +const CATEGORY_ICONS: Record = { + Building: Building2, + University: Landmark, + "Academic Department": GraduationCap, + "Administrative Department": Building2, + College: Shield, + Dormitory: House, + Library: BookOpen, + "Dining Hall": Utensils, + Cafe: Coffee, + Restaurant: Utensils, + "Parking Lot": ParkingCircle, + "Bus Stop": Bus, + "Train Station": TrainFront, + "Emergency Phone": Phone, + "Public Art": Palette, + Museum: Landmark, + "Events Venue": Users, + Theater: Users, + Athletics: Dumbbell, + "Sports Center": Dumbbell, + "Fitness Center": Dumbbell, + "Swimming Pool": Waves, + Park: Trees, + Shop: ShoppingBag, + Information: Info, + "@dining": Utensils, + "@freefood": Pizza, + "@clubs": Users, }; +export const QUICK_CATEGORIES = [ + { name: "@dining", label: "Dining" }, + { name: "@freefood", label: "Free food" }, + { name: "@clubs", label: "Clubs" }, +]; + +export function getCategoryLabel(category: string): string { + return QUICK_CATEGORIES.find((c) => c.name === category)?.label ?? category; +} + export function getCategoryColor(category: string | null | undefined): string { - return CATEGORY_COLORS[category ?? ""] ?? "#64748b"; + if (["@dining", "Dining Hall", "Cafe"].includes(category ?? "")) return "#47644b"; + if (["@freefood", "Restaurant"].includes(category ?? "")) return "#ad4d20"; + if (["@clubs", "College", "Dormitory"].includes(category ?? "")) return "#796249"; + if (category === "Library") return "#4e6679"; + return "#62695b"; } -export function getCategoryIcon(category: string | null | undefined): string { - return CATEGORY_ICONS[category ?? ""] ?? "•"; +export function getCategoryIcon(category: string | null | undefined): LucideIcon { + return CATEGORY_ICONS[category ?? ""] ?? MapPin; } diff --git a/apps/web/tests/mobileSheets.browser.js b/apps/web/tests/mobileSheets.browser.js new file mode 100644 index 0000000..63f4db8 --- /dev/null +++ b/apps/web/tests/mobileSheets.browser.js @@ -0,0 +1,91 @@ +// Run with agent-browser eval --stdin in an open mobile detail or directions sheet +(async () => { + const panel = document.querySelector(".detail-panel"); + const handle = panel?.querySelector(".sheet-handle-button"); + if (!panel || !handle || !matchMedia("(max-width: 699px)").matches) + throw new Error("Open a sheet at a mobile viewport first"); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + const offset = () => { + const transform = getComputedStyle(panel).transform; + return transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42; + }; + const expanded = () => handle.getAttribute("aria-expanded") === "true"; + const pointer = (type, y) => + handle.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + pointerId: 12, + pointerType: "touch", + isPrimary: true, + button: 0, + clientY: y, + }), + ); + // Synthetic pointers cannot acquire native capture + const capture = ["setPointerCapture", "hasPointerCapture", "releasePointerCapture"]; + const original = capture.map((name) => Element.prototype[name]); + for (const name of capture) Element.prototype[name] = () => false; + const swipe = async (delta, cancel = false) => { + pointer("pointerdown", 400); + pointer("pointermove", 400 + delta); + await frame(); + assert( + Math.abs(offset() - (delta > 0 ? delta : delta * 0.2)) < 2, + "Sheet must follow the finger", + ); + pointer(cancel ? "pointercancel" : "pointerup", 400 + delta); + handle.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + await frame(); + }; + try { + await wait(400); + if (expanded()) { + handle.click(); + await wait(320); + } + const collapsedHeight = panel.getBoundingClientRect().height; + await swipe(-80, true); + assert(!expanded(), "Cancelled gesture must preserve expansion"); + await wait(320); + assert(Math.abs(offset()) < 1, "Cancelled gesture must return to rest"); + await swipe(-80); + assert(expanded(), "Swipe up must expand without a duplicate click"); + await wait(320); + assert( + panel.getBoundingClientRect().height >= collapsedHeight - 1, + "Expansion must not shrink the sheet", + ); + if (panel.matches(".directions-sheet") && panel.querySelector(".steps-wrap")) + assert( + panel.querySelector(".steps-wrap.steps-open"), + "Directions expansion must reveal route steps", + ); + await swipe(80); + assert( + !expanded() && panel.isConnected, + "Short downward swipe must collapse an expanded sheet", + ); + await wait(320); + handle.click(); + await frame(); + assert(expanded(), "Handle must support keyboard activation"); + handle.click(); + await wait(320); + await swipe(24); + assert(!expanded() && panel.isConnected, "Short drag must not dismiss or toggle the sheet"); + await wait(320); + assert(Math.abs(offset()) < 1, "Short drag must snap back without replaying entrance"); + await swipe(80); + await wait(280); + assert(!panel.isConnected, "Downward swipe must dismiss a collapsed sheet"); + return { passed: true, directions: panel.matches(".directions-sheet") }; + } finally { + capture.forEach((name, index) => { + Element.prototype[name] = original[index]; + }); + } +})(); diff --git a/apps/web/tests/panelResize.browser.js b/apps/web/tests/panelResize.browser.js new file mode 100644 index 0000000..b223430 --- /dev/null +++ b/apps/web/tests/panelResize.browser.js @@ -0,0 +1,24 @@ +// Run with agent-browser eval --stdin after opening a place detail panel +(async () => { + const panel = document.querySelector(".detail-panel"); + const content = panel?.querySelector(".detail-content"); + if (!panel || !content) throw new Error("Open a place detail panel first"); + const originalMaxHeight = content.style.maxHeight; + const before = panel.getBoundingClientRect().height; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + try { + content.style.maxHeight = "24px"; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const resize = panel + .getAnimations() + .find((animation) => animation.effect.getKeyframes().some((frame) => frame.height)); + if (reduced && resize) throw new Error("Panel resize ignores reduced motion"); + if (!reduced && !resize) throw new Error("Panel content resized without a transition"); + if (resize) await resize.finished; + const after = panel.getBoundingClientRect().height; + if (after >= before) throw new Error("Panel did not adapt to the shorter content"); + return { passed: true, reducedMotion: reduced, before, after }; + } finally { + content.style.maxHeight = originalMaxHeight; + } +})(); From 7f1a888af6656ab9fd7e806517345a68b59cbdc5 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:17:36 -0400 Subject: [PATCH 05/10] fix(web): keep map error overlay clickable above the canvas When Mapbox fails after the style loads, the mounted but transparent canvas covered the overlay and swallowed Retry clicks. --- apps/web/src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6edbcc2..8da9de2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -43,7 +43,7 @@ svg { flex-shrink: 0; } .campus-map { background: #eae9df; } .campus-map > .mapboxgl-map { opacity: 0; transition: opacity 500ms var(--ease); } .campus-map.is-loaded > .mapboxgl-map { opacity: 1; } -.map-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; color: var(--muted); font-size: 14px; } +.map-loading { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; color: var(--muted); font-size: 14px; } .map-loading .primary-button { width: auto; min-width: 120px; } .map-loading-symbol { color: var(--accent); animation: loading-breathe 1.6s ease-in-out infinite; } .map-context { position: absolute; top: 28px; right: 28px; display: flex; align-items: center; gap: 8px; border: 1px solid oklch(89% 0.018 85 / 75%); border-radius: 24px; background: var(--surface); padding: 10px 16px; color: var(--muted); font-size: 11px; font-weight: 600; pointer-events: none; } From 5a6a06184e50d8d094c47c88085929efb24ec68f Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:18:46 -0400 Subject: [PATCH 06/10] fix(web): fit route camera to the landscape panel layout Short screens place the panel on the right, but the camera still reserved portrait sheet padding taller than the canvas, so fitBounds failed and left the route off-screen. Camera padding now follows each layout, with extra clearance above the portrait directions sheet. --- apps/web/src/components/CampusMap.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/CampusMap.tsx b/apps/web/src/components/CampusMap.tsx index db9b5c0..50980ae 100644 --- a/apps/web/src/components/CampusMap.tsx +++ b/apps/web/src/components/CampusMap.tsx @@ -30,6 +30,17 @@ const CAMPUS_CENTER: [number, number] = [-74.6554, 40.3473]; const motionDuration = (duration: number) => window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : duration; +// keeps the camera target clear of the panels laid out in index.css +function cameraPadding(routing: boolean) { + const { innerWidth: width, innerHeight: height } = window; + if (width >= 700) return { top: routing ? 240 : 180, bottom: 100, left: 80, right: 460 }; + // short screens put the panel on the right and the search tools top left + if (height <= 550) return { top: 196, bottom: 64, left: 24, right: width * 0.46 + 24 }; + return routing + ? { top: 190, bottom: 334, left: 40, right: 70 } + : { top: 210, bottom: Math.min(height * 0.65, height - 220) + 16, left: 36, right: 36 }; +} + function presentMarker(marker: MapboxMarker | null) { const element = marker?.getElement(); if (!element) return; @@ -169,18 +180,10 @@ export function CampusMap({ }); return; } - const mobile = window.innerWidth < 700; mapRef.current?.flyTo({ center: [focus.lng, focus.lat], zoom: 17, - padding: mobile - ? { - top: 210, - bottom: Math.min(window.innerHeight * 0.65, window.innerHeight - 220) + 16, - left: 36, - right: 36, - } - : { top: 180, bottom: 100, left: 80, right: 460 }, + padding: cameraPadding(false), duration: motionDuration(650), }); }, [focus, loaded, routeDest]); @@ -188,7 +191,6 @@ export function CampusMap({ useEffect(() => { const map = mapRef.current; if (!map || !routeDest || !loaded) return; - const mobile = window.innerWidth < 700; if (route) { const coords = [...route.geometry.coordinates, [routeDest.lng, routeDest.lat]]; if (routeOrigin) coords.push([routeOrigin.lng, routeOrigin.lat]); @@ -200,9 +202,7 @@ export function CampusMap({ [Math.max(...lngs), Math.max(...lats)], ], { - padding: mobile - ? { top: 190, bottom: 310, left: 40, right: 70 } - : { top: 240, bottom: 100, left: 80, right: 460 }, + padding: cameraPadding(true), duration: motionDuration(700), maxZoom: 17.5, }, From d983c266441b824dfe726ec9d259866e08e431dc Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Wed, 30 Sep 2026 15:19:55 -0400 Subject: [PATCH 07/10] fix(web): keep long titles from filling the detail header Club events now show the club name in the header and the full subject in the scrollable content. Other panel titles clamp to three lines, or two on short screens, so content and actions stay visible. --- apps/web/src/components/DetailPanel.tsx | 2 +- apps/web/src/components/EatingClubDetail.tsx | 5 +++-- apps/web/src/index.css | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/DetailPanel.tsx b/apps/web/src/components/DetailPanel.tsx index e90e41c..7229a51 100644 --- a/apps/web/src/components/DetailPanel.tsx +++ b/apps/web/src/components/DetailPanel.tsx @@ -183,7 +183,7 @@ export function DetailPanel({ )}
{category} -

{title}

+

{title}

)} - {feedError &&
Some live updates are unavailable
} + {(feedError || foodFeedError) && ( +
Some live updates are unavailable
+ )} )}
diff --git a/apps/web/src/components/CampusMap.tsx b/apps/web/src/components/CampusMap.tsx index f64a998..917d9c2 100644 --- a/apps/web/src/components/CampusMap.tsx +++ b/apps/web/src/components/CampusMap.tsx @@ -53,6 +53,7 @@ function PlaceMarker({ at, label, tooltip, + anchor = "center", className = "", color, selected, @@ -62,6 +63,7 @@ function PlaceMarker({ at: LatLng; label: string; tooltip: string; + anchor?: "center" | "bottom"; className?: string; color?: string; selected: boolean; @@ -69,7 +71,7 @@ function PlaceMarker({ children: ReactNode; }) { return ( - +