harshith99 Claude Sonnet 4.6 commited on
Commit
5c44de2
Β·
1 Parent(s): 23dabb1

UX: add guided tour, pipeline timing note

Browse files

- tour.js: 6-step spotlight tour (upload β†’ model β†’ run β†’ reimport β†’ roulette β†’ pipeline diagram); simulates tab navigation and temporarily reveals sub-tab-bar in demo mode; triggered by β–Ά Take the tour button in FAQ
- Progress card: static timing note ("usually 5–10 min β€” may appear stuck") so users don't abandon a running job
- app.css: tour overlay/spotlight/tooltip styles + .progress-timing-note + .tour-cta-card/.btn-tour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. CLAUDE.md +6 -3
  2. README.md +2 -1
  3. web/static/app.css +140 -0
  4. web/static/tour.js +213 -0
  5. web/templates/index.html +11 -1
CLAUDE.md CHANGED
@@ -32,9 +32,11 @@ pipeline/ shared core (used by CLI, Docker, web)
32
  web/app.py FastAPI: pipeline, SSE progress, results browser, resolve_provider()
33
  POST /import β€” KML or CSV re-import (no extraction, skip to tabs)
34
  web/static/ served at /static/ via FastAPI StaticFiles mount
35
- app.css all styles (~820 lines, no Jinja)
36
  app.js all client JS (~1280 lines, no Jinja β€” reads config from body data-* attrs)
37
- web/templates/index.html HTML skeleton only (~465 lines); Jinja vars passed to JS via
 
 
38
  <body data-ollama-enabled data-client-geocode data-default-model
39
  data-default-ollama-model>
40
  tests/ runnable scripts (NOT pytest) β€” see Conventions
@@ -58,7 +60,7 @@ The app has a **permanent two-level nav** visible from first page load:
58
  └── [🎲 Roulette] [πŸ“‹ Browse] ← #sub-tab-bar, after data loads
59
  ```
60
 
61
- - **Extract tab** (`#top-panel-extract`): upload card, model picker, run button, progress card.
62
  - **Explore tab** (`#top-panel-explore`): import card at top; once data loads the import card hides
63
  and `#sub-tab-bar` appears with two sub-panels.
64
  - After extraction completes β†’ `showTopTab('explore')` β†’ `showSubTabBar(jobId)` β†’ `showSubTab('roulette')` (auto-spins) + nudge toast.
@@ -83,6 +85,7 @@ Key JS functions:
83
  - `showSubTabBar(jobId)` β€” hides import card, reveals `#sub-tab-bar` + `#download-bar` inside Explore
84
  - `showSubTab(name)` β€” switches sub-panels (roulette / browse); auto-spins roulette on first visit
85
  - `updateTabBadges()` β€” sets badge counts and stat strip
 
86
 
87
  KML parser convention: folder nesting = country (outer) β†’ city (inner); `<coordinates>` order is
88
  **lng,lat,alt**; description CDATA uses `" Β· "` separator for category Β· cuisine Β· price.
 
32
  web/app.py FastAPI: pipeline, SSE progress, results browser, resolve_provider()
33
  POST /import β€” KML or CSV re-import (no extraction, skip to tabs)
34
  web/static/ served at /static/ via FastAPI StaticFiles mount
35
+ app.css all styles (~1150 lines, no Jinja)
36
  app.js all client JS (~1280 lines, no Jinja β€” reads config from body data-* attrs)
37
+ tour.js guided tour (IIFE, ~210 lines); exposes window.startTour / window.endTour;
38
+ requires app.js globals showTopTab, showSubTab, currentJobId
39
+ web/templates/index.html HTML skeleton only (~500 lines); Jinja vars passed to JS via
40
  <body data-ollama-enabled data-client-geocode data-default-model
41
  data-default-ollama-model>
42
  tests/ runnable scripts (NOT pytest) β€” see Conventions
 
60
  └── [🎲 Roulette] [πŸ“‹ Browse] ← #sub-tab-bar, after data loads
61
  ```
62
 
63
+ - **Extract tab** (`#top-panel-extract`): upload card, model picker, run button, progress card. The progress card includes a static `.progress-timing-note` ("usually 5–10 min β€” may appear stuck") so users don't abandon a running job.
64
  - **Explore tab** (`#top-panel-explore`): import card at top; once data loads the import card hides
65
  and `#sub-tab-bar` appears with two sub-panels.
66
  - After extraction completes β†’ `showTopTab('explore')` β†’ `showSubTabBar(jobId)` β†’ `showSubTab('roulette')` (auto-spins) + nudge toast.
 
85
  - `showSubTabBar(jobId)` β€” hides import card, reveals `#sub-tab-bar` + `#download-bar` inside Explore
86
  - `showSubTab(name)` β€” switches sub-panels (roulette / browse); auto-spins roulette on first visit
87
  - `updateTabBadges()` β€” sets badge counts and stat strip
88
+ - `startTour()` / `endTour()` β€” guided tour (defined in `tour.js`); works without data loaded by temporarily revealing the sub-tab-bar in demo mode and restoring state on exit. The FAQ tab has a `.tour-cta-card` with a `β–Ά Take the tour` button (`onclick="startTour()"`).
89
 
90
  KML parser convention: folder nesting = country (outer) β†’ city (inner); `<coordinates>` order is
91
  **lng,lat,alt**; description CDATA uses `" Β· "` separator for category Β· cuisine Β· price.
README.md CHANGED
@@ -307,8 +307,9 @@ The UI lets you:
307
  - **πŸ—Ί Explore** β€” for returning users; drop your `places_map.kml` or `places_full.csv` here to skip re-extraction entirely. Once data loads, two sub-tabs appear:
308
  - **🎲 Roulette** β€” auto-spins the moment you land on it; filter by category or location (accepts city name or country name); badge shows how many unvisited spots remain. Default after extraction
309
  - **πŸ“‹ Browse** β€” stats bar, interactive Leaflet map, full-text search, filters, card grid, and KML / GeoJSON / CSV download buttons. Default after import
310
- - **❓ FAQ** β€” system pipeline diagram, About The Belagurian, and answers to common questions
311
  - After extraction completes, the app automatically jumps to Explore β†’ Roulette and spins immediately
 
312
  - **Preview pins on a map** β€” an in-app Leaflet/OpenStreetMap map renders every geocoded place so you can sanity-check for misplaced pins before exporting. Follows your active filters; pins link back to the original reel
313
  - **Filter and search** β€” text search by name/city, country, category, and cuisine dropdowns, multi-select price range chips, "missing pins" toggle, and a **🍴 Food only** toggle (on by default) that limits Browse and Roulette to food/drink places (Restaurant, Bar, Cafe, Bakery, Market) β€” all client-side with instant results
314
  - **Fix extractions inline** β€” click any field (name, city, state, category, cuisine, occasion) directly on the card to edit it; changes are saved to the CSV immediately
 
307
  - **πŸ—Ί Explore** β€” for returning users; drop your `places_map.kml` or `places_full.csv` here to skip re-extraction entirely. Once data loads, two sub-tabs appear:
308
  - **🎲 Roulette** β€” auto-spins the moment you land on it; filter by category or location (accepts city name or country name); badge shows how many unvisited spots remain. Default after extraction
309
  - **πŸ“‹ Browse** β€” stats bar, interactive Leaflet map, full-text search, filters, card grid, and KML / GeoJSON / CSV download buttons. Default after import
310
+ - **❓ FAQ** β€” system pipeline diagram, About The Belagurian, and answers to common questions. Includes a **β–Ά Take the tour** button that runs a 6-step guided walkthrough of the full workflow (upload β†’ model β†’ run β†’ re-import β†’ roulette β†’ pipeline diagram), with spotlight highlighting and simulated tab navigation
311
  - After extraction completes, the app automatically jumps to Explore β†’ Roulette and spins immediately
312
+ - **Pipeline timing note** β€” the progress card shows a persistent note ("usually 5–10 min β€” the bar may appear stuck during AI extraction") so users don't think the app is broken
313
  - **Preview pins on a map** β€” an in-app Leaflet/OpenStreetMap map renders every geocoded place so you can sanity-check for misplaced pins before exporting. Follows your active filters; pins link back to the original reel
314
  - **Filter and search** β€” text search by name/city, country, category, and cuisine dropdowns, multi-select price range chips, "missing pins" toggle, and a **🍴 Food only** toggle (on by default) that limits Browse and Roulette to food/drink places (Restaurant, Bar, Cafe, Bakery, Market) β€” all client-side with instant results
315
  - **Fix extractions inline** β€” click any field (name, city, state, category, cuisine, occasion) directly on the card to edit it; changes are saved to the CSV immediately
web/static/app.css CHANGED
@@ -1010,3 +1010,143 @@
1010
  border-radius: 4px;
1011
  padding: 1px 5px;
1012
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  border-radius: 4px;
1011
  padding: 1px 5px;
1012
  }
1013
+
1014
+ /* ── Pipeline timing note ─────────────────────────────────── */
1015
+ .progress-timing-note {
1016
+ margin-top: 14px;
1017
+ padding: 10px 13px;
1018
+ background: var(--surface2);
1019
+ border: 1px solid var(--border);
1020
+ border-radius: 8px;
1021
+ font-size: 0.78rem;
1022
+ color: var(--muted);
1023
+ line-height: 1.55;
1024
+ }
1025
+ .progress-timing-note strong { color: var(--text); }
1026
+
1027
+ /* ── Tour overlay & spotlight ─────────────────────────────── */
1028
+ #tour-overlay {
1029
+ display: none;
1030
+ position: fixed;
1031
+ inset: 0;
1032
+ z-index: 10000;
1033
+ background: transparent;
1034
+ pointer-events: all;
1035
+ }
1036
+
1037
+ .tour-spotlight {
1038
+ position: relative !important;
1039
+ z-index: 10001 !important;
1040
+ box-shadow: 0 0 0 4px var(--ig1), 0 0 0 9999px rgba(0,0,0,0.62) !important;
1041
+ border-radius: 10px;
1042
+ }
1043
+
1044
+ /* ── Tour tooltip ─────────────────────────────────────────── */
1045
+ #tour-tooltip {
1046
+ display: none;
1047
+ position: fixed;
1048
+ z-index: 10002;
1049
+ flex-direction: column;
1050
+ gap: 10px;
1051
+ background: var(--surface);
1052
+ border: 1px solid var(--border);
1053
+ border-radius: 14px;
1054
+ box-shadow: 0 8px 32px rgba(0,0,0,0.45);
1055
+ padding: 18px 20px 14px;
1056
+ max-width: 320px;
1057
+ min-width: 260px;
1058
+ font-size: 0.88rem;
1059
+ line-height: 1.5;
1060
+ }
1061
+
1062
+ #tour-close {
1063
+ position: absolute;
1064
+ top: 10px; right: 12px;
1065
+ background: none;
1066
+ border: none;
1067
+ cursor: pointer;
1068
+ font-size: 1rem;
1069
+ color: var(--muted);
1070
+ padding: 2px 6px;
1071
+ border-radius: 4px;
1072
+ line-height: 1;
1073
+ }
1074
+ #tour-close:hover { background: var(--surface2); }
1075
+
1076
+ #tour-title {
1077
+ font-weight: 700;
1078
+ font-size: 0.95rem;
1079
+ padding-right: 24px;
1080
+ color: var(--text);
1081
+ }
1082
+
1083
+ #tour-body { color: var(--muted); }
1084
+ #tour-body code {
1085
+ font-family: monospace;
1086
+ font-size: 0.8rem;
1087
+ color: var(--text);
1088
+ background: var(--surface2);
1089
+ padding: 1px 5px;
1090
+ border-radius: 3px;
1091
+ }
1092
+ #tour-body strong, #tour-body em { color: var(--text); font-style: normal; }
1093
+
1094
+ #tour-footer {
1095
+ display: flex;
1096
+ align-items: center;
1097
+ justify-content: space-between;
1098
+ margin-top: 4px;
1099
+ }
1100
+ #tour-counter { font-size: 0.75rem; color: var(--muted); }
1101
+ #tour-btns { display: flex; gap: 8px; }
1102
+
1103
+ #tour-back, #tour-next, #tour-finish {
1104
+ font-size: 0.82rem;
1105
+ padding: 5px 13px;
1106
+ border-radius: 6px;
1107
+ border: 1px solid var(--border);
1108
+ cursor: pointer;
1109
+ background: var(--surface2);
1110
+ color: var(--text);
1111
+ transition: background 0.15s;
1112
+ }
1113
+ #tour-back:hover { background: var(--border); }
1114
+ #tour-next, #tour-finish {
1115
+ background: var(--ig1);
1116
+ color: #fff;
1117
+ border-color: var(--ig1);
1118
+ }
1119
+ #tour-next:hover, #tour-finish:hover { filter: brightness(1.12); }
1120
+
1121
+ /* ── Take the tour button in FAQ ──────────────────────────── */
1122
+ .tour-cta-card {
1123
+ max-width: 640px;
1124
+ display: flex;
1125
+ align-items: center;
1126
+ justify-content: space-between;
1127
+ gap: 16px;
1128
+ flex-wrap: wrap;
1129
+ }
1130
+ .tour-cta-card p {
1131
+ font-size: 0.88rem;
1132
+ color: var(--muted);
1133
+ flex: 1;
1134
+ min-width: 0;
1135
+ }
1136
+ .btn-tour {
1137
+ flex: 0 0 auto;
1138
+ display: inline-flex;
1139
+ align-items: center;
1140
+ gap: 7px;
1141
+ padding: 9px 18px;
1142
+ background: var(--ig1);
1143
+ color: #fff;
1144
+ border: none;
1145
+ border-radius: 8px;
1146
+ font-size: 0.88rem;
1147
+ font-weight: 600;
1148
+ cursor: pointer;
1149
+ white-space: nowrap;
1150
+ transition: filter 0.15s;
1151
+ }
1152
+ .btn-tour:hover { filter: brightness(1.12); }
web/static/tour.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Guided tour β€” triggered by "Take the tour" button in FAQ tab.
2
+ // Requires app.js globals: showTopTab, showSubTab, currentJobId.
3
+
4
+ (function () {
5
+ let _step = -1;
6
+ let _overlay = null;
7
+ let _tooltip = null;
8
+ let _spotlit = null;
9
+
10
+ // Explore state before demo mode (we temporarily show sub-tab-bar)
11
+ let _savedState = null;
12
+
13
+ function _saveExploreState() {
14
+ return {
15
+ subTabBar: document.getElementById('sub-tab-bar').style.display,
16
+ importCard: document.getElementById('import-card').style.display,
17
+ };
18
+ }
19
+
20
+ function _restoreExploreState() {
21
+ if (!_savedState) return;
22
+ document.getElementById('sub-tab-bar').style.display = _savedState.subTabBar;
23
+ document.getElementById('import-card').style.display = _savedState.importCard;
24
+ _savedState = null;
25
+ }
26
+
27
+ // ── Step definitions ───────────────────────────────────────────────────────
28
+ const STEPS = [
29
+ {
30
+ id: 'upload-card',
31
+ pre() { showTopTab('extract'); },
32
+ title: 'πŸ“‚ Upload your export',
33
+ body: 'Drop your Instagram ZIP or <code>saved_posts.json</code> here. No unzipping needed β€” the ZIP works as-is.',
34
+ },
35
+ {
36
+ id: 'model-card',
37
+ title: 'πŸ€– Choose your AI',
38
+ body: '<strong>Claude API</strong> is fast (bring your Anthropic key). <strong>Ollama</strong> is free and fully local. <strong>Free chatbot</strong> needs no API key β€” paste posts into ChatGPT or Claude.ai.',
39
+ },
40
+ {
41
+ id: 'run-btn-wrap',
42
+ title: 'πŸš€ Extract & geocode',
43
+ body: 'Hit <em>Extract places</em>. AI reads captions, OpenStreetMap geocodes each place, and a KML file is built β€” ready to import into Google My Maps.',
44
+ },
45
+ {
46
+ id: 'import-card',
47
+ pre() {
48
+ if (_savedState) {
49
+ // Restore before we navigate back to import card
50
+ const st = _savedState;
51
+ _savedState = null;
52
+ document.getElementById('sub-tab-bar').style.display = st.subTabBar;
53
+ document.getElementById('import-card').style.display = st.importCard;
54
+ }
55
+ showTopTab('explore');
56
+ },
57
+ title: 'πŸ“ Re-import a previous run',
58
+ body: 'Already have a <code>places_map.kml</code> or <code>places_full.csv</code>? Drop it here to skip extraction and jump straight to browsing β€” no re-running needed.',
59
+ },
60
+ {
61
+ id: 'roulette-section',
62
+ pre() {
63
+ if (!_savedState) _savedState = _saveExploreState();
64
+ document.getElementById('import-card').style.display = 'none';
65
+ document.getElementById('sub-tab-bar').style.display = 'block';
66
+ showSubTab('roulette');
67
+ },
68
+ title: '🎲 Place Roulette',
69
+ body: "Can't decide where to go? Spin the wheel for a random pick from your saved places. Filter by city, distance, or category.",
70
+ },
71
+ {
72
+ id: 'faq-pipeline-card',
73
+ pre() {
74
+ if (_savedState && !currentJobId) _restoreExploreState();
75
+ showTopTab('faq');
76
+ },
77
+ title: 'πŸ” Everything runs local-first',
78
+ body: 'Pre-filter β†’ AI extraction β†’ Nominatim geocoding β†’ KML / GeoJSON / CSV export. Your Instagram data never leaves your device (except the AI call you pick).',
79
+ last: true,
80
+ },
81
+ ];
82
+
83
+ // ── DOM setup ──────────────────────────────────────────────────────────────
84
+ function _ensureDOM() {
85
+ if (_overlay) return;
86
+
87
+ _overlay = document.createElement('div');
88
+ _overlay.id = 'tour-overlay';
89
+ document.body.appendChild(_overlay);
90
+ _overlay.addEventListener('click', endTour);
91
+
92
+ _tooltip = document.createElement('div');
93
+ _tooltip.id = 'tour-tooltip';
94
+ _tooltip.innerHTML =
95
+ '<button id="tour-close" title="Exit tour">βœ•</button>' +
96
+ '<div id="tour-title"></div>' +
97
+ '<div id="tour-body"></div>' +
98
+ '<div id="tour-footer">' +
99
+ '<span id="tour-counter"></span>' +
100
+ '<div id="tour-btns">' +
101
+ '<button id="tour-back">← Back</button>' +
102
+ '<button id="tour-next">Next β†’</button>' +
103
+ '<button id="tour-finish">Finish βœ“</button>' +
104
+ '</div>' +
105
+ '</div>';
106
+ document.body.appendChild(_tooltip);
107
+
108
+ document.getElementById('tour-close').addEventListener('click', endTour);
109
+ document.getElementById('tour-back').addEventListener('click', () => goToStep(_step - 1));
110
+ document.getElementById('tour-next').addEventListener('click', () => goToStep(_step + 1));
111
+ document.getElementById('tour-finish').addEventListener('click', endTour);
112
+ }
113
+
114
+ // ── Spotlight ──────────────────────────────────────────────────────────────
115
+ function _applySpotlight(el) {
116
+ _clearSpotlight();
117
+ _spotlit = el;
118
+ el.classList.add('tour-spotlight');
119
+ }
120
+
121
+ function _clearSpotlight() {
122
+ if (_spotlit) { _spotlit.classList.remove('tour-spotlight'); _spotlit = null; }
123
+ }
124
+
125
+ // ── Tooltip positioning ────────────────────────────────────────────────────
126
+ function _positionTooltip(el) {
127
+ const tt = _tooltip;
128
+ tt.style.visibility = 'hidden';
129
+ tt.style.display = 'flex';
130
+
131
+ requestAnimationFrame(() => {
132
+ requestAnimationFrame(() => {
133
+ const rect = el.getBoundingClientRect();
134
+ const ttH = tt.offsetHeight;
135
+ const ttW = tt.offsetWidth;
136
+ const vW = window.innerWidth;
137
+ const vH = window.innerHeight;
138
+ const GAP = 14;
139
+ const EDGE = 12;
140
+
141
+ let top, left;
142
+
143
+ if (rect.bottom + GAP + ttH <= vH - EDGE) {
144
+ // Below target
145
+ top = rect.bottom + GAP;
146
+ left = rect.left + rect.width / 2 - ttW / 2;
147
+ } else if (rect.top - GAP - ttH >= EDGE) {
148
+ // Above target
149
+ top = rect.top - ttH - GAP;
150
+ left = rect.left + rect.width / 2 - ttW / 2;
151
+ } else {
152
+ // Centre of viewport
153
+ top = (vH - ttH) / 2;
154
+ left = (vW - ttW) / 2;
155
+ }
156
+
157
+ left = Math.max(EDGE, Math.min(left, vW - ttW - EDGE));
158
+ top = Math.max(EDGE, Math.min(top, vH - ttH - EDGE));
159
+
160
+ tt.style.top = top + 'px';
161
+ tt.style.left = left + 'px';
162
+ tt.style.visibility = 'visible';
163
+ });
164
+ });
165
+ }
166
+
167
+ // ── Navigation ─────────────────────────────────────────────────────────────
168
+ function goToStep(n) {
169
+ if (n < 0 || n >= STEPS.length) return;
170
+ _step = n;
171
+ const s = STEPS[n];
172
+
173
+ if (s.pre) s.pre();
174
+
175
+ const el = document.getElementById(s.id);
176
+ if (!el) return;
177
+
178
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
179
+
180
+ setTimeout(() => {
181
+ _applySpotlight(el);
182
+ _overlay.style.display = 'block';
183
+
184
+ document.getElementById('tour-title').textContent = s.title;
185
+ document.getElementById('tour-body').innerHTML = s.body;
186
+ document.getElementById('tour-counter').textContent = (n + 1) + ' / ' + STEPS.length;
187
+
188
+ document.getElementById('tour-back').style.display = n === 0 ? 'none' : '';
189
+ document.getElementById('tour-next').style.display = s.last ? 'none' : '';
190
+ document.getElementById('tour-finish').style.display = s.last ? '' : 'none';
191
+
192
+ _positionTooltip(el);
193
+ }, 280);
194
+ }
195
+
196
+ // ── Public API ─────────────────────────────────────────────────────────────
197
+ function startTour() {
198
+ _ensureDOM();
199
+ _savedState = null;
200
+ goToStep(0);
201
+ }
202
+
203
+ function endTour() {
204
+ _clearSpotlight();
205
+ if (_overlay) _overlay.style.display = 'none';
206
+ if (_tooltip) _tooltip.style.display = 'none';
207
+ if (_savedState && !currentJobId) _restoreExploreState();
208
+ _step = -1;
209
+ }
210
+
211
+ window.startTour = startTour;
212
+ window.endTour = endTour;
213
+ })();
web/templates/index.html CHANGED
@@ -205,6 +205,9 @@
205
  </div>
206
  </div>
207
  <div class="status-msg" id="status-msg"></div>
 
 
 
208
  </div>
209
 
210
  </div><!-- /top-panel-extract -->
@@ -378,8 +381,14 @@
378
  <!-- ══ FAQ / About panel ═══════════════════════════════════════ -->
379
  <div class="top-panel" id="top-panel-faq">
380
 
 
 
 
 
 
 
381
  <!-- System diagram -->
382
- <div class="card" style="max-width:640px;">
383
  <p class="card-title">How it works</p>
384
  <div class="pipeline-diagram">
385
  <div class="pipe-node">
@@ -488,5 +497,6 @@
488
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
489
  integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
490
  <script src="/static/app.js"></script>
 
491
  </body>
492
  </html>
 
205
  </div>
206
  </div>
207
  <div class="status-msg" id="status-msg"></div>
208
+ <p class="progress-timing-note">
209
+ <strong>⏱ This usually takes 5–10 min.</strong> The progress bar may appear stuck while the AI reads your captions in bulk β€” it's still running. Keep this tab open.
210
+ </p>
211
  </div>
212
 
213
  </div><!-- /top-panel-extract -->
 
381
  <!-- ══ FAQ / About panel ═══════════════════════════════════════ -->
382
  <div class="top-panel" id="top-panel-faq">
383
 
384
+ <!-- Tour CTA -->
385
+ <div class="card tour-cta-card">
386
+ <p>New here? Take a 60-second walkthrough of the full workflow β€” upload, extract, roulette, and browse.</p>
387
+ <button class="btn-tour" onclick="startTour()">β–Ά Take the tour</button>
388
+ </div>
389
+
390
  <!-- System diagram -->
391
+ <div class="card" id="faq-pipeline-card" style="max-width:640px;">
392
  <p class="card-title">How it works</p>
393
  <div class="pipeline-diagram">
394
  <div class="pipe-node">
 
497
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
498
  integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
499
  <script src="/static/app.js"></script>
500
+ <script src="/static/tour.js"></script>
501
  </body>
502
  </html>