)
@@ -162,7 +194,11 @@ export default function Panel({ onManeuverClick }) {
return (
{/* Drag handle */}
{sheetState !== 'collapsed' && (
+ {header}
{content}
)}
diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx
new file mode 100644
index 0000000..f338bad
--- /dev/null
+++ b/src/components/PlaceDetail.jsx
@@ -0,0 +1,236 @@
+import { useEffect, useState } from 'react'
+import { X, Navigation, Plus, Bookmark, Share2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+import { useStore } from '../store'
+import { fetchElevation } from '../api'
+
+/** Meters to feet */
+const M_TO_FT = 3.28084
+
+/** Build display address from raw result data */
+function buildAddress(place) {
+ if (place.address) return place.address
+ const raw = place.raw || {}
+ const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
+ return parts.join(', ') || null
+}
+
+export default function PlaceDetail() {
+ const selectedPlace = useStore((s) => s.selectedPlace)
+ const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
+ const startDirections = useStore((s) => s.startDirections)
+ const addStop = useStore((s) => s.addStop)
+ const stops = useStore((s) => s.stops)
+ const geoPermission = useStore((s) => s.geoPermission)
+
+ const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
+ const [isMobile, setIsMobile] = useState(false)
+
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 768)
+ check()
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+
+ // Fetch elevation when place changes
+ const placeLat = selectedPlace?.lat
+ const placeLon = selectedPlace?.lon
+ useEffect(() => {
+ if (placeLat == null || placeLon == null) return
+ let cancelled = false
+ fetchElevation(placeLat, placeLon).then((h) => {
+ if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h })
+ })
+ return () => { cancelled = true }
+ }, [placeLat, placeLon])
+
+ // Derive elevation/loading from comparing result to current place
+ const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
+ const elevation = !elevLoading ? elevResult.value : null
+
+ if (!selectedPlace) return null
+
+ const address = buildAddress(selectedPlace)
+ const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
+ const raw = selectedPlace.raw || {}
+
+ // Check if place is already in stops
+ const existingStopIndex = stops.findIndex(
+ (s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
+ )
+
+ const handleDirections = () => {
+ startDirections(selectedPlace)
+ if (geoPermission !== 'granted' && stops.length === 0) {
+ toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
+ }
+ }
+
+ const handleAddStop = () => {
+ addStop({
+ lat: selectedPlace.lat,
+ lon: selectedPlace.lon,
+ name: selectedPlace.name,
+ source: selectedPlace.source,
+ matchCode: selectedPlace.matchCode,
+ })
+ clearSelectedPlace()
+ }
+
+ const handleSave = () => {
+ toast('Saved places coming soon')
+ }
+
+ const handleShare = () => {
+ const text = [
+ selectedPlace.name,
+ address,
+ `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`,
+ ].filter(Boolean).join('\n')
+ navigator.clipboard.writeText(text).then(
+ () => toast('Copied to clipboard'),
+ () => toast.error('Failed to copy')
+ )
+ }
+
+ const panelContent = (
+ <>
+ {/* Close button */}
+
+
+ {/* Place name */}
+
+
+ {selectedPlace.name}
+
+ {selectedPlace.type && (
+
+ {selectedPlace.type}
+
+ )}
+
+
+ {/* Address */}
+ {address && (
+
+ {address}
+
+ )}
+
+ {/* Coordinates + elevation */}
+
+ {selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}
+ ·
+
+ {elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
+
+
+
+ {/* Optional extras */}
+ {(raw.opening_hours || raw.website || raw.phone) && (
+
+ )}
+
+ {/* Action buttons */}
+
+
+
+ {existingStopIndex >= 0 ? (
+
+ Added as stop {String.fromCharCode(65 + existingStopIndex)}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ )
+
+ // Mobile: bottom overlay
+ if (isMobile) {
+ return (
+
+ {panelContent}
+
+ )
+ }
+
+ // Desktop: side panel
+ return (
+
+ {panelContent}
+
+ )
+}
diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx
index 6923d93..652b216 100644
--- a/src/components/SearchBar.jsx
+++ b/src/components/SearchBar.jsx
@@ -1,32 +1,59 @@
-import { useRef, useEffect, useCallback, useState } from 'react'
+import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
+import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-react'
+import toast from 'react-hot-toast'
import { useStore } from '../store'
import { searchGeocode } from '../api'
-export default function SearchBar() {
+/** Get category icon based on result type/source */
+function CategoryIcon({ result }) {
+ const type = result.type || ''
+ const source = result.source || ''
+ const size = 14
+
+ if (source === 'nickname') return
+ if (type === 'coordinates') return
+ if (type === 'locality' || type === 'city') return
+
+ // POI subcategories from osm_value if available
+ const osmVal = result.raw?.osm_value || ''
+ if (osmVal.includes('cafe') || osmVal.includes('coffee')) return
+ if (osmVal.includes('fuel') || osmVal.includes('gas')) return
+ if (osmVal.includes('shop') || osmVal.includes('supermarket')) return
+ if (osmVal.includes('hotel') || osmVal.includes('motel')) return
+
+ return
+}
+
+const SearchBar = forwardRef(function SearchBar(_, ref) {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
+ useImperativeHandle(ref, () => ({
+ focus: () => inputRef.current?.focus(),
+ }))
+
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
+ const pendingDestination = useStore((s) => s.pendingDestination)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
+ const setSelectedPlace = useStore((s) => s.setSelectedPlace)
+ const clearPendingDestination = useStore((s) => s.clearPendingDestination)
- // Focus on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
- // Abort previous
const prev = useStore.getState().abortController
if (prev) prev.abort()
@@ -61,19 +88,40 @@ export default function SearchBar() {
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
-
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
+ const handleClear = () => {
+ setQuery('')
+ setResults([])
+ setAutocompleteOpen(false)
+ inputRef.current?.focus()
+ }
+
const selectResult = (result) => {
- addStop({
- lat: result.lat,
- lon: result.lon,
- name: result.name,
- source: result.source,
- matchCode: result.match_code,
- })
+ const { pendingDestination: pending } = useStore.getState()
+
+ if (pending) {
+ // GPS-denied Directions flow: this result becomes the starting point
+ addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
+ addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
+ clearPendingDestination()
+ toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
+ } else {
+ // Normal flow: open PlaceDetail
+ setSelectedPlace({
+ lat: result.lat,
+ lon: result.lon,
+ name: result.name,
+ address: result.address || null,
+ type: result.type,
+ source: result.source,
+ matchCode: result.match_code,
+ raw: result.raw || {},
+ })
+ }
+
setQuery('')
setResults([])
setAutocompleteOpen(false)
@@ -83,9 +131,7 @@ export default function SearchBar() {
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
- if (e.key === 'Escape') {
- setAutocompleteOpen(false)
- }
+ if (e.key === 'Escape') setAutocompleteOpen(false)
return
}
@@ -116,35 +162,51 @@ export default function SearchBar() {
return (
-
-
-
results.length > 0 && setAutocompleteOpen(true)}
- placeholder={atCap ? 'Max 10 stops reached' : 'Search for a place...'}
- disabled={atCap}
- className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
- aria-label="Search places"
- aria-expanded={autocompleteOpen}
- aria-autocomplete="list"
- role="combobox"
- />
- {searchLoading && (
-
- )}
+
+
results.length > 0 && setAutocompleteOpen(true)}
+ placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
+ disabled={atCap}
+ className="navi-input w-full pr-8"
+ aria-label="Search places"
+ aria-expanded={autocompleteOpen}
+ aria-autocomplete="list"
+ role="combobox"
+ />
+ {/* Clear / Loading indicator */}
+
+ {searchLoading ? (
+
+ ) : query ? (
+
+ ) : null}
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
{results.map((r, i) => (
@@ -152,27 +214,34 @@ export default function SearchBar() {
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
- className={`px-3 py-2 cursor-pointer text-sm border-b border-gray-700 last:border-b-0 ${
- i === activeIndex
- ? 'bg-gray-700 text-white'
- : 'text-gray-200 hover:bg-gray-700'
- }`}
+ className="px-3 py-2 cursor-pointer text-sm"
+ style={{
+ background: i === activeIndex ? 'var(--accent-muted)' : 'transparent',
+ borderBottom: i < results.length - 1 ? '1px solid var(--border-subtle)' : 'none',
+ }}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
-
-
{r.name}
-
+
+
+
+
+
+ {r.name}
+
+
{r.match_code?.housenumber === 'matched' && (
-
- exact match
+
+ exact
)}
- {r.source}
-
- {r.type} · {r.confidence}
+
+ {r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
))}
@@ -180,4 +249,6 @@ export default function SearchBar() {
)}
)
-}
+})
+
+export default SearchBar
diff --git a/src/components/StopItem.jsx b/src/components/StopItem.jsx
index a433e94..f59c93d 100644
--- a/src/components/StopItem.jsx
+++ b/src/components/StopItem.jsx
@@ -1,8 +1,9 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
+import { X, GripVertical } from 'lucide-react'
import { useStore } from '../store'
-export default function StopItem({ stop, index, total }) {
+export default function StopItem({ stop, index, total, indexOffset = 0 }) {
const removeStop = useStore((s) => s.removeStop)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -14,62 +15,62 @@ export default function StopItem({ stop, index, total }) {
opacity: isDragging ? 0.5 : 1,
}
- // Pin color logic
- let pinColor = 'bg-blue-500' // intermediate
- let pinLabel = String(index + 1)
- if (index === 0) {
- pinColor = 'bg-green-500'
- pinLabel = 'A'
- } else if (index === total - 1 && total > 1) {
- pinColor = 'bg-red-500'
- pinLabel = String.fromCharCode(65 + Math.min(index, 25)) // A-Z
- } else {
- pinLabel = String.fromCharCode(65 + Math.min(index, 25))
- }
+ const displayIndex = index + indexOffset
+ const effectiveTotal = total + indexOffset
+
+ // Pin color from tokens
+ let pinVar = '--pin-intermediate'
+ if (displayIndex === 0) pinVar = '--pin-origin'
+ else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinVar = '--pin-destination'
+
+ const pinLabel = String.fromCharCode(65 + Math.min(displayIndex, 25))
return (
{/* Drag handle */}
{/* Pin indicator */}
{pinLabel}
{/* Stop name */}
- {stop.name}
+
+ {stop.name}
+
{/* Remove button */}
)
diff --git a/src/components/StopList.jsx b/src/components/StopList.jsx
index a6b0c6e..a370b09 100644
--- a/src/components/StopList.jsx
+++ b/src/components/StopList.jsx
@@ -14,11 +14,17 @@ import {
} from '@dnd-kit/sortable'
import { useStore } from '../store'
import StopItem from './StopItem'
+import GpsOriginItem from './GpsOriginItem'
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
+ const gpsOrigin = useStore((s) => s.gpsOrigin)
+ const pendingDestination = useStore((s) => s.pendingDestination)
+
+ const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
+ const indexOffset = hasGpsOrigin ? 1 : 0
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -34,25 +40,34 @@ export default function StopList() {
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
- if (stops.length === 0) {
+ if (stops.length === 0 && !hasGpsOrigin) {
return (
-
- {geoPermission === 'denied'
- ? 'Add a starting point and destination above'
- : 'Search and add stops to build your route'}
+
+ {pendingDestination
+ ? 'Search for a starting point above'
+ : geoPermission === 'denied'
+ ? 'Add a starting point and destination above'
+ : 'Search and add stops to build your route'}
)
}
return (
-
- s.id)} strategy={verticalListSortingStrategy}>
-
+
+ {hasGpsOrigin && }
+
+ s.id)} strategy={verticalListSortingStrategy}>
{stops.map((stop, i) => (
-
+
))}
-
-
-
+
+
+
)
}
diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js
new file mode 100644
index 0000000..f09d835
--- /dev/null
+++ b/src/hooks/useTheme.js
@@ -0,0 +1,45 @@
+import { useEffect } from 'react'
+import { useStore } from '../store'
+
+/**
+ * Initializes and manages the theme system.
+ * Call once in App — it handles:
+ * - Reading localStorage override on mount
+ * - Listening to system prefers-color-scheme
+ * - Applying data-theme to
+ * - Updating store.theme (resolved value)
+ */
+export function useTheme() {
+ const setTheme = useStore((s) => s.setTheme)
+ const themeOverride = useStore((s) => s.themeOverride)
+
+ // Initialize override from localStorage on first mount
+ useEffect(() => {
+ const stored = localStorage.getItem('navi-theme-override')
+ if (stored === 'dark' || stored === 'light') {
+ useStore.getState().setThemeOverride(stored)
+ }
+ }, [])
+
+ // Resolve and apply theme
+ useEffect(() => {
+ function resolve() {
+ if (themeOverride) return themeOverride
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ }
+
+ function apply() {
+ const resolved = resolve()
+ document.documentElement.setAttribute('data-theme', resolved)
+ setTheme(resolved)
+ }
+
+ apply()
+
+ // Listen for system changes (only matters when no override)
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
+ const handler = () => { if (!themeOverride) apply() }
+ mq.addEventListener('change', handler)
+ return () => mq.removeEventListener('change', handler)
+ }, [themeOverride, setTheme])
+}
diff --git a/src/index.css b/src/index.css
index e8999cf..671ac29 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,38 +1,158 @@
@import "tailwindcss";
+/* ═══════════════════════════════════════════════════════
+ NAVI DESIGN TOKENS
+ Warm grays, sage greens, khaki tans, deep blacks.
+ No blue in UI chrome.
+ ═══════════════════════════════════════════════════════ */
+
+:root {
+ /* ── Typography ── */
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+
+ /* ── Type scale ── */
+ --text-xs: 0.6875rem; /* 11px */
+ --text-sm: 0.8125rem; /* 13px */
+ --text-base: 0.875rem; /* 14px */
+ --text-md: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+}
+
+/* ═══ DARK MODE (default) ═══ */
+[data-theme="dark"] {
+ --bg-base: #1c1917; /* warm off-black (was #0f1210) */
+ --bg-raised: #252220; /* raised surface (was #181d1a) */
+ --bg-overlay: #2e2a27; /* overlay/dropdown (was #1e2522) */
+ --bg-input: #201d1a; /* input fields (was #141a16) */
+
+ --text-primary: #dde3dc;
+ --text-secondary: #8f9a8e;
+ --text-tertiary: #5e6b5d;
+ --text-inverse: #1c1917;
+
+ --border: #3a3530; /* warm brown-gray (was #2a3329) */
+ --border-subtle: #2a2624; /* (was #1f261e) */
+
+ --accent: #7a9a6b; /* sage green — interactive states */
+ --accent-hover: #8fad7f;
+ --accent-muted: #3d4d36;
+
+ --tan: #b8a88a; /* khaki — secondary highlights */
+ --tan-muted: #4a4235;
+
+ --pin-origin: #6b8f5e; /* sage */
+ --pin-destination: #a67c52; /* rust/tan */
+ --pin-intermediate: #6b7268; /* warm gray */
+ --pin-stroke: #1c1917;
+
+ --status-success: #6b8f5e;
+ --status-warning: #b89a4a;
+ --status-danger: #a65c52;
+
+ --route-line: #7a9a6b;
+
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
+}
+
+/* ═══ LIGHT MODE ═══ */
+[data-theme="light"] {
+ --bg-base: #ece8e1; /* warm tan-gray (was #f5f2ed) */
+ --bg-raised: #f5f2ec; /* raised surface (was #ffffff) */
+ --bg-overlay: #f0ece5; /* overlay/dropdown (was #faf8f5) */
+ --bg-input: #f5f2ec; /* input fields (was #ffffff) */
+
+ --text-primary: #1a1d1a;
+ --text-secondary: #5c6558;
+ --text-tertiary: #8a9486;
+ --text-inverse: #f5f2ed;
+
+ --border: #d4cfc5;
+ --border-subtle: #e8e3db;
+
+ --accent: #4a7040;
+ --accent-hover: #3d5e35;
+ --accent-muted: #dce8d6;
+
+ --tan: #8a7556;
+ --tan-muted: #f0e8d8;
+
+ --pin-origin: #4a7040;
+ --pin-destination: #8a5c35;
+ --pin-intermediate: #6b6960;
+ --pin-stroke: #1a1d1a;
+
+ --status-success: #4a7040;
+ --status-warning: #8a7040;
+ --status-danger: #8a4040;
+
+ --route-line: #4a7040;
+
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
+}
+
+/* ═══ BASE STYLES ═══ */
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
+ font-family: var(--font-sans);
}
-/* MapLibre popup styling to match dark theme */
+body {
+ background: var(--bg-base);
+ color: var(--text-primary);
+}
+
+/* Mono class utility */
+.font-mono {
+ font-family: var(--font-mono);
+}
+
+/* ═══ FOCUS RING — accent, never blue ═══ */
+*:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* ═══ TRANSITIONS — respect reduced motion ═══ */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* ═══ MAPLIBRE POPUP ═══ */
.maplibregl-popup-content {
- background: #1f2937 !important;
- border: 1px solid #374151 !important;
+ background: var(--bg-raised) !important;
+ border: 1px solid var(--border) !important;
border-radius: 8px !important;
padding: 8px 12px !important;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
+ box-shadow: var(--shadow-lg) !important;
+ color: var(--text-primary) !important;
}
.maplibregl-popup-tip {
- border-top-color: #1f2937 !important;
- border-bottom-color: #1f2937 !important;
+ border-top-color: var(--bg-raised) !important;
+ border-bottom-color: var(--bg-raised) !important;
}
.maplibregl-popup-close-button {
- color: #9ca3af !important;
+ color: var(--text-secondary) !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.maplibregl-popup-close-button:hover {
- color: #fff !important;
+ color: var(--text-primary) !important;
background: transparent !important;
}
-/* Custom scrollbar for panels */
+/* ═══ SCROLLBAR ═══ */
::-webkit-scrollbar {
width: 6px;
}
@@ -42,10 +162,123 @@ html, body, #root {
}
::-webkit-scrollbar-thumb {
- background: #4b5563;
+ background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
- background: #6b7280;
+ background: var(--text-tertiary);
+}
+
+/* ═══ GPS CHEVRON MARKER ═══ */
+.navi-chevron {
+ width: 16px;
+ height: 16px;
+ transition: transform 0.3s ease;
+}
+
+.navi-gps-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 2px solid var(--bg-raised);
+ box-shadow: 0 0 0 2px var(--accent);
+}
+
+/* ═══ STOP PIN MARKERS (map) ═══ */
+.navi-pin {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-sans);
+ font-size: 11px;
+ font-weight: 600;
+ color: #fff;
+ border: 2px solid var(--pin-stroke);
+ cursor: pointer;
+ box-shadow: var(--shadow);
+}
+
+.navi-pin--origin { background: var(--pin-origin); }
+.navi-pin--destination { background: var(--pin-destination); }
+.navi-pin--intermediate { background: var(--pin-intermediate); }
+
+/* ═══ FORM ELEMENTS ═══ */
+.navi-input {
+ padding: 0.5rem 0.75rem;
+ font-size: var(--text-sm);
+ font-family: var(--font-sans);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ color: var(--text-primary);
+ transition: border-color 0.1s;
+}
+
+.navi-input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.navi-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-muted);
+}
+
+.navi-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.navi-btn-secondary {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--text-xs);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ background: var(--tan-muted);
+ color: var(--tan);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.navi-btn-secondary:hover:not(:disabled) {
+ background: var(--accent-muted);
+ color: var(--accent);
+}
+
+.navi-btn-secondary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ═══ PREVIEW PIN (selected but not committed) ═══ */
+.navi-pin-preview {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 3px solid var(--accent);
+ background: transparent;
+ box-shadow: 0 0 0 3px var(--accent-muted), var(--shadow);
+ pointer-events: none;
+}
+
+/* ═══ PLACE DETAIL PANEL ═══ */
+.navi-place-detail {
+ transition: transform 150ms ease, opacity 150ms ease;
+}
+
+.navi-place-detail-enter {
+ transform: translateX(-10px);
+ opacity: 0;
+}
+
+.navi-place-detail-active {
+ transform: translateX(0);
+ opacity: 1;
}
diff --git a/src/main.jsx b/src/main.jsx
index b9a1a6d..ef272ce 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,10 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { Toaster } from 'react-hot-toast'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
+
,
)
diff --git a/src/store.js b/src/store.js
index 4769a6b..3ea3733 100644
--- a/src/store.js
+++ b/src/store.js
@@ -54,12 +54,51 @@ export const useStore = create((set, get) => ({
setRouteError: (err) => set({ routeError: err, route: null }),
clearRoute: () => set({ route: null, routeError: null }),
+ // ── Place detail ──
+ selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
+ gpsOrigin: true, // whether GPS should be used as origin when available
+ pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
+
+ setSelectedPlace: (place) => set({ selectedPlace: place }),
+ clearSelectedPlace: () => set({ selectedPlace: null }),
+ setGpsOrigin: (val) => set({ gpsOrigin: val }),
+ setPendingDestination: (place) => set({ pendingDestination: place }),
+ clearPendingDestination: () => set({ pendingDestination: null }),
+
+ startDirections: (place) => {
+ const { geoPermission, stops, addStop, clearStops } = get()
+ if (geoPermission === 'granted') {
+ clearStops()
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ gpsOrigin: true, selectedPlace: null })
+ } else if (stops.length > 0) {
+ const origin = stops[0]
+ clearStops()
+ addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
+ addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
+ set({ selectedPlace: null })
+ } else {
+ set({ pendingDestination: place, selectedPlace: null })
+ }
+ },
+
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
+ theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
+ themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
+ setTheme: (theme) => set({ theme }),
+ setThemeOverride: (override) => {
+ set({ themeOverride: override })
+ if (override) {
+ localStorage.setItem('navi-theme-override', override)
+ } else {
+ localStorage.removeItem('navi-theme-override')
+ }
+ },
}))