coreprinciple commited on
Commit
a82c73b
·
1 Parent(s): 10cf611

update readme

Browse files
Files changed (2) hide show
  1. README.md +102 -52
  2. src/discoverroute/ui/shell.py +324 -116
README.md CHANGED
@@ -1,78 +1,128 @@
1
  ---
2
- title: DiscoverRoute
3
  emoji: 🗺️
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
  app_file: app.py
9
- pinned: false
10
- license: mit
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- # DiscoverRoute
14
 
15
- You give a start, a destination, a free-text vibe, and an "adventurousness" level.
16
- DiscoverRoute returns a walkable/bikeable route that **deliberately detours past
17
- places matching your taste** — within a travel-time budget — plus a narrated
18
- itinerary explaining why each place is on the path.
19
 
20
- It runs on a small (≤32B) model and open OpenStreetMap data. Single city: **Paris**.
 
 
 
 
 
21
 
22
- The core inversion: ordinary navigation minimizes time. DiscoverRoute treats extra
23
- time as a *budget to spend on discovery*.
24
 
25
- ## Status
26
 
27
- **Complete first version** all P0 must-haves + persistent taste profile,
28
- serendipity, alternative routes, offline geocoding, and a fully custom UI (the
29
- "clay sticker" design). 42 tests passing, live-verified, deploy-ready. See `PROGRESS.md`
30
- for the per-brick build log, `FIELD_NOTES.md` for the build story, and
31
- `DEPLOY.md` to push.
32
 
33
- ## Features
 
 
34
 
35
- - **Plain vs discovery route** on one map, with the time the detour buys you.
36
- - **Vibe → route**: free-text mood (e.g. "quiet green wander") is matched to OSM
37
- categories by sentence embeddings (`bge-small-en-v1.5`) and reshapes the route.
38
- - **Detour budget**: a single slider trading extra time for discovery; the route
39
- never exceeds `(1 + budget) ×` the direct time. Budget 0 = the plain route.
40
- - **Diversity by design**: a submodular orienteering solver favours a park + a
41
- viewpoint + a bookshop over five cafés, within budget.
42
- - **Adventurousness**: low → well-documented places; high → injects hidden gems.
43
- - **Grounded narration**: an itinerary that names only real waypoints, behind a
44
- hard zero-hallucination gate (optional in-Space MiniCPM5-1B enhancer — a ≤4B
45
- Tiny Titan on ZeroGPU — gated by the same).
46
- - **Alternative routes**: up to three genuinely distinct options.
47
- - **Persistent taste profile**: standing preferences + saved places, per device,
48
- blended with each trip's mood. No accounts.
49
 
50
- ## Local development
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  ```bash
53
  uv venv --python 3.11
54
- uv pip install -e . # skeleton (Bricks 0-3)
55
- uv pip install -e ".[ml,dev]" # + vibe interpretation / narration + tests
56
 
57
- # one-time offline data prep for Paris (downloads OSM, builds graph + POIs)
58
- .venv/bin/python -m discoverroute.data.build_graph
 
59
 
60
- # run the app
61
- .venv/bin/python app.py
62
  ```
63
 
64
- ## Architecture
 
65
 
66
- Offline (built once for Paris, cached): download OSM extract → build walk/bike
67
- routing graph → extract POIs with features + confidence.
68
 
69
- Runtime (per request): interpret vibescore corridor POIs plan detour
70
- (orienteering solver) trace real polyline → narrate + map overlay.
71
 
72
- The model is load-bearing only in interpretation and narration. Routing is pure
73
- classical algorithms (OSMnx + networkx + SciPy multi-source Dijkstra; the
74
- orienteering solver is a custom greedy submodular heuristic).
75
 
76
- Geocoding is local-first: named Paris places resolve against the cached POI
77
- table with no network call (set `DISCOVERROUTE_OFFLINE=1` to forbid the
78
- Nominatim fallback entirely). Map data © OpenStreetMap contributors (ODbL).
 
 
1
  ---
2
+ title: WanderLust
3
  emoji: 🗺️
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
  app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ short_description: Discovery routing — get from A to B through places you'll love
12
+ tags:
13
+ - backyard-ai-track
14
+ - badge-off-brand
15
+ - badge-tiny-titan
16
+ - badge-best-agent
17
+ - badge-best-demo
18
+ - openbmb
19
  ---
20
 
21
+ # WanderLust
22
 
23
+ ### Spend your extra time on discovery.
 
 
 
24
 
25
+ Most navigation apps answer one question: *what's the fastest way there?* **WanderLust
26
+ asks a better one: what if those few extra minutes were a gift?** You tell it where you're
27
+ going and the kind of moment you're after — "a slow Sunday-morning kind of walk,"
28
+ "bookshops and quiet streets," "a lively café crawl" — and it threads you from A to B
29
+ **past the places you'll actually love**, then tells you, in your own words, why each
30
+ one is on your path. Same destination. A walk you'll remember instead of one you'll forget.
31
 
32
+ > One-liner: **WanderLust turns any walk from A to B into a personal discovery — routing
33
+ > you through places that match your taste, not just the fastest path.** Single city today: **Paris**.
34
 
35
+ ---
36
 
37
+ ## 🔗 Links (judges start here)
 
 
 
 
38
 
39
+ - **🎬 Demo video:** _TBA_
40
+ - **📣 Social post:** _TBA_
41
+ - **🧑‍🤝‍🧑 Team:** [Ishrat Jahan Ananya](TBA) · [Tristan Leduc](TBA)
42
 
43
+ ---
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ ## Why the AI is load-bearing
46
+
47
+ WanderLust **cannot work without the model.** The hard problem isn't routing — it's the
48
+ gap between how people describe what they want and what a router can optimize. A user
49
+ types a fuzzy, open-ended vibe ("somewhere that feels like a slow Sunday morning"), and
50
+ **MiniCPM5-1B converts it into concrete, scored routing weights** across 17 place
51
+ categories plus quiet/green/lively modifiers. No lookup table, keyword list, or rule
52
+ engine can map unbounded human mood onto a route — the model *is* the bridge. It then
53
+ writes the itinerary that explains why each chosen stop matches what you asked for,
54
+ grounded so it can only name real places on your route.
55
+
56
+ (When no GPU is allocated, the app degrades gracefully to an embedding + keyword
57
+ interpreter and a deterministic template — it never breaks — but the model is what makes
58
+ the experience feel like it read your mind.)
59
+
60
+ ## The tech, one sentence each
61
+
62
+ - **Model — `openbmb/MiniCPM5-1B`:** a 1B-parameter model does two jobs — vibe → routing
63
+ weights (JSON), and route → first-person itinerary narration.
64
+ - **ZeroGPU:** the model runs **inside the Space** on HF ZeroGPU via `@spaces.GPU`,
65
+ weights pulled from the Hub — no external inference API, nothing leaves the Space.
66
+ - **OpenStreetMap + OSMnx:** the Paris walking/biking graph and ~30k POIs, pre-built and
67
+ cached offline (Git LFS) so the demo city is instant.
68
+ - **Routing — classical, exact:** a `networkx` + SciPy multi-source Dijkstra travel-time
69
+ matrix, solved by a custom **orienteering** (prize-collecting TSP) heuristic with
70
+ submodular diversity — so you get a park + a viewpoint + a bookshop, not five cafés.
71
+ - **Frontend — Gradio `gr.Server`:** a hand-built HTML/CSS/JS app-shell on Gradio's
72
+ FastAPI backend, called from the browser via `@gradio/client` — **no default Gradio UI**.
73
+
74
+ ## What it does
75
+
76
+ - **Plain vs. discovery route** drawn on one map, with the exact time the detour buys you.
77
+ - **Vibe → route:** free-text mood reshapes which places the route seeks out.
78
+ - **Detour budget:** one slider trades extra time for discovery; the route never exceeds
79
+ `(1 + budget) ×` the direct time. Budget 0 = the plain fastest route.
80
+ - **Adventurousness:** low → well-documented places; high → injects hidden gems.
81
+ - **Grounded narration:** an itinerary naming only real waypoints, behind a hard
82
+ zero-hallucination gate.
83
+ - **Alternative routes:** up to three genuinely distinct options.
84
+ - **Persistent taste profile:** standing preferences + saved places, per device, blended
85
+ with each trip's mood. No accounts.
86
+
87
+ ## Badges we're claiming (and why)
88
+
89
+ | Badge | Justification |
90
+ |---|---|
91
+ | **Off-Brand** | Custom `gr.Server` app-shell — hand-built frontend, **zero default Gradio components**. |
92
+ | **Tiny Titan** | **MiniCPM5-1B — 1B parameters** (well under the 4B cap), running in-Space on ZeroGPU. |
93
+ | **Best Agent** | A four-stage pipeline: **vibe→weights extraction → POI scoring → orienteering solve → grounded narration.** |
94
+ | **Best Demo** | End-to-end Paris route from a single free-text vibe — see the demo video above. |
95
+ | **openbmb** | Built on OpenBMB's MiniCPM5-1B as the core reasoning model. |
96
+
97
+ > We also run **OSM-only with the model in-Space** (no external/cloud APIs at request
98
+ > time) and **log every inference call** to `logs/traces.jsonl` (optionally pushed to an
99
+ > HF Dataset) — supporting the *Off the Grid* and *Open Trace* narratives.
100
+
101
+ ## Run it locally
102
 
103
  ```bash
104
  uv venv --python 3.11
105
+ uv pip install -e ".[ml,dev]" # routing + vibe interpretation/narration + tests
 
106
 
107
+ # one-time offline data prep for Paris (downloads OSM, builds the graph + POIs)
108
+ python -m discoverroute.data.build_graph
109
+ python -m discoverroute.data.build_pois
110
 
111
+ python app.py # serves the gr.Server app-shell on :7860
 
112
  ```
113
 
114
+ `65 tests passing` · See `DEPLOY.md` to push to a Space, `FIELD_NOTES.md` for the build
115
+ story, `PROGRESS.md` for the per-feature log.
116
 
117
+ ## Architecture
 
118
 
119
+ **Offline (built once for Paris, cached):** OSM extractwalk/bike routing graph
120
+ POIs with feature priors + confidence.
121
 
122
+ **Runtime (per request):** interpret vibe score corridor POIs solve the detour
123
+ (orienteering) trace a real polyline narrate + overlay on the map.
 
124
 
125
+ The model is load-bearing only in **interpretation and narration**; routing is pure
126
+ classical algorithms. Geocoding is local-first named Paris places resolve against the
127
+ cached POI table with no network call (`DISCOVERROUTE_OFFLINE=1` forbids the Nominatim
128
+ fallback entirely). Map data © OpenStreetMap contributors (ODbL).
src/discoverroute/ui/shell.py CHANGED
@@ -25,76 +25,139 @@ body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-i
25
  background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
26
 
27
  .app-shell{
28
- display:grid; grid-template-columns:320px 1fr; grid-template-rows:auto 1fr;
29
  height:100vh; width:100%; background:var(--dr-cream);
30
  }
31
 
32
- /* ---- hero (compact ≤120px, content + colors preserved) ---- */
33
- .hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:18px;
34
  background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
35
- padding:12px 26px; max-height:120px; overflow:hidden; position:relative; }
36
- .hero .hero-body{ flex:1; min-width:0; }
37
- .hero .loc-pill{ display:inline-flex; align-items:center; gap:7px; background:rgba(255,255,255,.16);
38
- border-radius:999px; padding:3px 11px; font-family:'Fredoka',sans-serif; font-weight:600;
39
- font-size:11.5px; letter-spacing:.04em; }
40
- .hero .loc-pill .dot{ width:7px;height:7px;border-radius:50%;background:#FFC247; }
41
- .hero h1{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:28px; letter-spacing:-.02em;
42
- margin:5px 0 3px; line-height:1.05; color:#fff; }
 
 
 
 
 
 
43
  .hero h1 .accent{ color:#FFE0A0; }
44
- .hero p{ margin:0 0 7px; max-width:62ch; color:#EAF0FF; font-size:13px; line-height:1.3; }
45
- .hero .badges{ display:flex; flex-wrap:wrap; gap:7px; }
46
- .hero .badges span{ background:rgba(255,255,255,.13); border-radius:999px; padding:4px 11px;
47
  font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
48
- .hero .hero-art{ flex-shrink:0; width:80px; }
49
- .hero .hero-art svg{ width:80px; height:auto; display:block; }
50
- .hero .titan-chip{ position:absolute; top:10px; right:18px; background:rgba(255,255,255,.16);
51
- border-radius:999px; padding:3px 10px; font-size:10.5px; font-family:'Fredoka',sans-serif;
 
 
 
52
  font-weight:600; letter-spacing:.02em; }
 
 
53
 
54
- /* ---- left control panel (320px, scrollable, sticky CTA) ---- */
55
- .left-panel{ grid-column:1; grid-row:2; width:320px; height:100%; overflow-y:auto;
56
- padding:18px 16px 0; border-right:1px solid rgba(0,0,0,.08); background:var(--dr-cream);
 
57
  display:flex; flex-direction:column; }
58
- .dr-control{ margin-bottom:13px; }
 
 
 
 
 
59
  .dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px;
60
- color:var(--dr-ink); margin-bottom:5px; }
61
- .dr-help{ font-size:11.5px; color:var(--dr-soft); margin-top:3px; }
62
- .left-panel input[type=text]{ width:100%; padding:10px 12px; font-size:14px; }
 
 
 
 
 
 
 
 
 
63
  .combo{ position:relative; }
64
- .combo-list{ position:absolute; z-index:50; left:0; right:0; top:calc(100% + 3px);
65
- background:var(--dr-paper); border:1px solid var(--dr-line); border-radius:14px;
66
- box-shadow:0 14px 30px -14px rgba(43,38,32,.4); overflow:hidden; display:none; }
67
- .combo-list.open{ display:block; }
68
- .combo-list div{ padding:9px 12px; font-size:13.5px; cursor:pointer; }
 
 
 
69
  .combo-list div:hover,.combo-list div.active{ background:#F1FAF4; }
70
- .dr-slider{ display:flex; align-items:center; gap:10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  .dr-slider input[type=range]{ flex:1; }
72
  .dr-slider output{ font-family:'JetBrains Mono',monospace; font-size:12.5px; color:var(--dr-soft);
73
- min-width:30px; text-align:right; }
74
- details.dr-collapse{ padding:0; margin-bottom:13px; }
75
- details.dr-collapse summary{ list-style:none; cursor:pointer; padding:11px 14px;
76
- font-family:'Fredoka',sans-serif; font-weight:600; font-size:13px; color:var(--dr-ink); }
 
 
 
 
 
77
  details.dr-collapse summary::-webkit-details-marker{ display:none; }
 
 
 
78
  details.dr-collapse[open] summary{ border-bottom:1.5px dashed var(--dr-line); }
79
- details.dr-collapse .collapse-body{ padding:12px 14px; }
80
- .dr-cta-wrap{ position:sticky; bottom:0; padding:12px 0; margin-top:auto;
81
- background:linear-gradient(180deg,rgba(246,236,217,0),var(--dr-cream) 32%); }
82
- #dr-plan button{ width:100%; padding:13px; cursor:pointer; }
 
 
83
  .dr-note{ font-size:12.5px; color:var(--dr-soft); margin:8px 0; }
84
  .dr-row{ display:flex; gap:8px; }
85
- .dr-row button{ flex:1; padding:8px; border-radius:12px; border:1px solid var(--dr-line);
86
- background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
87
- color:var(--dr-ink); }
88
- .dr-star{ width:100%; margin-top:8px; padding:9px; border-radius:12px; border:1px solid var(--dr-line);
89
  background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
90
- color:var(--dr-ink); }
 
 
 
 
 
91
 
92
  /* ---- right column: map · summary bar · itinerary ---- */
93
  .right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
94
- min-width:0; padding:14px 16px 0; gap:12px; overflow:hidden; }
95
- .map-container{ flex:1 1 0; min-height:480px; position:relative; }
96
  #dr-map{ height:100%; }
97
- #dr-map .map-inner{ position:absolute; inset:34px 0 0 0; }
98
  #dr-map .map-inner > div,
99
  #dr-map iframe,
100
  #dr-map .folium-map,
@@ -104,33 +167,53 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
104
  #dr-summary{ margin:0; }
105
  /* the styled result blocks only exist once a route is planned */
106
  #dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
107
- .itinerary-panel{ flex:0 0 auto; max-height:40vh; overflow-y:auto; padding-bottom:14px; }
108
- #dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:12px; }
109
- #dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:10px 13px;
110
- background:var(--dr-paper); color:var(--dr-ink); cursor:pointer; font-size:12.5px;
111
- transition:all .2s var(--dr-spring); }
 
 
 
 
 
 
112
  #dr-options .opt:hover{ transform:translateY(-3px); }
113
  #dr-options .opt.selected{ border-color:var(--dr-grass); background:#F1FAF4;
114
  box-shadow:0 10px 26px -14px rgba(47,164,99,.6); }
115
 
 
 
 
 
 
 
 
 
116
  /* ---- loading: stride + 4-step stepper (State 2) ---- */
117
- #dr-loading{ position:absolute; inset:34px 0 0 0; z-index:20; display:none;
118
  place-items:center; border-radius:0 0 26px 26px;
119
  background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
120
- #dr-loading.on{ display:grid; }
121
- .stepper{ display:flex; align-items:center; gap:0; margin-top:18px; }
122
- .stepper .step{ display:flex; flex-direction:column; align-items:center; gap:6px; width:120px;
123
- opacity:.4; transition:opacity .4s ease; }
 
124
  .stepper .step.active{ opacity:1; }
125
- .stepper .step .pip{ width:14px; height:14px; border-radius:50%; background:#CFE8D8;
126
- transition:background .4s ease, transform .4s var(--dr-spring); }
127
- .stepper .step.active .pip{ background:var(--dr-grass); transform:scale(1.15); }
 
 
 
 
128
  .stepper .step .lbl{ font-size:11.5px; font-family:'DM Sans',sans-serif; color:var(--dr-ink);
129
- text-align:center; }
130
- .stepper .bar{ flex:1; height:3px; min-width:24px; background:#CFE8D8; position:relative; }
 
131
  .stepper .bar.fill{ background:var(--dr-grass); }
132
  /* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
133
- #dr-mapping{ position:absolute; inset:34px 0 0 0; z-index:21; display:none; place-items:center;
134
  background:#F6ECD9; }
135
  #dr-mapping.on{ display:grid; }
136
  #dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
@@ -139,6 +222,7 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
139
 
140
  /* no-detour sticker slot */
141
  #dr-nodetour:empty{ display:none; }
 
142
 
143
  /* mobile FAB (hidden on desktop) */
144
  .fab{ display:none; }
@@ -148,24 +232,32 @@ details.dr-collapse .collapse-body{ padding:12px 14px; }
148
  .app-shell{ grid-template-columns:1fr; grid-template-rows:auto 1fr; height:100vh; }
149
  .hero{ max-height:none; }
150
  .hero .hero-art{ display:none; }
 
151
  .right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
152
  .map-container{ min-height:0; }
153
  #dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
154
  .itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
155
  .route-summary-bar{ padding:0 12px; }
156
  .left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
157
- height:auto; max-height:78vh; border-right:none; border-top-left-radius:24px;
158
- border-top-right-radius:24px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
159
- transform:translateY(110%); transition:transform .32s var(--dr-spring); }
 
160
  .left-panel.open{ transform:translateY(0); }
161
  .fab{ display:grid; place-items:center; position:fixed; right:18px; bottom:18px; z-index:210;
162
- width:60px; height:60px; border-radius:50%; border:none; cursor:pointer;
163
- background:var(--dr-coral); color:#fff; font-size:24px;
164
- box-shadow:0 10px 24px -8px rgba(255,106,82,.8); }
165
- .drawer-close{ display:block; }
 
 
166
  }
167
- .drawer-close{ display:none; width:100%; text-align:center; padding:6px; font-size:22px;
168
- color:var(--dr-soft); cursor:pointer; }
 
 
 
 
169
 
170
  @media (prefers-reduced-motion:reduce){ *{ animation:none !important; transition:none !important; } }
171
  """
@@ -192,38 +284,67 @@ def _compact_hero() -> str:
192
  """
193
 
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  def _left_panel() -> str:
196
- return """
197
  <aside class="left-panel" id="left-panel">
198
- <div class="drawer-close" id="drawer-close">×</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  <div class="dr-control combo">
201
  <label class="dr-label" for="dr-start">Start</label>
202
- <input type="text" id="dr-start" class="dr-field" autocomplete="off"
203
- value="Place de la République, Paris">
 
 
204
  <div class="combo-list" id="dr-start-list"></div>
205
- <div class="dr-help">Type a Paris place — suggestions appear as you type</div>
206
  </div>
207
 
208
  <div class="dr-control combo">
209
  <label class="dr-label" for="dr-dest">Destination</label>
210
- <input type="text" id="dr-dest" class="dr-field" autocomplete="off"
211
- value="Jardin du Luxembourg, Paris">
 
 
212
  <div class="combo-list" id="dr-dest-list"></div>
213
  </div>
214
 
215
- <div class="dr-control">
216
- <label class="dr-label" for="dr-vibe">Vibe (free text)</label>
217
- <input type="text" id="dr-vibe" class="dr-field"
218
- placeholder="e.g. 'quiet green wander' or 'lively café crawl'">
219
- </div>
220
-
221
  <div class="dr-control">
222
  <label class="dr-label">Mode</label>
223
  <div id="dr-mode" class="dr-seg">
224
  <div class="wrap">
225
- <label class="selected" data-v="walk">walk</label>
226
- <label data-v="bike">bike</label>
227
  </div>
228
  </div>
229
  </div>
@@ -231,52 +352,54 @@ def _left_panel() -> str:
231
  <div class="dr-control">
232
  <label class="dr-label" for="dr-budget-i">Detour budget</label>
233
  <div id="dr-budget" class="dr-slider">
234
- <input type="range" id="dr-budget-i" min="0" max="2" step="0.1" value="0.5">
 
235
  <output id="dr-budget-o">0.5</output>
236
  </div>
237
- <div class="dr-help">Extra time vs. the direct trip — 0 = straight there, 1 = up to 2× longer</div>
238
  </div>
239
 
240
  <div class="dr-control">
241
  <label class="dr-label" for="dr-adv-i">Adventurousness</label>
242
  <div id="dr-adv" class="dr-slider">
243
- <input type="range" id="dr-adv-i" min="0" max="1" step="0.05" value="0.3">
 
244
  <output id="dr-adv-o">0.3</output>
245
  </div>
246
- <div class="dr-help">Low = well-known places · high = hidden gems</div>
247
  </div>
248
 
249
  <details class="dr-collapse">
250
- <summary>Manual taste (used only when Vibe and saved profile are both empty)</summary>
251
  <div class="collapse-body">
 
 
252
  <div class="dr-control">
253
  <label class="dr-label" for="dr-green-i">Prefer green</label>
254
  <div id="dr-green" class="dr-slider green">
255
- <input type="range" id="dr-green-i" min="0" max="1" step="0.05" value="0.5">
 
256
  <output id="dr-green-o">0.5</output>
257
  </div>
258
  </div>
259
- <div class="dr-control">
260
  <label class="dr-label" for="dr-quiet-i">Prefer quiet</label>
261
  <div id="dr-quiet" class="dr-slider green">
262
- <input type="range" id="dr-quiet-i" min="0" max="1" step="0.05" value="0.5">
 
263
  <output id="dr-quiet-o">0.5</output>
264
  </div>
265
  </div>
266
  </div>
267
  </details>
268
 
269
- <div class="dr-cta-wrap">
270
- <div id="dr-plan"><button type="button" id="dr-plan-btn">Plan route</button></div>
271
- </div>
272
-
273
  <details class="dr-collapse">
274
- <summary>⭐ My taste profile (saved on this device)</summary>
275
  <div class="collapse-body">
276
  <label class="dr-label" for="dr-profile-text">Standing preferences</label>
277
  <input type="text" id="dr-profile-text" class="dr-field"
278
  placeholder="e.g. 'I always love bookshops, gardens, and old churches'">
279
- <div class="dr-row" style="margin-top:8px;">
280
  <button type="button" id="dr-save">Save</button>
281
  <button type="button" id="dr-clear">Clear</button>
282
  </div>
@@ -284,6 +407,12 @@ def _left_panel() -> str:
284
  <button type="button" class="dr-star" id="dr-save-places">⭐ Save this route's places</button>
285
  </div>
286
  </details>
 
 
 
 
 
 
287
  </aside>
288
  """
289
 
@@ -315,6 +444,15 @@ def index_html() -> str:
315
  </div>
316
  <div class="route-summary-bar"><div id="dr-summary"></div></div>
317
  <div class="itinerary-panel">
 
 
 
 
 
 
 
 
 
318
  <div id="dr-options"></div>
319
  <div id="dr-nodetour"></div>
320
  <div id="dr-interp"></div>
@@ -322,7 +460,7 @@ def index_html() -> str:
322
  </div>
323
  </main>
324
  </div>
325
- <button class="fab" id="fab" aria-label="Open controls"></button>
326
 
327
  <script type="module">{_app_js()}</script>
328
  </body>
@@ -341,16 +479,14 @@ def _loading_inner() -> str:
341
  f'<div class="lbl">{label}</div></div>'
342
  )
343
  stepper = '<div class="stepper">' + "".join(pips) + "</div>"
344
- # Reuse the existing stride mascot markup from LOADING_HTML's inner SVG block.
345
  return f"""
346
- <div style="text-align:center;">
347
- <div style="font-family:'Fredoka',system-ui,sans-serif;font-weight:600;font-size:19px;
348
  color:#2B2620;">Scouting your wander…</div>
 
 
349
  {stepper}
350
  </div>
351
- <style>
352
- @keyframes drBounce {{ 0%,100% {{ transform:translateY(0) }} 50% {{ transform:translateY(-9px) }} }}
353
- </style>
354
  """
355
 
356
 
@@ -409,15 +545,37 @@ function toast(msg) {
409
  inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0");
410
  });
411
 
412
- /* ---------- mode segmented toggle ---------- */
413
  let mode = "walk";
 
 
 
 
 
 
414
  document.querySelectorAll("#dr-mode label").forEach(l => {
415
- l.addEventListener("click", () => {
416
- document.querySelectorAll("#dr-mode label").forEach(x => x.classList.remove("selected"));
417
- l.classList.add("selected"); mode = l.dataset.v;
418
  });
419
  });
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  /* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */
422
  function wireCombo(inputId, listId) {
423
  const input = $(inputId), list = $(listId);
@@ -457,12 +615,17 @@ wireCombo("dr-dest", "dr-dest-list");
457
  /* ---------- 4-step loader (paced to the pipeline phases) ---------- */
458
  let stepTimer = null;
459
  function setStep(n) {
460
- document.querySelectorAll(".stepper .step").forEach(s =>
461
- s.classList.toggle("active", +s.dataset.step <= n));
 
 
 
462
  document.querySelectorAll(".stepper .bar").forEach(b =>
463
  b.classList.toggle("fill", +b.dataset.bar <= n));
464
  }
 
465
  function startLoading() {
 
466
  $("dr-loading").classList.add("on");
467
  $("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
468
  $("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
@@ -472,19 +635,38 @@ function startLoading() {
472
  function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").classList.remove("on"); }
473
 
474
  /* ---------- render ---------- */
 
475
  let lastAlts = [], lastCats = [];
476
  function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  function selectAlt(idx) {
478
  const a = lastAlts[idx]; if (!a) return;
479
  renderMap(a.map_html);
480
  $("dr-summary").innerHTML = md(a.summary_md);
481
  $("dr-itin").innerHTML = md(a.itinerary_md);
 
482
  document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
483
  o.classList.toggle("selected", i === idx));
484
  }
485
  function renderResult(d) {
 
486
  if (d.error) { renderMap(d.map_html); toast("Hmm — " + d.error.split(".")[0] + "."); return; }
487
  $("dr-interp").innerHTML = md(d.interpretation_md);
 
488
  if (d.no_detour) {
489
  renderMap(d.map_html);
490
  $("dr-summary").innerHTML = md(d.summary_md);
@@ -547,8 +729,34 @@ $("dr-save-places").addEventListener("click", () => {
547
  });
548
 
549
  /* ---------- mobile drawer ---------- */
550
- $("fab").addEventListener("click", () => $("left-panel").classList.add("open"));
551
- $("drawer-close").addEventListener("click", () => $("left-panel").classList.remove("open"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
 
553
  renderSaved();
554
  """
 
25
  background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
26
 
27
  .app-shell{
28
+ display:grid; grid-template-columns:340px 1fr; grid-template-rows:auto 1fr;
29
  height:100vh; width:100%; background:var(--dr-cream);
30
  }
31
 
32
+ /* ---- hero (compact but roomy — content must never clip) ---- */
33
+ .hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:22px;
34
  background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
35
+ padding:18px 34px; max-height:180px; overflow:hidden; position:relative;
36
+ box-shadow:0 10px 30px -18px rgba(33,74,208,.7); z-index:3; }
37
+ .hero::after{ content:''; position:absolute; inset:0; pointer-events:none;
38
+ background:radial-gradient(420px 180px at 96% -30%,rgba(255,255,255,.16),transparent 70%); }
39
+ .hero .hero-body{ flex:1; min-width:0; position:relative; z-index:1; }
40
+ .hero .loc-pill{ display:inline-flex; align-items:center; gap:7px; background:rgba(255,255,255,.18);
41
+ border-radius:999px; padding:4px 12px; font-family:'Fredoka',sans-serif; font-weight:600;
42
+ font-size:11.5px; letter-spacing:.05em; text-transform:uppercase; }
43
+ .hero .loc-pill .dot{ width:7px;height:7px;border-radius:50%;background:#FFC247;
44
+ box-shadow:0 0 0 0 rgba(255,194,71,.7); animation:drBeacon 2.4s ease-out infinite; }
45
+ @keyframes drBeacon{ 0%{ box-shadow:0 0 0 0 rgba(255,194,71,.6); }
46
+ 70%,100%{ box-shadow:0 0 0 7px rgba(255,194,71,0); } }
47
+ .hero h1{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:30px; letter-spacing:-.025em;
48
+ margin:7px 0 4px; line-height:1.04; color:#fff; }
49
  .hero h1 .accent{ color:#FFE0A0; }
50
+ .hero p{ margin:0 0 9px; max-width:64ch; color:#EAF0FF; font-size:13px; line-height:1.35; }
51
+ .hero .badges{ display:flex; flex-wrap:wrap; gap:8px; }
52
+ .hero .badges span{ background:rgba(255,255,255,.14); border-radius:999px; padding:5px 12px;
53
  font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
54
+ .hero .hero-art{ flex-shrink:0; width:84px; position:relative; z-index:1;
55
+ animation:drFloat 6s ease-in-out infinite; }
56
+ .hero .hero-art svg{ width:84px; height:auto; display:block; }
57
+ @keyframes drFloat{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-7px); } }
58
+ .hero .titan-chip{ position:absolute; top:12px; right:20px; z-index:2;
59
+ display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,.18);
60
+ border-radius:999px; padding:4px 12px; font-size:10.5px; font-family:'Fredoka',sans-serif;
61
  font-weight:600; letter-spacing:.02em; }
62
+ .hero .titan-chip::before{ content:''; width:6px; height:6px; border-radius:50%;
63
+ background:#9BF0BE; box-shadow:0 0 6px #6FE39C; }
64
 
65
+ /* ---- left control panel (340px, scrollable, sticky CTA) ---- */
66
+ .left-panel{ grid-column:1; grid-row:2; width:340px; height:100%; overflow:hidden;
67
+ border-right:1px solid var(--dr-line);
68
+ background:linear-gradient(180deg,#FBF3E2,var(--dr-cream) 140px);
69
  display:flex; flex-direction:column; }
70
+ .panel-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; overflow-x:hidden;
71
+ padding:20px 18px 10px; scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
72
+ .panel-scroll::-webkit-scrollbar{ width:9px; }
73
+ .panel-scroll::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
74
+ border:3px solid transparent; background-clip:content-box; }
75
+ .dr-control{ margin-bottom:15px; }
76
  .dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px;
77
+ color:var(--dr-ink); margin-bottom:6px; }
78
+ .dr-label .opt-tag{ font-family:'DM Sans',sans-serif; font-weight:500; font-size:10.5px;
79
+ color:var(--dr-soft); text-transform:uppercase; letter-spacing:.05em; margin-left:6px; }
80
+ .dr-help{ font-size:11.5px; color:var(--dr-soft); margin-top:4px; line-height:1.4; }
81
+ .left-panel input[type=text]{ width:100%; padding:11px 13px; font-size:14px; }
82
+
83
+ /* a leading pin/flag glyph for the start & destination fields */
84
+ .field-wrap{ position:relative; }
85
+ .field-wrap > input[type=text]{ padding-left:34px; }
86
+ .field-wrap::before{ content:attr(data-glyph); position:absolute; left:12px; top:50%;
87
+ transform:translateY(-50%); font-size:14px; pointer-events:none; z-index:2; line-height:1; }
88
+
89
  .combo{ position:relative; }
90
+ .combo-list{ position:absolute; z-index:60; left:0; right:0; top:calc(100% + 5px);
91
+ background:var(--dr-paper); border:1.5px solid var(--dr-line); border-radius:16px;
92
+ box-shadow:0 18px 38px -16px rgba(43,38,32,.45); overflow:hidden; display:none; padding:5px; }
93
+ .combo-list.open{ display:block; animation:drPop .16s var(--dr-spring); }
94
+ @keyframes drPop{ from{ transform:translateY(-4px); opacity:.4; } to{ transform:none; opacity:1; } }
95
+ .combo-list div{ padding:9px 11px; font-size:13.5px; cursor:pointer; border-radius:11px;
96
+ display:flex; align-items:center; gap:8px; transition:background .12s; }
97
+ .combo-list div::before{ content:'📍'; font-size:11px; opacity:.55; }
98
  .combo-list div:hover,.combo-list div.active{ background:#F1FAF4; }
99
+ .combo-list div.active{ box-shadow:inset 0 0 0 1.5px rgba(47,164,99,.4); }
100
+
101
+ /* ---- VIBE — the star input ---- */
102
+ .vibe-block{ background:var(--dr-paper); border:1.5px solid var(--dr-line); border-radius:20px;
103
+ padding:14px 14px 12px; margin-bottom:16px;
104
+ box-shadow:0 14px 34px -22px rgba(43,38,32,.4); position:relative; }
105
+ .vibe-block::before{ content:'⭐ start here'; position:absolute; top:-9px; left:14px;
106
+ background:var(--dr-sun); color:#5A3D00; font-family:'Fredoka',sans-serif; font-weight:600;
107
+ font-size:10px; letter-spacing:.04em; text-transform:uppercase; padding:2px 9px;
108
+ border-radius:999px; box-shadow:0 4px 10px -4px rgba(255,194,71,.9); }
109
+ .vibe-block .dr-label{ font-size:15px; margin-top:2px; }
110
+ .vibe-block input[type=text]{ font-size:15px !important; }
111
+ .vibe-chips{ display:flex; flex-wrap:wrap; gap:7px; margin-top:10px; }
112
+ .vibe-chips .chip{ border:1.5px solid var(--dr-line); background:#FFFDF8; color:var(--dr-ink);
113
+ border-radius:999px; padding:6px 12px; font-size:12px; font-family:'DM Sans',sans-serif;
114
+ font-weight:500; cursor:pointer; transition:all .18s var(--dr-spring); }
115
+ .vibe-chips .chip:hover{ transform:translateY(-2px); border-color:var(--dr-coral);
116
+ background:#FFF1ED; box-shadow:0 8px 16px -10px rgba(255,106,82,.7); }
117
+ .vibe-chips .chip:active{ transform:translateY(0); }
118
+ .vibe-chips .chip.on{ border-color:var(--dr-coral); background:var(--dr-coral); color:#fff;
119
+ box-shadow:0 8px 18px -8px rgba(255,106,82,.8); }
120
+
121
+ .dr-slider{ display:flex; align-items:center; gap:12px; }
122
  .dr-slider input[type=range]{ flex:1; }
123
  .dr-slider output{ font-family:'JetBrains Mono',monospace; font-size:12.5px; color:var(--dr-soft);
124
+ min-width:34px; text-align:right; padding:2px 7px; background:#FFFDF8; border:1px solid var(--dr-line);
125
+ border-radius:8px; }
126
+ .slider-ends{ display:flex; justify-content:space-between; font-size:10.5px; color:var(--dr-soft);
127
+ margin-top:3px; letter-spacing:.02em; }
128
+
129
+ details.dr-collapse{ padding:0; margin-bottom:14px; }
130
+ details.dr-collapse summary{ list-style:none; cursor:pointer; padding:12px 15px;
131
+ font-family:'Fredoka',sans-serif; font-weight:600; font-size:13px; color:var(--dr-ink);
132
+ display:flex; align-items:center; justify-content:space-between; gap:8px; }
133
  details.dr-collapse summary::-webkit-details-marker{ display:none; }
134
+ details.dr-collapse summary::after{ content:'▾'; color:var(--dr-soft); font-size:12px;
135
+ transition:transform .2s ease; }
136
+ details.dr-collapse[open] summary::after{ transform:rotate(180deg); }
137
  details.dr-collapse[open] summary{ border-bottom:1.5px dashed var(--dr-line); }
138
+ details.dr-collapse .collapse-body{ padding:13px 15px; }
139
+
140
+ .dr-cta-wrap{ flex:0 0 auto; padding:12px 18px 16px; background:var(--dr-cream);
141
+ border-top:1px solid var(--dr-line); box-shadow:0 -10px 22px -18px rgba(43,38,32,.5); }
142
+ #dr-plan button{ width:100%; padding:15px; cursor:pointer; }
143
+ .cta-hint{ text-align:center; font-size:11px; color:var(--dr-soft); margin-top:8px; }
144
  .dr-note{ font-size:12.5px; color:var(--dr-soft); margin:8px 0; }
145
  .dr-row{ display:flex; gap:8px; }
146
+ .dr-row button{ flex:1; padding:9px; border-radius:12px; border:1.5px solid var(--dr-line);
 
 
 
147
  background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
148
+ color:var(--dr-ink); transition:all .16s var(--dr-spring); }
149
+ .dr-row button:hover{ transform:translateY(-2px); border-color:var(--dr-cobalt); }
150
+ .dr-star{ width:100%; margin-top:10px; padding:10px; border-radius:12px; border:1.5px dashed var(--dr-line);
151
+ background:#FFFDF8; font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
152
+ color:var(--dr-ink); transition:all .16s var(--dr-spring); }
153
+ .dr-star:hover{ transform:translateY(-2px); border-color:var(--dr-sun); background:#FFF8E8; }
154
 
155
  /* ---- right column: map · summary bar · itinerary ---- */
156
  .right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
157
+ min-width:0; padding:16px 18px 0; gap:14px; overflow:hidden; }
158
+ .map-container{ flex:1 1 0; min-height:240px; position:relative; }
159
  #dr-map{ height:100%; }
160
+ #dr-map .map-inner{ position:absolute; inset:46px 0 0 0; }
161
  #dr-map .map-inner > div,
162
  #dr-map iframe,
163
  #dr-map .folium-map,
 
167
  #dr-summary{ margin:0; }
168
  /* the styled result blocks only exist once a route is planned */
169
  #dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
170
+ .itinerary-panel{ flex:0 1 auto; min-height:0; max-height:46vh; overflow-y:auto; padding-bottom:16px;
171
+ scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
172
+ .itinerary-panel::-webkit-scrollbar{ width:9px; }
173
+ .itinerary-panel::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
174
+ border:3px solid transparent; background-clip:content-box; }
175
+ #dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
176
+ #dr-options:empty{ display:none; }
177
+ #dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:11px 14px;
178
+ background:var(--dr-paper); color:var(--dr-ink); cursor:pointer; font-size:12.5px; font-weight:500;
179
+ box-shadow:0 6px 16px -14px rgba(43,38,32,.5);
180
+ transition:transform .2s var(--dr-spring), border-color .2s, box-shadow .2s; }
181
  #dr-options .opt:hover{ transform:translateY(-3px); }
182
  #dr-options .opt.selected{ border-color:var(--dr-grass); background:#F1FAF4;
183
  box-shadow:0 10px 26px -14px rgba(47,164,99,.6); }
184
 
185
+ /* onboarding hint shown in the itinerary slot before the first plan */
186
+ .onboard{ background:#FFFDF8; border:1.5px dashed var(--dr-line); border-radius:var(--dr-r);
187
+ padding:16px 18px; display:flex; gap:13px; align-items:flex-start; }
188
+ .onboard .ob-emoji{ font-size:22px; line-height:1; flex-shrink:0; margin-top:1px; }
189
+ .onboard h3{ font-family:'Fredoka',sans-serif; font-weight:600; font-size:15px; margin:0 0 3px;
190
+ color:var(--dr-ink); }
191
+ .onboard p{ margin:0; font-size:12.5px; color:var(--dr-soft); line-height:1.5; }
192
+
193
  /* ---- loading: stride + 4-step stepper (State 2) ---- */
194
+ #dr-loading{ position:absolute; inset:46px 0 0 0; z-index:20; display:none;
195
  place-items:center; border-radius:0 0 26px 26px;
196
  background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
197
+ #dr-loading.on{ display:grid; animation:drFade .25s ease; }
198
+ @keyframes drFade{ from{ opacity:0; } to{ opacity:1; } }
199
+ .stepper{ display:flex; align-items:flex-start; gap:0; margin-top:22px; }
200
+ .stepper .step{ display:flex; flex-direction:column; align-items:center; gap:8px; width:118px;
201
+ opacity:.38; transition:opacity .4s ease; }
202
  .stepper .step.active{ opacity:1; }
203
+ .stepper .step .pip{ width:16px; height:16px; border-radius:50%; background:#CFE8D8;
204
+ transition:background .4s ease, transform .4s var(--dr-spring); position:relative; }
205
+ .stepper .step.active .pip{ background:var(--dr-grass); transform:scale(1.18);
206
+ box-shadow:0 4px 10px -3px rgba(47,164,99,.7); }
207
+ .stepper .step.current .pip::after{ content:''; position:absolute; inset:-5px; border-radius:50%;
208
+ border:2px solid var(--dr-grass); opacity:.5; animation:drRing 1.1s ease-out infinite; }
209
+ @keyframes drRing{ 0%{ transform:scale(.7); opacity:.6; } 100%{ transform:scale(1.5); opacity:0; } }
210
  .stepper .step .lbl{ font-size:11.5px; font-family:'DM Sans',sans-serif; color:var(--dr-ink);
211
+ text-align:center; line-height:1.25; }
212
+ .stepper .bar{ flex:1; height:3px; min-width:22px; margin-top:7px; background:#CFE8D8;
213
+ border-radius:3px; transition:background .4s ease; }
214
  .stepper .bar.fill{ background:var(--dr-grass); }
215
  /* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
216
+ #dr-mapping{ position:absolute; inset:46px 0 0 0; z-index:21; display:none; place-items:center;
217
  background:#F6ECD9; }
218
  #dr-mapping.on{ display:grid; }
219
  #dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
 
222
 
223
  /* no-detour sticker slot */
224
  #dr-nodetour:empty{ display:none; }
225
+ #dr-nodetour{ margin-bottom:14px; }
226
 
227
  /* mobile FAB (hidden on desktop) */
228
  .fab{ display:none; }
 
232
  .app-shell{ grid-template-columns:1fr; grid-template-rows:auto 1fr; height:100vh; }
233
  .hero{ max-height:none; }
234
  .hero .hero-art{ display:none; }
235
+ .hero .titan-chip{ position:static; margin-top:8px; align-self:flex-start; }
236
  .right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
237
  .map-container{ min-height:0; }
238
  #dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
239
  .itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
240
  .route-summary-bar{ padding:0 12px; }
241
  .left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
242
+ height:auto; max-height:84vh; border-right:none; border-top-left-radius:26px;
243
+ border-top-right-radius:26px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
244
+ transform:translateY(110%); transition:transform .32s var(--dr-spring);
245
+ padding-top:8px; }
246
  .left-panel.open{ transform:translateY(0); }
247
  .fab{ display:grid; place-items:center; position:fixed; right:18px; bottom:18px; z-index:210;
248
+ width:62px; height:62px; border-radius:50%; border:none; cursor:pointer;
249
+ background:var(--dr-coral); color:#fff; font-size:15px; font-family:'Fredoka',sans-serif;
250
+ font-weight:600; line-height:1.05; text-align:center;
251
+ box-shadow:0 12px 26px -8px rgba(255,106,82,.85); transition:transform .18s var(--dr-spring); }
252
+ .fab:hover{ transform:translateY(-3px); }
253
+ .drawer-close{ display:flex; }
254
  }
255
+ .drawer-close{ display:none; align-items:center; justify-content:center; width:44px; height:44px;
256
+ margin:0 auto 4px; border-radius:50%; background:#FFFDF8; border:1.5px solid var(--dr-line);
257
+ font-size:22px; color:var(--dr-soft); cursor:pointer; }
258
+ .drawer-grip{ display:none; }
259
+ @media (max-width:768px){ .drawer-grip{ display:block; width:42px; height:5px; border-radius:999px;
260
+ background:var(--dr-line); margin:2px auto 8px; } }
261
 
262
  @media (prefers-reduced-motion:reduce){ *{ animation:none !important; transition:none !important; } }
263
  """
 
284
  """
285
 
286
 
287
+ _VIBE_PRESETS = [
288
+ "quiet green wander",
289
+ "lively café crawl",
290
+ "bookshops & old churches",
291
+ "morning coffee crawl",
292
+ "riverside stroll",
293
+ "art & history",
294
+ ]
295
+
296
+
297
+ def _vibe_chips() -> str:
298
+ chips = "".join(
299
+ f'<button type="button" class="chip" data-vibe="{v.replace(chr(34), "")}">{v}</button>'
300
+ for v in _VIBE_PRESETS
301
+ )
302
+ return f'<div class="vibe-chips" id="dr-vibe-chips">{chips}</div>'
303
+
304
+
305
  def _left_panel() -> str:
306
+ return f"""
307
  <aside class="left-panel" id="left-panel">
308
+ <div class="drawer-grip"></div>
309
+ <div class="drawer-close" id="drawer-close" role="button" tabindex="0"
310
+ aria-label="Close controls">&times;</div>
311
+
312
+ <div class="panel-scroll">
313
+ <div class="vibe-block">
314
+ <div class="dr-control combo" style="margin-bottom:0;">
315
+ <label class="dr-label" for="dr-vibe">What's your vibe today?</label>
316
+ <input type="text" id="dr-vibe" class="dr-field"
317
+ placeholder="Describe the wander you're after…">
318
+ <div class="dr-help">Tell me the mood — I'll read it and pick places to match.</div>
319
+ </div>
320
+ {_vibe_chips()}
321
+ </div>
322
 
323
  <div class="dr-control combo">
324
  <label class="dr-label" for="dr-start">Start</label>
325
+ <div class="field-wrap" data-glyph="📍">
326
+ <input type="text" id="dr-start" class="dr-field" autocomplete="off"
327
+ value="Place de la République, Paris">
328
+ </div>
329
  <div class="combo-list" id="dr-start-list"></div>
330
+ <div class="dr-help">Try a landmark or place name not a street address.</div>
331
  </div>
332
 
333
  <div class="dr-control combo">
334
  <label class="dr-label" for="dr-dest">Destination</label>
335
+ <div class="field-wrap" data-glyph="🏁">
336
+ <input type="text" id="dr-dest" class="dr-field" autocomplete="off"
337
+ value="Jardin du Luxembourg, Paris">
338
+ </div>
339
  <div class="combo-list" id="dr-dest-list"></div>
340
  </div>
341
 
 
 
 
 
 
 
342
  <div class="dr-control">
343
  <label class="dr-label">Mode</label>
344
  <div id="dr-mode" class="dr-seg">
345
  <div class="wrap">
346
+ <label class="selected" data-v="walk" role="button" tabindex="0">🚶 walk</label>
347
+ <label data-v="bike" role="button" tabindex="0">🚲 bike</label>
348
  </div>
349
  </div>
350
  </div>
 
352
  <div class="dr-control">
353
  <label class="dr-label" for="dr-budget-i">Detour budget</label>
354
  <div id="dr-budget" class="dr-slider">
355
+ <input type="range" id="dr-budget-i" min="0" max="2" step="0.1" value="0.5"
356
+ aria-label="Detour budget">
357
  <output id="dr-budget-o">0.5</output>
358
  </div>
359
+ <div class="slider-ends"><span>straight there</span><span>up to 2× longer</span></div>
360
  </div>
361
 
362
  <div class="dr-control">
363
  <label class="dr-label" for="dr-adv-i">Adventurousness</label>
364
  <div id="dr-adv" class="dr-slider">
365
+ <input type="range" id="dr-adv-i" min="0" max="1" step="0.05" value="0.3"
366
+ aria-label="Adventurousness">
367
  <output id="dr-adv-o">0.3</output>
368
  </div>
369
+ <div class="slider-ends"><span>well-known</span><span>hidden gems</span></div>
370
  </div>
371
 
372
  <details class="dr-collapse">
373
+ <summary>Manual taste <span class="opt-tag">optional</span></summary>
374
  <div class="collapse-body">
375
+ <div class="dr-help" style="margin:0 0 10px;">Only used when Vibe and your saved
376
+ profile are both empty.</div>
377
  <div class="dr-control">
378
  <label class="dr-label" for="dr-green-i">Prefer green</label>
379
  <div id="dr-green" class="dr-slider green">
380
+ <input type="range" id="dr-green-i" min="0" max="1" step="0.05" value="0.5"
381
+ aria-label="Prefer green">
382
  <output id="dr-green-o">0.5</output>
383
  </div>
384
  </div>
385
+ <div class="dr-control" style="margin-bottom:0;">
386
  <label class="dr-label" for="dr-quiet-i">Prefer quiet</label>
387
  <div id="dr-quiet" class="dr-slider green">
388
+ <input type="range" id="dr-quiet-i" min="0" max="1" step="0.05" value="0.5"
389
+ aria-label="Prefer quiet">
390
  <output id="dr-quiet-o">0.5</output>
391
  </div>
392
  </div>
393
  </div>
394
  </details>
395
 
 
 
 
 
396
  <details class="dr-collapse">
397
+ <summary>⭐ My taste profile <span class="opt-tag">saved on this device</span></summary>
398
  <div class="collapse-body">
399
  <label class="dr-label" for="dr-profile-text">Standing preferences</label>
400
  <input type="text" id="dr-profile-text" class="dr-field"
401
  placeholder="e.g. 'I always love bookshops, gardens, and old churches'">
402
+ <div class="dr-row" style="margin-top:10px;">
403
  <button type="button" id="dr-save">Save</button>
404
  <button type="button" id="dr-clear">Clear</button>
405
  </div>
 
407
  <button type="button" class="dr-star" id="dr-save-places">⭐ Save this route's places</button>
408
  </div>
409
  </details>
410
+ </div>
411
+
412
+ <div class="dr-cta-wrap">
413
+ <div id="dr-plan"><button type="button" id="dr-plan-btn">Plan my route →</button></div>
414
+ <div class="cta-hint">Reads your vibe · threads a detour · writes the itinerary</div>
415
+ </div>
416
  </aside>
417
  """
418
 
 
444
  </div>
445
  <div class="route-summary-bar"><div id="dr-summary"></div></div>
446
  <div class="itinerary-panel">
447
+ <div id="dr-onboard" class="onboard">
448
+ <div class="ob-emoji">🥐</div>
449
+ <div>
450
+ <h3>Ready when you are.</h3>
451
+ <p>Pick a vibe (or tap a chip), keep or tweak the Start &amp; Destination, then
452
+ hit <strong>Plan my route</strong>. I'll thread a detour past places that match —
453
+ and tell you why each one made the cut.</p>
454
+ </div>
455
+ </div>
456
  <div id="dr-options"></div>
457
  <div id="dr-nodetour"></div>
458
  <div id="dr-interp"></div>
 
460
  </div>
461
  </main>
462
  </div>
463
+ <button class="fab" id="fab" aria-label="Open route controls">Plan</button>
464
 
465
  <script type="module">{_app_js()}</script>
466
  </body>
 
479
  f'<div class="lbl">{label}</div></div>'
480
  )
481
  stepper = '<div class="stepper">' + "".join(pips) + "</div>"
 
482
  return f"""
483
+ <div style="text-align:center; max-width:540px; padding:0 16px;">
484
+ <div style="font-family:'Fredoka',system-ui,sans-serif;font-weight:600;font-size:21px;
485
  color:#2B2620;">Scouting your wander…</div>
486
+ <div style="font-family:'DM Sans',system-ui,sans-serif;font-size:13px;color:#6B6256;
487
+ margin-top:6px;">reading your vibe · scoring 30,000 places · threading the detour</div>
488
  {stepper}
489
  </div>
 
 
 
490
  """
491
 
492
 
 
545
  inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0");
546
  });
547
 
548
+ /* ---------- mode segmented toggle (mouse + keyboard) ---------- */
549
  let mode = "walk";
550
+ function pickMode(l) {
551
+ document.querySelectorAll("#dr-mode label").forEach(x => {
552
+ x.classList.remove("selected"); x.setAttribute("aria-pressed", "false");
553
+ });
554
+ l.classList.add("selected"); l.setAttribute("aria-pressed", "true"); mode = l.dataset.v;
555
+ }
556
  document.querySelectorAll("#dr-mode label").forEach(l => {
557
+ l.addEventListener("click", () => pickMode(l));
558
+ l.addEventListener("keydown", (e) => {
559
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pickMode(l); }
560
  });
561
  });
562
 
563
+ /* ---------- vibe preset chips (fill the field on click) ---------- */
564
+ const vibeInput = $("dr-vibe");
565
+ function syncVibeChips() {
566
+ const v = (vibeInput.value || "").trim().toLowerCase();
567
+ document.querySelectorAll("#dr-vibe-chips .chip").forEach(c =>
568
+ c.classList.toggle("on", (c.dataset.vibe || "").toLowerCase() === v));
569
+ }
570
+ document.querySelectorAll("#dr-vibe-chips .chip").forEach(c => {
571
+ c.addEventListener("click", () => {
572
+ vibeInput.value = c.dataset.vibe || "";
573
+ syncVibeChips();
574
+ vibeInput.focus();
575
+ });
576
+ });
577
+ vibeInput.addEventListener("input", syncVibeChips);
578
+
579
  /* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */
580
  function wireCombo(inputId, listId) {
581
  const input = $(inputId), list = $(listId);
 
615
  /* ---------- 4-step loader (paced to the pipeline phases) ---------- */
616
  let stepTimer = null;
617
  function setStep(n) {
618
+ document.querySelectorAll(".stepper .step").forEach(s => {
619
+ const i = +s.dataset.step;
620
+ s.classList.toggle("active", i <= n);
621
+ s.classList.toggle("current", i === n);
622
+ });
623
  document.querySelectorAll(".stepper .bar").forEach(b =>
624
  b.classList.toggle("fill", +b.dataset.bar <= n));
625
  }
626
+ function hideOnboard() { const o = $("dr-onboard"); if (o) o.style.display = "none"; }
627
  function startLoading() {
628
+ hideOnboard();
629
  $("dr-loading").classList.add("on");
630
  $("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
631
  $("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
 
635
  function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").classList.remove("on"); }
636
 
637
  /* ---------- render ---------- */
638
+ const REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
639
  let lastAlts = [], lastCats = [];
640
  function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
641
+ function enter(el) {
642
+ if (!el || REDUCE) return;
643
+ try { el.animate([{ transform: "translateY(10px)", opacity: 0.001 }, { transform: "none", opacity: 1 }],
644
+ { duration: 420, easing: "cubic-bezier(.34,1.56,.64,1)" }); } catch (e) {}
645
+ }
646
+ // Organic squash-and-pop on tap/click — springy overshoot, settles back.
647
+ function bounce(el) {
648
+ if (!el || REDUCE) return;
649
+ try {
650
+ el.animate(
651
+ [{ transform: "scale(1)" }, { transform: "scale(.90)" },
652
+ { transform: "scale(1.06)" }, { transform: "scale(.985)" }, { transform: "scale(1)" }],
653
+ { duration: 440, easing: "cubic-bezier(.34,1.56,.64,1)" });
654
+ } catch (e) {}
655
+ }
656
  function selectAlt(idx) {
657
  const a = lastAlts[idx]; if (!a) return;
658
  renderMap(a.map_html);
659
  $("dr-summary").innerHTML = md(a.summary_md);
660
  $("dr-itin").innerHTML = md(a.itinerary_md);
661
+ enter($("dr-summary")); enter($("dr-itin"));
662
  document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
663
  o.classList.toggle("selected", i === idx));
664
  }
665
  function renderResult(d) {
666
+ hideOnboard();
667
  if (d.error) { renderMap(d.map_html); toast("Hmm — " + d.error.split(".")[0] + "."); return; }
668
  $("dr-interp").innerHTML = md(d.interpretation_md);
669
+ enter($("dr-interp"));
670
  if (d.no_detour) {
671
  renderMap(d.map_html);
672
  $("dr-summary").innerHTML = md(d.summary_md);
 
729
  });
730
 
731
  /* ---------- mobile drawer ---------- */
732
+ const openDrawer = () => $("left-panel").classList.add("open");
733
+ const closeDrawer = () => $("left-panel").classList.remove("open");
734
+ $("fab").addEventListener("click", openDrawer);
735
+ $("drawer-close").addEventListener("click", closeDrawer);
736
+
737
+ /* ---------- organic bounce on interaction (delegated, capture phase) ---------- */
738
+ document.addEventListener("click", (e) => {
739
+ const el = e.target.closest(
740
+ "#dr-plan button, .vibe-chips .chip, #dr-mode label, #dr-options .opt, " +
741
+ ".dr-row button, .dr-star, details.dr-collapse > summary, #fab, .drawer-close");
742
+ if (el) bounce(el);
743
+ }, true);
744
+ $("drawer-close").addEventListener("keydown", (e) => {
745
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); closeDrawer(); }
746
+ });
747
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDrawer(); });
748
+
749
+ /* Enter in any text field plans the route (except while a suggestion list is open). */
750
+ ["dr-vibe", "dr-start", "dr-dest"].forEach(id => {
751
+ const el = $(id); if (!el) return;
752
+ el.addEventListener("keydown", (e) => {
753
+ if (e.key !== "Enter") return;
754
+ const lists = document.querySelectorAll(".combo-list.open");
755
+ if (lists.length) return; // let the combo handle its own Enter
756
+ e.preventDefault(); plan();
757
+ });
758
+ });
759
 
760
+ syncVibeChips();
761
  renderSaved();
762
  """