# Navi Directions UX Redesign **Status:** Draft **Author:** Claude + Matt **Date:** 2026-04-26 **Implementation:** Deferred to dedicated session --- ## 1. Current State ### Components | Component | File | Role | |-----------|------|------| | SearchBar | `SearchBar.jsx` | Overloaded: search, add stop, set origin (hidden modes) | | StopList | `StopList.jsx` | Drag-drop reordering of stops | | GpsOriginItem | `GpsOriginItem.jsx` | "Your location" row when GPS granted | | StopItem | `StopItem.jsx` | Individual stop with delete button | | ModeSelector | `ModeSelector.jsx` | auto/pedestrian/bicycle toggle | | ManeuverList | `ManeuverList.jsx` | Turn-by-turn directions display | | PlaceDetail | `PlaceDetail.jsx` | "Directions" button for selected place | ### State Model ```javascript stops: [] // Array of {id, lat, lon, name, source, matchCode} gpsOrigin: true // Use GPS as origin when available pendingDestination: null // Place waiting for origin (GPS-denied flow) route: null // Valhalla trip response routeLoading: false routeError: null ``` ### Failure Modes 1. **No visible from/to inputs** — Users cannot see or directly edit origin/destination 2. **SearchBar hidden mode-switching** — Three different behaviors based on invisible state: - Normal: opens place detail - With `pendingDestination`: first result becomes origin - After adding stops: unclear which role next selection plays 3. **GPS-denied flow uses ephemeral toast** — "Set a starting point" disappears, no persistent UI guidance 4. **No swap button** — Cannot reverse route direction 5. **No map context menu** — Right-click/long-press does nothing 6. **No waypoint addition UI** — Only drag-drop reordering, no insert-between 7. **Place panel "Directions" silently sets up route** — Based on hidden state, no confirmation --- ## 2. Design Principles 1. **Direct manipulation over hidden modes** — Every action should have visible UI 2. **Two visible inputs always** — When in directions mode, From and To fields are always visible 3. **Spatial interactions over linear** — Radial menu for map interactions, not dropdowns 4. **Same gesture model everywhere** — Right-click (desktop) = long-press (mobile) 5. **Preserve existing state model** — `stops[]` array stays, just better UI on top --- ## 3. Visual Mockup — Directions Panel ``` ┌─────────────────────────────────────┐ │ DIRECTIONS │ ├─────────────────────────────────────┤ │ │ │ From: [📍 Your location ][×] │ │ ──────────────────────── │ │ [⇅] │ ← Swap button │ ──────────────────────── │ │ To: [Coffee shop on Main St][×] │ │ │ │ [+ Add stop] │ │ │ ├─────────────────────────────────────┤ │ [🚗 Auto] [🚶 Walk] [🚲 Bike] │ ├─────────────────────────────────────┤ │ ┌─────────────────────────────┐ │ │ │ 12 min · 4.2 mi │ │ │ │ via W Main St │ │ │ └─────────────────────────────┘ │ │ │ │ ▼ Turn-by-turn (expandable) │ │ → Head north on Oak Ave │ │ ↱ Turn right onto Main St │ │ ◉ Arrive at destination │ │ │ └─────────────────────────────────────┘ ``` ### Input States **From field:** - GPS granted: Shows "📍 Your location" pill with clear button - GPS denied/cleared: Empty, placeholder "Starting point..." - Filled: Shows place name with clear button **To field:** - Empty: Placeholder "Destination..." - Filled: Shows place name with clear button **Active input:** - Blue border highlight - Search dropdown appears on typing - Map click populates this field --- ## 4. Visual Mockup — Radial Map Menu ``` Drop pin 🔴 ╱ ╲ ╱ ╲ Directions ╱ ╲ Directions from here 🟢──────────────🔵 to here │ 43.6166 │ │ -116.2008 │ │ [loading…] │ ← Center disc with coords/label Add as 🟡──────────────🟣 Save place stop ╲ ╱ ╲ ╱ ╲ ╱ 🟠 What's here ``` ### Wedge Layout (60° each) | Position | Action | Icon | Color | |----------|--------|------|-------| | Top | Drop pin | Pin | Red | | Top-right | Directions to here | Arrow-in | Blue | | Bottom-right | Save place | Star + 🔒 | Purple | | Bottom | What's here | Info | Orange | | Bottom-left | Add as stop | Plus | Yellow | | Top-left | Directions from here | Arrow-out | Green | ### Behavior - **Trigger:** Right-click (desktop) or long-press 450ms (mobile) - **Center disc:** ~40px diameter, shows coordinates immediately, reverse-geocoded label async - **Wedge highlight:** On hover (desktop) or drag-over (mobile) - **Commit:** Release on wedge (mobile) or click wedge (desktop) - **Cancel:** Release outside, Escape key, tap elsewhere --- ## 5. Component Breakdown ### DirectionsPanel Replaces current Panel directions mode. ``` Props: none (reads from store) State: none (all in global store) Children: - LocationInput (from) - SwapButton - LocationInput (to) - WaypointList (if stops.length > 2) - AddStopButton - ModeSelector - RouteSummary - ManeuverList (collapsible) ``` ### LocationInput Reusable component for from, to, and waypoint inputs. ``` Props: - slot: 'from' | 'to' | `waypoint:${index}` - value: { lat, lon, name, source } | null - placeholder: string - showGpsPill: boolean - onClear: () => void Features: - Search-as-you-type (Photon geocoder) - GPS pill state with clear button - Active-input visual state (blue border) - Reverse-geocoded labels for coord-only entries - Dropdown for search results ``` ### SwapButton Simple button between From and To inputs. ``` Props: none Action: Swaps stops[0] and stops[stops.length - 1] Visual: ⇅ icon, hover highlight ``` ### WaypointList Refactored from existing StopList, preserves drag-drop. ``` Props: none (reads stops from store) Features: - Only renders stops[1..n-1] (middle waypoints) - Drag-drop reordering via @dnd-kit - Delete button per waypoint - "Via" label prefix ``` ### RadialMenu New general-purpose component. ``` Props: - open: boolean - x: number (screen X) - y: number (screen Y) - lat: number - lon: number - wedges: Array<{ id, icon, label, action: (lat, lon) => void }> - onClose: () => void Features: - Configurable wedge count and actions - Async center label (reverse geocode) - Keyboard dismissal (Escape) - Touch-friendly sizing on mobile - Fade in/out animations ``` --- ## 6. State Model ### Existing (unchanged) ```javascript stops: [] // Origin = stops[0], destination = stops[last], waypoints in between gpsOrigin: boolean // Whether GPS should be used as origin route: object | null // Valhalla trip response routeLoading: boolean routeError: string | null ``` ### New ```javascript activeInputSlot: 'from' | 'to' | `waypoint:${N}` | null // Which input is currently focused/active for map-click-to-fill radialMenuState: { open: boolean, x: number, // Screen coordinates y: number, lat: number, // Map coordinates lon: number, label: string | null // Reverse-geocoded, async populated } // Pin persistence (three-tier) transientPins: [] // In-memory, lost on refresh // localStorage: 'navi_saved_pins' key for guest saves // Backend sync: future work for authed users ``` ### Removed ```javascript pendingDestination: null // No longer needed — explicit inputs replace hidden state ``` --- ## 7. Interaction Flows ### Open directions tab fresh 1. From field shows GPS pill if `geoPermission === 'granted'`, else empty 2. To field is empty, focused by default 3. No route calculated yet ### Click "Directions" from place panel 1. Directions panel opens (if not already) 2. To field auto-fills with selected place 3. From field: - If GPS granted: shows GPS pill - Else: empty, receives focus 4. Route calculates if both filled ### Type in input 1. Input receives focus, becomes `activeInputSlot` 2. Photon search fires on debounce (300ms) 3. Dropdown shows results 4. Select result → populates input, clears dropdown 5. Route recalculates ### Right-click / long-press on map 1. Radial menu appears centered on click point 2. Center disc shows coordinates immediately 3. Reverse geocode fires async, populates label 4. User hovers/drags to wedge: | Wedge | Action | |-------|--------| | **Directions from here** | Opens directions if closed, fills From with coords, focuses To | | **Directions to here** | Opens directions if closed, fills To with coords, focuses From if empty | | **Add as stop** | Inserts new stop before destination | | **What's here** | Reverse geocode → opens place panel | | **Drop pin** | Creates transient marker (session-only by default) | | **Save place** | Guest: opens login flow; Authed: opens save dialog | 5. Release outside or Escape → dismisses without action ### Map click with active input When directions panel is open and an input is focused (`activeInputSlot !== null`): 1. Single click on map 2. Clicked coordinates populate the active input 3. Reverse geocode fires to get display name 4. Input loses focus, `activeInputSlot = null` 5. Route recalculates ### Map long-press / right-click with active input Even when an input is active: 1. Long-press / right-click opens radial menu (overrides click-to-fill) 2. User can select "Directions from here" or "Directions to here" 3. Explicitly overrides the active input — intentional action takes priority ### Map click with no active input 1. No action (map interaction only) 2. Future v2 consideration: reverse-geocode and show place panel ### Swap button 1. Click swap button 2. `stops[0]` and `stops[stops.length - 1]` swap positions 3. If GPS was origin, GPS pill moves to destination (unusual but allowed) 4. Route recalculates --- ## 8. Place Panel "Directions" Handoff **Current behavior:** Calls `startDirections(place)` with complex conditional logic, may show toast. **New behavior:** ```javascript handleDirections = () => { // Always open directions panel setActiveTab('directions') // Fill destination setStop(stops.length, { // Appends or replaces last lat: place.lat, lon: place.lon, name: place.name, source: place.source }) // Handle origin if (geoPermission === 'granted') { setGpsOrigin(true) // GPS pill in From } else if (stops.length === 0) { setActiveInputSlot('from') // Focus From input } // Close place panel clearSelectedPlace() } ``` **No toast needed** — UI is self-explanatory with visible From/To fields. --- ## 9. Radial Menu Specifics ### Trigger | Platform | Gesture | Duration | |----------|---------|----------| | Desktop | Right-click | Instant | | Mobile | Long-press | 450ms | ### Conflict Avoidance Long-press must NOT fire during active pan: - Track touch start position - If touch moves >5-10px before timer fires, cancel long-press - Pan gesture takes priority - Matches iOS Safari default contextmenu synthesis behavior ### Geometry ``` Outer radius: ~80px from center (desktop), ~100px (mobile) Inner radius: ~40px (center disc, desktop), ~50px (mobile) Wedge angle: 60° each (6 wedges) Gap between wedges: 2px Minimum touch target: 48px per wedge ``` ### Visual States | Element | Default | Hover/Active | Selected | |---------|---------|--------------|----------| | Wedge background | `rgba(0,0,0,0.7)` | `rgba(0,0,0,0.85)` | Wedge accent color | | Wedge icon | White, 50% opacity | White, 100% opacity | White | | Wedge label | Hidden | Shown (tooltip) | Shown | | Center disc | Dark, coords visible | — | — | | Save place (guest) | Lock icon overlay | — | — | ### Animation - **Fade in:** <100ms ease-out - **Fade out:** <150ms ease-in - **Wedge hover:** Instant background change - **Center label:** Fade in when reverse geocode completes --- ## 10. Mobile Considerations ### Panel Layout **Decision: Bottom sheet on mobile (<768px), side panel on desktop.** Bottom sheet states: - **Peek:** Route summary only (collapsed) - **Half:** Inputs + summary visible - **Full:** Inputs + turn-by-turn maneuvers Drag handle at top for resize between states. **Implementation notes:** - Use React state for sheet position: `sheetState: 'peek' | 'half' | 'full'` - CSS transforms for smooth transitions: `transform: translateY()` - Touch gesture detection for drag-to-resize ### Long-press Timing **Decision: 450ms with 5-10px movement threshold.** If finger moves more than threshold during press window, abort long-press and treat as pan. This matches iOS Safari's default contextmenu synthesis behavior. **Implementation notes:** - Create reusable `useLongPress` hook - Track `touchstart` position - `setTimeout` for 450ms - Clear timeout on `touchmove` if delta > threshold - Fire callback on timeout completion ### Radial Sizing Mobile radial larger for finger touch: - Outer radius: ~100px (vs 80px desktop) - Center disc: ~50px (vs 40px desktop) - Minimum wedge touch target: 48px ### Compact Directions Mode When route is calculated and user is navigating: 1. Collapse From/To inputs to single-line summary 2. Show prominent next maneuver 3. Expand on tap to edit inputs 4. Maneuver list scrollable ### Keyboard Awareness - Detect keyboard open via `visualViewport` API - Shift panel content up to keep active input visible - Don't let keyboard overlap input being typed in --- ## 11. Place Panel Restructure **Out of scope for this document.** Separate session will address: - Cleaner info card layout (Google Maps style) - Better visual hierarchy - Action button placement - No new data sources, just CSS/JSX polish --- ## 12. Out of Scope (Future Phases) | Feature | Notes | |---------|-------| | Saved routes | Auth required, dedicated work | | Route alternatives | Valhalla supports, surface in v2 | | Avoid tolls/highways | Valhalla supports via costing options | | Real-time rerouting | Requires location tracking loop | | Multi-modal | Drive + transit + walk hybrids | | Traffic-aware routing | Requires traffic data source | | Offline routing | Requires local Valhalla instance | | Search history backend persistence | Frontend localStorage first | | Saved places browsing UI | Future dedicated work | | Contacts view redesign | Separate from route-building | --- ## 13. Implementation Sequence | Phase | Task | Depends On | |-------|------|------------| | **a** | Build RadialMenu component (general-purpose, no actions wired) | — | | **b** | Wire "What's here" action to validate trigger + reverse-geocode flow | a | | **c** | Refactor SearchBar to single-mode (search-only, remove pending* logic) | — | | **d** | Build LocationInput component (reusable) | c | | **e** | Build DirectionsPanel layout with two LocationInputs | d | | **f** | Wire remaining radial actions to directions flow | b, e | | **g** | Wire place panel "Directions" handoff to new flow | e | | **h** | Add SwapButton | e | | **i** | Add map-click-to-fill-active-input | e | | **j** | Mobile polish (long-press timing, bottom sheet, keyboard) | a-i | ### Phases k-o: Single-Panel Architecture | Phase | Task | Depends On | |-------|------|------------| | **k** | Refactor Panel.jsx to single-column state-driven content, remove right PlaceDetail panel | — | | **l** | Build shared place card component (preview + stop cards) | k | | **m** | Wire panel state transitions (IDLE → PREVIEW → ROUTING → ROUTE_CALCULATED) | l | | **n** | Mobile bottom sheet state mapping | m | | **o** | Search history (frontend localStorage scope first) | m | **Estimated phases:** 15 discrete tasks, can be done incrementally. --- ## 14. Resolved Decisions ### 1. Mobile Layout **Decision:** Bottom sheet on mobile (<768px breakpoint), side panel on desktop. Three sheet states: - **Peek:** Summary only (route time/distance) - **Half:** Inputs + summary visible - **Full:** Inputs + turn-by-turn maneuvers Drag-to-resize between states via handle at top of sheet. **Implementation notes:** - `sheetState` in store: `'peek' | 'half' | 'full'` - CSS transforms (`translateY`) for smooth animation - Touch gesture handler for drag detection - Snap to nearest state on release ### 2. Long-press Timing **Decision:** 450ms with 5-10px movement threshold. If finger moves more than threshold during the press window, abort long-press and treat as pan gesture. This matches iOS Safari's default contextmenu synthesis behavior. **Implementation notes:** - Create `useLongPress(callback, delay = 450)` hook - Track `touchstart` coordinates - On `touchmove`, check if `Math.hypot(dx, dy) > threshold` - If exceeded, clear timeout (abort) - If timeout fires without abort, invoke callback with coords ### 3. Save Place for Guests **Decision:** Visible wedge with subtle lock icon overlay to indicate auth required. Tapping as guest opens login flow with return-to-action after auth completes. **Implementation notes:** - "Save place" wedge always visible - Lock icon (🔒) overlaid at 50% opacity on wedge icon - On tap: check auth state - Authed: open save dialog - Guest: trigger login modal with `returnAction: 'save-place'` - After successful auth, resume save flow ### 4. Radial Ring Structure **Decision:** Single ring only. Six wedges maximum. Future expansion via contextual wedge sets (different actions based on what was clicked — e.g., clicking on a route segment could show "Avoid this road" instead of "Drop pin") rather than deeper nested rings. **Implementation notes:** - `wedges` prop on RadialMenu is array of 6 max - Context-aware wedge selection handled by parent component - No inner ring in v1; revisit if six actions prove insufficient ### 5. Drop Pin Persistence **Decision:** Three-tier model. | Tier | Scope | Storage | Behavior | |------|-------|---------|----------| | Transient | Session | In-memory (`transientPins[]`) | Default. Lost on refresh. | | Guest save | Device | `localStorage` (`navi_saved_pins`) | Survives refresh, lost on cache clear or other device. | | Authed sync | Account | Backend API | Syncs across devices. Future work. | **Implementation notes:** - Transient pins: `transientPins: []` in store - Guest save: on "Save" tap, copy pin to localStorage - localStorage schema: `[{ id, lat, lon, name, createdAt }]` - Backend sync: TODO placeholder; initial implementation is localStorage only - Authed users see "Sync to account" option (greyed out with "Coming soon" for v1) ### 6. Radial vs Click During Active Input **Decision:** Different gestures, different behaviors. | State | Gesture | Behavior | |-------|---------|----------| | Active input | Map click | Fills active input with click coords (reverse-geocoded) | | Active input | Map long-press / right-click | Opens radial (explicit override allowed) | | No active input | Map click | No action (v2: reverse-geocode + place panel) | | No active input | Map long-press / right-click | Opens radial (primary use case) | **Implementation notes:** - Click handler checks `activeInputSlot` - If set: fill input, reverse-geocode for label - If null: no-op (or v2 place panel) - Long-press / contextmenu handler always opens radial - Radial "Directions from/to here" explicitly sets input, overriding prior value --- ## 15. Panel Architecture (Single-Column Model) This section defines the broader panel design that consolidates today's two-panel layout (Routes/Contacts + floating PlaceDetail) into one always-visible left column with state-driven content. ### Overview The left panel is one always-visible column with state-driven content. The right-side PlaceDetail panel is **removed**; its content moves into the left panel. **Width:** ~360px desktop. Mobile: panel becomes bottom sheet per Section 10. **Visual style:** Panel is always anchored at the left edge of the viewport with consistent panel background. When idle (no preview, no route), only the search bar is visible at the top — the rest of the panel is empty space. The panel never disappears; it just shows less or more content depending on state. ### Panel States Panel states are mutually exclusive content modes: #### IDLE (no preview, no route) - Search bar at top - Empty body (or subtle empty-state prompt: "Search or click a place to begin") - Recent activity / search history (when implemented) #### PREVIEW (place selected via map click or search, not committed to route) - Search bar at top - Preview card: full place detail (name, type, coords, elevation, land class, about, contact, links) - Action buttons inside or below the card: [Directions] [Add stop] [Save] [Share] [x] - Click another place: preview replaced (previous lost — search history retains it) - Click x or Escape: preview dismissed, return to IDLE #### ROUTING (1+ stops, no preview) - Search bar at top - Section: "Route" with stop cards - Each stop card: collapsed by default (header: pin + name + drag handle + remove); expandable to show full detail - Drag-to-reorder via card headers (preserves existing StopList) - Per-card actions: Save, Share, Remove - Bottom: [Get Directions] (when 2+ stops with valid mode), mode selector (auto/walk/bike), trip summary placeholder #### PREVIEW + ROUTING (place clicked while route exists) - Search bar at top - Preview card directly below search - Route section below preview - Both visible simultaneously - Multiple cards can be expanded at once (preview + a stop) #### ROUTE_CALCULATED (after route fetch) - Search bar at top - Route summary (total time, distance) - Mode selector - Stop cards (collapsed; expand for detail) - Turn-by-turn maneuvers - Preview can appear on top if user clicks a place ### Search Bar Behavior - Pinned to top of panel - Always for browsing (search -> preview, never auto-add as stop) #### Search History (aspirational — design now, implement later) - Empty search -> dropdown shows 5 most recent searches - Authed: persisted to backend - Guest: localStorage - Typing: recent matches sorted to top of search results ### Card Pattern Shared between preview and stop cards. Both use the same component, parameterized by role. #### Header (always visible) - Pin/marker icon (color varies by role — preview vs stop number) - Place name - Expand/collapse chevron - For stops: drag handle, remove (x) #### Body (collapsed by default for stops, expanded by default for preview) - Type / category - Coordinates + elevation + land class - About (description, Wikipedia excerpt) - Contact (phone, website, hours) - Links (Wikipedia, OSM, Wikidata) - Action buttons: - Preview: [Directions] [Add stop] [Save] [Share] - Stop: [Save] [Share] ### State Transitions ``` IDLE -> PREVIEW : click place / search-select PREVIEW -> IDLE : x button / Escape PREVIEW -> PREVIEW (different) : click another place PREVIEW -> ROUTING : "Add stop" on preview PREVIEW -> ROUTE_CALCULATED : "Directions" (becomes destination, route auto-calculates if From available) ROUTING -> IDLE : remove all stops ROUTING -> PREVIEW + ROUTING : click place ROUTING -> ROUTE_CALCULATED : "Get Directions" / auto-route trigger PREVIEW + ROUTING -> ROUTING : dismiss preview PREVIEW + ROUTING -> ROUTING : "Add stop" on preview (adds new stop) ROUTE_CALCULATED -> ROUTING : edit route ROUTE_CALCULATED -> IDLE : clear route ``` ### Routes / Contacts Tabs Tentative: Routes is default panel. Contacts is a separate view accessible via a tab or icon at the top. Contacts view replaces route/preview content with a contact list — does not combine with route-building. ### Map Zoom-to-Feature With single panel, padding becomes simpler — just one panel width (~360px) on the left. Top/right/bottom can be small (~50px each). ### Mobile Bottom Sheet State Mapping Same logic as desktop, layout rotated: | State | Sheet Position | |---------------------|----------------| | IDLE | peek (search bar visible) | | PREVIEW | half (preview card) | | ROUTING | half (stops list) | | PREVIEW + ROUTING | full (both, scrollable) | | ROUTE_CALCULATED | half/full (summary + maneuvers) | --- ## 16. Open Questions ### From Single-Panel Architecture - Routes/Contacts tab location and behavior in single-panel model - Preview-of-already-routed-place: probably auto-expands the existing stop card, no duplicate preview - Preview card position confirmed above route section (per mock) --- ## Appendix A: Current Code References | File | Lines | Relevance | |------|-------|-----------| | `store.js` | 72-86 | `startDirections()` logic to replace | | `store.js` | 16-34 | `stops[]` management to preserve | | `SearchBar.jsx` | 140-170 | `pendingDestination` logic to remove | | `PlaceDetail.jsx` | 574-579 | `handleDirections()` to rewrite | | `App.jsx` | 31-66 | Route fetch effect to preserve | | `api.js` | 29-56 | `requestRoute()` unchanged | --- ## Appendix B: Radial Menu SVG Structure ```svg 43.6166 -116.2008 Loading... ``` --- *Document created 2026-04-26. Updated with resolved decisions and single-panel architecture (Phases k-o). Implementation to follow in dedicated session.*