Coverage for scanpath_studio/tour.py: 90%

536 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""First-visit welcome tour. 

2 

3Two interchangeable styles, both introducing the app's main surfaces (data 

4sources, filters, viz controls, tabs, annotations) the first time a session 

5opens the app, re-playable any time from the ❓ Help menu's tutorial button: 

6 

7- ``"spotlight"`` (default): a floating card that walks through the *actual* 

8 UI — each step scrolls the target section into view and pulses an outline 

9 around it. Rendered by ``render_spotlight_tour()`` at the end of ``main()``. 

10- ``"dialog"``: a self-contained multi-step ``st.dialog`` modal. 

11 

12Switch styles with the ``TOUR_STYLE`` constant below. 

13 

14Mechanics worth knowing before editing: 

15 

16- Both styles run as *fragments*: Back/Next clicks rerun only the tour body, 

17 so navigation is instant instead of waiting for a full-app rerun (which 

18 re-renders the heavy plot embeds, ~10 s). The spotlight's Done / ✕ just 

19 clear ``tour_mode`` — the fragment then renders nothing and the card + 

20 highlight CSS disappear with it, again with no full rerun. The dialog's 

21 Skip/Done close the modal client-side (``_close_dialog_clientside``). 

22- The spotlight card streams to the browser *before* the heavy first-load 

23 work, but its Done / ✕ are ordinary Streamlit buttons — a click only 

24 schedules a rerun, which can't run until that ~10 s load finishes, so the 

25 card + dimming backdrop would linger the whole time. A same-origin listener 

26 (``_dismiss_listener_script``) hides them instantly on click; the button's 

27 native click still clears ``tour_mode`` once Streamlit catches up. 

28- Spotlight targets are ``.st-key-tour_grp_*`` classes from keyed wrapper 

29 containers around the page + menu sections (app.py / controls.py / 

30 annotations.py) plus Streamlit's stable ``data-testid``/``data-baseweb`` 

31 attributes for the tab strip. Keep ``_SPOTLIGHT_STEPS`` in sync with them. 

32- ``tour_seen`` is set **before** the tour is shown, not when it's finished. 

33 For the dialog style, setting it on Done only would make an X-dismissal 

34 re-open the modal on the very next widget interaction (any full rerun 

35 re-calls ``maybe_show_welcome_tour``), making the X appear broken. 

36- The tour is suppressed for embeds (``?embed=true``) and deep links 

37 (``?source=…&participant=…``): those sessions arrive mid-workflow from an 

38 external tool and shouldn't be greeted by a tutorial. 

39- **"Don't show this again" (UX-12)** persists in a first-party *cookie*, not 

40 session state — there are no user accounts, and session state dies with the 

41 tab. A cookie is the one browser-side store Python can also *read* 

42 (``st.context.cookies``); ``localStorage`` would need a bidirectional custom 

43 component to get the value back to the server. The checkbox writes it via a 

44 same-origin script (``_tour_optout_script``); ``tour_opted_out()`` reads it. 

45 The replay button ignores the opt-out entirely, so the tour is never lost. 

46- **The FAQ (UX-15)** is the other half of the ❓ Help menu group: a short 

47 ``st.dialog`` of recurring questions (``_faq_dialog``), deliberately kept to a 

48 handful of answers with the complete version on the docs site 

49 (``docs/faq.md``). It is armed exactly like the tour — the ❓ Help nav entry 

50 (``menu._arm_help_action``) calls ``_arm_faq``, which sets a request flag that 

51 ``maybe_show_faq`` serves early in ``main()``, so the modal never waits out the 

52 rest of the rerun (~10 s of plot embeds) before appearing. 

53""" 

54 

55from __future__ import annotations 

56 

57from dataclasses import dataclass 

58 

59import streamlit as st 

60 

61from scanpath_studio.crash_report import guarded 

62from scanpath_studio.html_embed import embed_html_iframe 

63from scanpath_studio.menu import NAV_SELECTOR 

64 

65from .constants import ( 

66 _VIEW_CORPUS, 

67 _VIEW_DATA, 

68 _VIEW_SCANPATH, 

69 CITATION, 

70 ICONS, 

71 SUBTAB_ANNOTATIONS, 

72 SUBTAB_COMPARISONS, 

73 SUBTAB_EXPORT, 

74 SUBTAB_SHARE, 

75 drift_correction_enabled, 

76 preprocessing_enabled, 

77 similarity_enabled, 

78 spoken, 

79) 

80 

81# UX-12: name of the first-party cookie holding the "don't show the welcome tour 

82# again" opt-out ("1" = opted out). One year, path=/, SameSite=Lax — no personal 

83# data, just a UI preference. 

84TOUR_OPTOUT_COOKIE = "sps_tour_optout" 

85_TOUR_OPTOUT_MAX_AGE = 365 * 24 * 60 * 60 

86 

87# First-entry tutorial style: "spotlight" (floating card pointing at the real 

88# UI) or "dialog" (self-contained modal walkthrough). Both stay available in 

89# code; this only picks which one auto-opens / the replay button launches. 

90TOUR_STYLE = "spotlight" 

91 

92# UX-101: the trial filters live behind one funnel since UX-64, and a *closed* 

93# Streamlit popover renders no body — so `tour_grp_narrow_by` is absent from the 

94# page until the funnel is clicked, and a step aimed at it silently spotlighted 

95# nothing. A step that points inside a popover names the trigger that opens it 

96# here, and the spotlight opens it (see `_popover_script`). 

97# 

98# Two selectors because the picker row has two layouts: `select_trial` drops the 

99# slider for a pool of one and gives the funnel its own trailing column 

100# (`railbtn_single_filter_solo`) — exactly the case a filter *causes*, so the 

101# tour must reach it there too. 

102FUNNEL_TRIGGER = ( 

103 ".st-key-railbtn_single_filter button, .st-key-railbtn_single_filter_solo button" 

104) 

105 

106 

107@dataclass(frozen=True) 

108class TutorialStep: 

109 """One reusable spotlight step in a task-oriented tutorial.""" 

110 

111 title: str 

112 body: str 

113 selector: str 

114 view: str = _VIEW_SCANPATH 

115 subtab: str | None = None 

116 #: PERF-9 made the Corpus Analysis subtabs lazy (only the open one renders), 

117 #: so a step aimed at a control inside one has to open it, as ``subtab`` 

118 #: does for the Scanpath view. A label from ``tabs.CORPUS_SUBTABS``. 

119 corpus_subtab: str | None = None 

120 #: DATA-35 — the step's target lives on the 🗂️ Data page's **✏️ Edit dataset** 

121 #: screen rather than its overview, so opening the view is not enough: the 

122 #: editor has to be raised too, or the spotlight aims at a hidden container. 

123 dataset_editor: bool = False 

124 #: UX-101 — the step's target is built inside a popover, so it is not in the 

125 #: page until that popover's trigger is clicked. The selector of the trigger; 

126 #: the spotlight opens it before it looks for the target (`_popover_script`). 

127 popover: str = "" 

128 optional: bool = False 

129 #: PRE-22 — a step about a feature this build may not show. ``"preprocessing"`` 

130 #: is the only value so far; a step whose gate is closed is dropped from the 

131 #: tutorial entirely (`steps_of`), so it never spotlights a surface that is 

132 #: not there or counts toward "step 3 of 7". 

133 gate: str | None = None 

134 

135 

136@dataclass(frozen=True) 

137class TutorialDefinition: 

138 """Registry entry shown by the Help chooser.""" 

139 

140 id: str 

141 title: str 

142 outcome: str 

143 estimated_time: str 

144 prerequisite: str 

145 availability: str 

146 completion_test: str 

147 docs_url: str 

148 steps: tuple[TutorialStep, ...] 

149 

150 

151# Each tutorial's "Matching written tutorial" button opens the docs page that 

152# covers the same task (ENG-88), not a copy of these steps. 

153DOCS_URL = CITATION["docs_url"] 

154DOCS_TUTORIALS_URL = f"{DOCS_URL}tutorials/" 

155 

156# PRE-21 hides NLD similarity scoring unless SCANPATH_EXPERIMENTAL=1, so the 

157# comparison tutorial must not promise a ranking this build does not show — 

158# the same honesty rule `faq_items()` applies to the drift-correction answers. 

159# The flag is an environment variable, fixed for the life of the process, so 

160# resolving it once at import is equivalent to checking it per render. 

161_SIMILARITY_SENTENCE = ( 

162 " Similarity scores (NLD) rank the closest trials for you." 

163 if similarity_enabled() 

164 else "" 

165) 

166 

167TUTORIALS: tuple[TutorialDefinition, ...] = ( 

168 TutorialDefinition( 

169 id="load_inspect", 

170 title="Load and verify a dataset", 

171 outcome="Finish with the parsed words, fixations, and mapping visibly checked.", 

172 estimated_time="3 min", 

173 prerequisite="A demo or uploaded dataset", 

174 availability="always", 

175 completion_test="Data page opened", 

176 docs_url=f"{DOCS_URL}guides/loading-data/", 

177 steps=( 

178 TutorialStep( 

179 "Choose the dataset", 

180 f"Everything about the dataset lives on the {ICONS['view_data']} **Data Management** page, in the " 

181 "order the pipeline uses it. Start at the list of datasets — " 

182 "click a row to open it, or **+ Add dataset** for your own tables.", 

183 ".st-key-tutorial_available_datasets", 

184 view=_VIEW_DATA, 

185 ), 

186 TutorialStep( 

187 "Check the column mapping", 

188 "**Edit dataset**, under its overview, opens its setup screen — " 

189 "the add screen's parts, for a dataset that exists. Part **2 · " 

190 "Data tables & column mapping** decides which columns everything " 

191 "reads. Rows marked ✨ were auto-detected; override any that " 

192 "guessed wrong.", 

193 ".st-key-tutorial_column_mapping", 

194 view=_VIEW_DATA, 

195 dataset_editor=True, 

196 ), 

197 TutorialStep( 

198 "Verify what was parsed", 

199 f"**{ICONS['search']} What's in the … dataset** opens on {ICONS['stats']} Stats — the " 

200 "counts and their spread, the quickest check that the mapping " 

201 "worked. The six raw tables and its annotations are the tabs " 

202 "beside it.", 

203 ".st-key-tutorial_data_inspection", 

204 view=_VIEW_DATA, 

205 ), 

206 TutorialStep( 

207 "Check one trial id is one reading", 

208 "Part **4 · Trial identity** checks the whole dataset, before any " 

209 "filtering, and says so either way. A warning here means the " 

210 "Trial ID above is missing a column — several readings are being " 

211 "drawn as one scanpath, which renders happily as a reading with a " 

212 "lot of regressions.", 

213 ".st-key-tutorial_trial_identity", 

214 view=_VIEW_DATA, 

215 dataset_editor=True, 

216 ), 

217 TutorialStep( 

218 "Decide on preprocessing", 

219 f"**Preprocessing**, the last part of the {ICONS['edit']} Edit screen, can " 

220 "soft-exclude or merge short fixations before anything is " 

221 "measured. It is off by default, applies to every view, and " 

222 "never discards your original rows.", 

223 ".st-key-tutorial_preprocessing", 

224 view=_VIEW_DATA, 

225 dataset_editor=True, 

226 optional=True, 

227 gate="preprocessing", 

228 ), 

229 ), 

230 ), 

231 TutorialDefinition( 

232 id="filter_annotate", 

233 title="Filter and mark trials", 

234 outcome="Finish with reviewed trials starred or tagged, and exported.", 

235 estimated_time="4 min", 

236 prerequisite="At least one trial", 

237 availability="has_trials", 

238 completion_test="Export panel reached after annotation review", 

239 docs_url=f"{DOCS_TUTORIALS_URL}data-filtering/", 

240 steps=( 

241 TutorialStep( 

242 "Narrow the review pool", 

243 "The funnel beside the trial picker — opened for you here — " 

244 "holds the text and participant pickers at the top (*All texts* " 

245 "/ *All participants* until you narrow), then condition and " 

246 "annotation filters (favorites, tags). This tutorial only " 

247 "points; it never changes a filter.", 

248 ".st-key-tour_grp_narrow_by", 

249 popover=FUNNEL_TRIGGER, 

250 ), 

251 TutorialStep( 

252 "Review one trial at a time", 

253 "One picker for every dataset: the **Select trial** dropdown, a scrubbing " 

254 "slider showing *index / total*, and ◀ ▶ to step through the pool " 

255 "you just narrowed.", 

256 ".st-key-tour_grp_trial_picker", 

257 ), 

258 TutorialStep( 

259 "Move between screens", 

260 "A multipart trial (one reading spread over several screens) adds a " 

261 "screen picker at the right end of the trial row. Each screen is its own " 

262 "coordinate space, so nothing is ever drawn across two of them.", 

263 ".st-key-tour_grp_screen_picker", 

264 optional=True, 

265 ), 

266 TutorialStep( 

267 "Annotate at the right scope", 

268 "Star, tag, or note the parent trial. Multipart data can instead attach " 

269 "a separate annotation to the active screen.", 

270 ".st-key-tutorial_annotations", 

271 subtab=SUBTAB_ANNOTATIONS, 

272 ), 

273 TutorialStep( 

274 "Export the marked result", 

275 "Open **Export** and choose the filtered scope and tabular files. " 

276 "Screen identity is retained in multipart exports.", 

277 ".st-key-tutorial_export", 

278 subtab=SUBTAB_EXPORT, 

279 ), 

280 ), 

281 ), 

282 TutorialDefinition( 

283 id="publication_figure", 

284 title="Build a publication figure", 

285 outcome="Finish at a ready figure download with a reproducible configuration.", 

286 estimated_time="4 min", 

287 prerequisite="Words or fixations for a selected trial", 

288 availability="has_visual_data", 

289 completion_test="Export panel reached", 

290 docs_url=f"{DOCS_TUTORIALS_URL}exporting-figures/", 

291 steps=( 

292 TutorialStep( 

293 "Choose the visual language", 

294 "Use design presets, palette, and the layer controls. Heatmap and scanpath " 

295 "are settings on the same figure, not separate data transformations.", 

296 ".st-key-tour_grp_viz_controls", 

297 ), 

298 TutorialStep( 

299 "Decide static or animated", 

300 "Use **Animate** only when motion is the outcome. Multipart replay keeps " 

301 "screen boundaries explicit and draws no connector between canvases.", 

302 ".st-key-tour_grp_view_modes", 

303 ), 

304 TutorialStep( 

305 "Download and preserve settings", 

306 "Open **Export** for this figure (PNG, SVG, PDF, HTML) or a bundle " 

307 "of many. Keep the settings file in a bundle so it can be redrawn.", 

308 ".st-key-tutorial_export", 

309 subtab=SUBTAB_EXPORT, 

310 ), 

311 TutorialStep( 

312 "Keep the figure reproducible", 

313 f"**{ICONS['share']} Share** turns the exact configuration into a **Link**, the " 

314 "**Code** that redraws it, or a settings **File**. Any of them " 

315 "reproduces this figure later — the PNG on its own does not.", 

316 ".st-key-tutorial_share", 

317 subtab=SUBTAB_SHARE, 

318 optional=True, 

319 ), 

320 ), 

321 ), 

322 TutorialDefinition( 

323 id="compare_readings", 

324 title="Compare trials of one text", 

325 outcome=( 

326 "Finish with the other trials of one text side by side, at one scale." 

327 ), 

328 estimated_time="3 min", 

329 prerequisite="Two trials sharing a text (and screen for multipart data)", 

330 availability="has_comparable_readings", 

331 completion_test="Comparisons panel reached", 

332 docs_url=f"{DOCS_URL}guides/scanpath-visualization/#replay-and-compare", 

333 steps=( 

334 TutorialStep( 

335 "Choose the reference trial", 

336 "Pick the trial that should anchor the comparison. The comparison " 

337 "panel reuses this exact parent trial and active screen.", 

338 ".st-key-tour_grp_trial_picker", 

339 ), 

340 TutorialStep( 

341 "Compare like with like", 

342 f"Open **{ICONS['comparisons']} Comparisons** and set **Match field** to the text id: " 

343 "the grid shows the other trials that share this trial's value in " 

344 "that field — here, the other trials of *this* text — at one " 

345 "scale." + _SIMILARITY_SENTENCE, 

346 ".st-key-tutorial_comparisons", 

347 subtab=SUBTAB_COMPARISONS, 

348 ), 

349 ), 

350 ), 

351 TutorialDefinition( 

352 id="explore_corpus", 

353 title="Explore a corpus question", 

354 outcome=( 

355 "Finish having answered one worked question — how a participant's average " 

356 "fixation duration moved across the experiment." 

357 ), 

358 estimated_time="4 min", 

359 prerequisite="Variation across trials, participants, or texts", 

360 availability="has_corpus_variation", 

361 completion_test="Corpus Analysis opened", 

362 docs_url=f"{DOCS_TUTORIALS_URL}corpus-analysis/", 

363 # UX-40 round 2: this was two steps where the others are four or five, 

364 # and it named the three subtabs without answering anything. It now walks 

365 # one real question end to end — the user's own example — because "here 

366 # are three subtabs" is a menu, not a tutorial. 

367 steps=( 

368 TutorialStep( 

369 "Switch analysis level", 

370 "Open **Corpus Analysis** to aggregate instead of inspecting one trial. " 

371 "Your loaded data and filters stay in place, so whatever you narrowed " 

372 "to on the Scanpath view is what gets aggregated here.", 

373 NAV_SELECTOR, 

374 ), 

375 TutorialStep( 

376 "Pick the question, not the chart", 

377 "Each subtab answers one shape of question: **Per text** (one text, " 

378 "many participants), **Per participant** (one participant, all their " 

379 "trials) and **Groups** (a cohort, or two compared). Our question — " 

380 "*did this participant speed up over the experiment?* — is " 

381 "**Per participant**.", 

382 ".st-key-tutorial_corpus_subtabs", 

383 view=_VIEW_CORPUS, 

384 ), 

385 TutorialStep( 

386 "Choose the participant and the view", 

387 "Pick the participant on the left, then set **View** to **Per-trial trend**. " 

388 "That plots one point per trial in presentation order — the whole " 

389 "experiment on one axis, rather than a single trial's dynamics.", 

390 ".st-key-tutorial_per_reader_view", 

391 view=_VIEW_CORPUS, 

392 corpus_subtab="Per participant", 

393 ), 

394 TutorialStep( 

395 "Read average fixation duration across the experiment", 

396 "Set the measure to **fixation duration**; each point is that trial's " 

397 "mean. A downward slope is the participant settling in — but check the " 

398 "spread and the trial count before believing it, because one short " 

399 "trial moves a mean a long way.", 

400 ".st-key-tutorial_corpus_analysis", 

401 view=_VIEW_CORPUS, 

402 ), 

403 TutorialStep( 

404 "Put it against the cohort", 

405 "**Distribution vs cohort** answers the companion question — is this " 

406 "participant unusual, or is the whole cohort like this? Read the sample size " 

407 "with the effect, never the plotted mean on its own.", 

408 ".st-key-tutorial_per_reader_view", 

409 corpus_subtab="Per participant", 

410 view=_VIEW_CORPUS, 

411 optional=True, 

412 ), 

413 ), 

414 ), 

415) 

416 

417_TUTORIAL_BY_ID = {tutorial.id: tutorial for tutorial in TUTORIALS} 

418 

419#: PRE-22 — which builds show a gated step. Read at *call* time, not at import, 

420#: so a test (or a session started with the env var) sees the current answer. 

421_STEP_GATES = {"preprocessing": preprocessing_enabled} 

422 

423 

424def steps_of(tutorial: TutorialDefinition) -> tuple[TutorialStep, ...]: 

425 """The tutorial's steps this build can honestly walk (PRE-22). 

426 

427 A step about a feature held back from the release is dropped rather than 

428 shown-and-skipped: it would otherwise spotlight a surface that does not 

429 exist, and count toward "step 3 of 7" for something the reader cannot do. 

430 """ 

431 return tuple( 

432 step 

433 for step in tutorial.steps 

434 if step.gate is None or _STEP_GATES.get(step.gate, lambda: True)() 

435 ) 

436 

437 

438# (title, markdown body) per step — keep bodies to a few lines each; the tour 

439# should take well under a minute. 

440_STEPS = [ 

441 ( 

442 f"{ICONS['app']} Welcome to Scanpath Studio", 

443 "Visualize **eye movements in reading** — scanpaths drawn true-to-scale " 

444 "over the text. This tour takes under a minute.", 

445 ), 

446 ( 

447 f"{ICONS['datasets']} Data Management", 

448 "Use the demo, or **upload your own** fixations / word tables " 

449 "(CSV / TSV / Parquet). Columns auto-detect — remap any field in the wizard.", 

450 ), 

451 ( 

452 f"{ICONS['trial_filter']} Filter trials", 

453 "Narrow trials by participant or condition. Each tab has its own trial picker.", 

454 ), 

455 ( 

456 f"{ICONS['plot_controls']} Plot controls", 

457 "Toggle and style every layer — fixations, saccades, heatmap, word boxes, " 

458 f"text. The monitor is set in {ICONS['view_data']} **Edit dataset → Recording " 

459 "setup**, so it stays true-to-scale.", 

460 ), 

461 ( 

462 f"{ICONS['views']} Three views", 

463 "**Scanpath** (tick *Animate* to replay) · **Corpus Analysis** · " 

464 "**Data Management** (set up and inspect the dataset). Bulk export is the " 

465 "**Export** subtab in Scanpath.", 

466 ), 

467 ( 

468 f"{ICONS['annotations']} Annotate & save", 

469 f"Star, tag, and note trials, then filter to them. **{ICONS['view_data']} Data Management → " 

470 "Annotations** exports them as JSON. Replay this via " 

471 "**Tutorials → Welcome tour**. 👀", 

472 ), 

473] 

474 

475 

476def _close_dialog_clientside() -> None: 

477 """Hide the open dialog instantly by clicking its own ✕ from a tiny script. 

478 

479 The documented way to close a dialog programmatically is a full-app 

480 ``st.rerun()`` — but on this app a full rerun re-renders the heavy plot 

481 embeds, so the modal lingered ~10 s after Skip/Done. Instead, run inside 

482 the (fast) dialog-fragment rerun and click the dialog's close button: 

483 the modal hides client-side immediately and Streamlit's normal dismiss 

484 handling syncs state in the background. ``st.iframe`` embeds are 

485 same-origin, so the script can reach the parent document. 

486 """ 

487 embed_html_iframe( 

488 """<script> 

489 window.parent.document 

490 .querySelector('div[role="dialog"] button[aria-label="Close"]') 

491 ?.click(); 

492 </script>""", 

493 height=0, 

494 ) 

495 

496 

497def tour_opted_out() -> bool: 

498 """True when this browser asked never to be shown the welcome tour again. 

499 

500 Reads the ``sps_tour_optout`` cookie (UX-12). Within a session 

501 ``_tour_dismissed`` — a plain data flag, not any checkbox's own widget 

502 key (UX-110) — wins, so ticking either the tour's own checkbox or the 🧭 

503 Tutorials picker's copy of it takes effect immediately rather than 

504 waiting for the browser to hand the cookie back on the next load. The 

505 legacy ``tour_dont_show`` key (the tour's own checkbox, or a value parked 

506 there directly before either checkbox has rendered) is still honoured as 

507 a fallback, so setting it directly — as tests, and any future caller that 

508 predates UX-110, do — keeps working. Defensive about ``st.context`` 

509 because bare-mode / AppTest runs have no request behind them. 

510 """ 

511 if "_tour_dismissed" in st.session_state: 

512 return bool(st.session_state["_tour_dismissed"]) 

513 if "tour_dont_show" in st.session_state: 

514 return bool(st.session_state["tour_dont_show"]) 

515 try: 

516 return st.context.cookies.get(TOUR_OPTOUT_COOKIE) == "1" 

517 except Exception: # no request context (bare mode, AppTest, headless import) 

518 return False 

519 

520 

521def _tour_optout_script(opted_out: bool) -> str: 

522 """A same-origin script that writes (or clears) the opt-out cookie. 

523 

524 ``st.iframe`` embeds share the app's origin, so the parent document's 

525 ``cookie`` is writable from here — the only way to persist a preference 

526 browser-side without a user account. 

527 """ 

528 if opted_out: 

529 value = f"{TOUR_OPTOUT_COOKIE}=1; max-age={_TOUR_OPTOUT_MAX_AGE}" 

530 else: 

531 value = f"{TOUR_OPTOUT_COOKIE}=; max-age=0" 

532 return ( 

533 "<script>window.parent.document.cookie = " 

534 f'"{value}; path=/; SameSite=Lax";</script>' 

535 ) 

536 

537 

538def _render_tour_optout(host=st, *, key_suffix: str = "") -> None: 

539 """The "Don't show this again" checkbox + the cookie write that backs it. 

540 

541 Two call sites (UX-110): the welcome tour's own first/last step 

542 (``host=st``, ``key_suffix=""`` — the original ``tour_dont_show`` key, 

543 unchanged, so existing links/tests keep working) and the 🧭 Tutorials 

544 picker's Welcome tour card (``host=<that card's container>``, 

545 ``key_suffix="_picker"``). Both can be on screen in the same run — the 

546 picker can be reopened while the tour it started is still settling onto 

547 screen — so they need distinct widget keys, synced as a pair: seeded from 

548 :func:`tour_opted_out` only when nothing has touched *this* particular 

549 checkbox since it last agreed with that shared truth (``_synced_key``), 

550 not via ``value=``, which Streamlit only consults before a widget's key 

551 first exists. 

552 

553 Whichever is ticked writes ``_tour_dismissed`` — a plain data flag, 

554 deliberately never a widget's own key — rather than the other checkbox's 

555 ``tour_dont_show``/``tour_dont_show_picker`` key directly: Streamlit 

556 refuses `st.session_state[key] = ...` for any key whose widget has 

557 already been instantiated *anywhere* in the current run, and both 

558 checkboxes CAN render in the same run (see above), so writing into the 

559 other one's own key would raise the moment that happens. `tour_opted_out` 

560 reads `_tour_dismissed` first, which is what makes either checkbox's 

561 click visible to the auto-open gate and to the other checkbox. 

562 """ 

563 key = f"tour_dont_show{key_suffix}" 

564 synced_key = f"_{key}_synced" 

565 shared_truth = tour_opted_out() 

566 if key not in st.session_state or ( 

567 st.session_state.get(synced_key) == st.session_state[key] 

568 and st.session_state[key] != shared_truth 

569 ): 

570 st.session_state[key] = shared_truth 

571 opted_out = host.checkbox( 

572 "Don't show this again", 

573 key=key, 

574 help="Skip the tour on future visits. **Tutorials → Welcome tour** under " 

575 f"{ICONS['help']} Help always brings it back.", 

576 ) 

577 st.session_state[synced_key] = opted_out 

578 st.session_state["_tour_dismissed"] = opted_out 

579 embed_html_iframe(_tour_optout_script(opted_out), height=0) 

580 

581 

582def _step_back() -> None: 

583 st.session_state["tour_step"] = max(0, st.session_state.get("tour_step", 0) - 1) 

584 

585 

586def _step_next() -> None: 

587 st.session_state["tour_step"] = st.session_state.get("tour_step", 0) + 1 

588 

589 

590@st.dialog("Quick tour", width="large") 

591@guarded() 

592def _tour_dialog() -> None: 

593 """One tour step + Back / Skip / Next navigation. 

594 

595 Back/Next mutate ``tour_step`` via ``on_click`` callbacks — the callback 

596 runs before the fragment rerun, so the body re-renders at the new step. 

597 Skip/Done close the dialog client-side (see ``_close_dialog_clientside``). 

598 """ 

599 step = min(st.session_state.get("tour_step", 0), len(_STEPS) - 1) 

600 title, body = _STEPS[step] 

601 st.subheader(title) 

602 st.markdown(body) 

603 st.progress((step + 1) / len(_STEPS), text=f"Step {step + 1} of {len(_STEPS)}") 

604 if step in (0, len(_STEPS) - 1): 

605 _render_tour_optout() 

606 

607 back_col, skip_col, next_col = st.columns(3) 

608 back_col.button( 

609 "← Back", 

610 key="tour_back", 

611 width="stretch", 

612 disabled=step == 0, 

613 on_click=_step_back, 

614 ) 

615 if step < len(_STEPS) - 1: 

616 if skip_col.button("Skip tour", key="tour_skip", width="stretch"): 

617 _close_dialog_clientside() 

618 next_col.button( 

619 "Next →", 

620 key="tour_next", 

621 width="stretch", 

622 type="primary", 

623 on_click=_step_next, 

624 ) 

625 else: 

626 if next_col.button("✓ Done", key="tour_done", width="stretch", type="primary"): 

627 _close_dialog_clientside() 

628 

629 

630# Spotlight steps: (selector, title, body). ``selector`` is what gets the 

631# pulsing outline + scroll-into-view; None for the selector-less welcome step. 

632# Bodies are markdown, kept short — the card is ~400 px wide. 

633# Walks the whole Scanpath screen in reading order (UX-2): the plot → the 

634# selection/controls above it → the chips → the rail (view modes + controls) → 

635# the bottom panel → the top menu bar. There is no `in_sidebar` flag any more: 

636# every target is in the page or on the menu bar, so no step has to expand a 

637# panel before it can scroll to it. Keep selectors in sync with the keyed 

638# wrappers in tabs.py / app.py / menu.py. 

639_SPOTLIGHT_STEPS = [ 

640 { 

641 "selector": None, 

642 "title": f"{ICONS['app']} Welcome to Scanpath Studio", 

643 # #374 F32: the dataset sentence is `_welcome_body`'s, from the one 

644 # actually open — "A demo dataset is loaded" greeted users' own data. 

645 "body": "Visualize **eye movements in reading** — scanpaths drawn " 

646 "true-to-scale over the text. **Next** for a quick tour, or **Skip " 

647 "tour** to start exploring.", 

648 }, 

649 { 

650 "selector": ".st-key-tour_grp_plot", 

651 "title": f"{ICONS['view_scanpath']} The scanpath", 

652 "body": "This is the main plot. Each circle is a **fixation**, sized by " 

653 "duration on one scale shared by every figure (the key in the corner " 

654 "gives sizes in ms); the lines are **saccades** between them.", 

655 }, 

656 { 

657 "selector": ".st-key-tour_grp_data_source", 

658 "title": f"{ICONS['datasets']} Your datasets", 

659 "body": "The **dataset** you're viewing is picked at the top left; " 

660 f"{ICONS['view_data']} **Data Management** lists them all — click a row " 

661 "there to open one, **+ Add dataset** for your own.", 

662 }, 

663 # Picking comes before narrowing: the picker is the control a new reader 

664 # reaches for first, and narrowing only means something once they have seen 

665 # the pool it narrows. (UX-34 walked them the other way round, top-to-bottom 

666 # by screen position; the workflow order reads better in the tour.) Each step 

667 # targets its own container — they used to share one wrapper, so both lit up 

668 # the whole block. 

669 { 

670 "selector": ".st-key-tour_grp_trial_picker", 

671 "title": f"{ICONS['pick_trial']} Pick a trial", 

672 "body": "Step through trials with the selector and ◀ ▶, or scrub the " 

673 "slider — it shows the trial's position and id.", 

674 }, 

675 { 

676 "selector": ".st-key-tour_grp_narrow_by", 

677 "popover": FUNNEL_TRIGGER, 

678 "title": f"{ICONS['trial_filter']} Narrow the pool", 

679 # The icon, not the word: the trigger beside the picker is Streamlit's 

680 # Material funnel (`tabs._FILTER_ICON`), and "the funnel" sent readers 

681 # hunting for an emoji the app never draws. 

682 "body": f"{ICONS['trial_filter']} beside the trial picker — opened for you " 

683 "here — holds every way to narrow the pool: the text and participant " 

684 "pickers first (*All texts* / *All participants*), then condition and " 

685 "annotation filters (favorites, tags).", 

686 }, 

687 { 

688 "selector": ".st-key-tour_grp_chips", 

689 "title": f"{ICONS['chips']} Trial at a glance", 

690 "body": "These chips show the trial's **identity, conditions, and summary " 

691 "stats**. Choose which fields appear — and drag to reorder — with " 

692 f"**{ICONS['edit']}** at the end of the trial row, which also hides them.", 

693 }, 

694 { 

695 "selector": ".st-key-tour_grp_view_modes", 

696 "title": f"{ICONS['animate']} Animate & compare", 

697 "body": "**Animate** replays the trial fixation by fixation, and " 

698 "**Compare** adds a second scanpath, overlaid or side by side — from this dataset or, " 

699 "via **Scanpath B from**, from another one. The ▾ beside each toggle " 

700 "opens its settings.", 

701 }, 

702 { 

703 "selector": ".st-key-tour_grp_viz_controls", 

704 "title": f"{ICONS['plot_controls']} Plot controls", 

705 "body": "Toggle and style every layer — fixations, saccades, stimulus, word " 

706 "boxes, heatmap, raw gaze. **Design presets** jump between Scanpath, " 

707 "Heatmap, Illustration and Custom; " 

708 f"**{ICONS['designs']} My designs** keeps yours under a name.", 

709 }, 

710 { 

711 "selector": ".st-key-tour_grp_subtabs", 

712 "title": f"{ICONS['panels']} Per-trial panels", 

713 "body": f"Below the plot: **{ICONS['annotations']} Annotations**, **{ICONS['stimulus']} Stimulus & context**, " 

714 f"**{ICONS['comparisons']} Comparisons**, **{ICONS['export']} Export**, and " 

715 f"**{ICONS['share']} Share** (link, code or settings file).", 

716 }, 

717 { 

718 # UX-100 merged the old "📚 The menu bar" step into this one. It named 

719 # `.st-key-top_menu`, a container that stopped being created when 

720 # #UX-63 emptied the settings row — so it had been highlighting nothing 

721 # (`tests/test_tour.py` now catches that) — and its two subjects are 

722 # nav entries themselves, which makes this the same target. 

723 "selector": NAV_SELECTOR, 

724 "title": f"{ICONS['nav']} The nav", 

725 "body": f"**{ICONS['view_scanpath']} Scanpath** is this view. " 

726 f"**{ICONS['view_corpus']} Corpus Analysis** pools all participants; " 

727 f"**{ICONS['view_data']} Data Management** lists your " 

728 f"datasets and adds new ones. **{ICONS['help']} Help** opens over your work.", 

729 }, 

730] 

731 

732# The floating card: a keyed st.container pinned bottom-right via its 

733# `.st-key-tour_card` class. Plain strings (no .format) so the CSS braces 

734# don't need escaping. 

735_CARD_CSS = """ 

736.st-key-tour_card { 

737 position: fixed; 

738 bottom: 1.25rem; 

739 right: 1.25rem; 

740 z-index: 999990; 

741 width: 410px; 

742 max-width: calc(100vw - 2.5rem); 

743 border-radius: 0.75rem; 

744 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 

745 padding: 1rem 1.25rem 0.6rem; 

746} 

747/* The card title is an <h2> for a valid page heading outline (see the title 

748 render in render_spotlight_tour); pin it back to the original <h4> size so 

749 the card looks unchanged. */ 

750.st-key-tour_card h2 { 

751 font-size: 24px !important; 

752 line-height: 1.3 !important; 

753 font-weight: 600 !important; 

754 letter-spacing: normal !important; 

755 padding: 0.25rem 1.5rem 0.75rem 0 !important; 

756 margin: 0 !important; 

757} 

758/* Close (✕) pinned to the card's top-right corner (top offset roughly matches 

759 the right one so it doesn't hug the edge). */ 

760.st-key-tour_sp_close { 

761 position: absolute; 

762 top: 0.6rem; 

763 right: 0.5rem; 

764 width: auto; 

765 z-index: 1; 

766} 

767.st-key-tour_sp_close button { 

768 border: none !important; 

769 background: transparent !important; 

770 box-shadow: none !important; 

771 min-height: 0 !important; 

772 padding: 0.05rem 0.4rem !important; 

773 font-size: 1.05rem; 

774 line-height: 1; 

775 opacity: 0.55; 

776} 

777.st-key-tour_sp_close button:hover { opacity: 1; } 

778/* UX-200: `box-shadow: none` above took Streamlit's focus ring too. */ 

779.st-key-tour_sp_close button:focus-visible { 

780 opacity: 1; 

781 outline: 2px solid var(--sps-accent); 

782 outline-offset: 1px; 

783} 

784/* Vertical rhythm inside the card: body → progress → Back / Next footer (the 

785 gap around the progress bar is set on both sides — the column row is what 

786 actually carries it). The card's own bottom padding is trimmed to match. */ 

787.st-key-tour_card [data-testid="stProgress"] { 

788 margin-top: 0.5rem; 

789 margin-bottom: 0.9rem; 

790} 

791.st-key-tour_card [data-testid="stHorizontalBlock"] { margin-top: 0.9rem; } 

792/* UX-12 "Don't show this again": a footnote between the progress bar and the 

793 footer, so it's muted and pulled tight rather than reading as another step. 

794 UX-110 gives the wizard-setup-guide card (same `tour_card` shape) the 

795 identical treatment for its own opt-out. */ 

796.st-key-tour_dont_show, 

797.st-key-wizard_guide_dont_show { margin-top: -0.5rem !important; } 

798.st-key-tour_dont_show label, 

799.st-key-wizard_guide_dont_show label { opacity: 0.8; } 

800.st-key-tour_dont_show label p, 

801.st-key-wizard_guide_dont_show label p { font-size: 0.85rem !important; } 

802""" 

803 

804# Welcome step only: center the card like a modal and dim the app behind it 

805# (the `.tour-backdrop` div is rendered only on that step). From step 2 on, 

806# the card drops to the bottom-right corner so it never covers the 

807# highlighted section. 

808_WELCOME_CSS = """ 

809.st-key-tour_card { 

810 top: 50%; 

811 left: 50%; 

812 right: auto; 

813 bottom: auto; 

814 transform: translate(-50%, -50%); 

815 width: 500px; 

816} 

817.tour-backdrop { 

818 position: fixed; 

819 inset: 0; 

820 z-index: 999980; 

821 background: rgba(0, 0, 0, 0.45); 

822} 

823""" 

824 

825 

826def _exit_spotlight() -> None: 

827 st.session_state["tour_mode"] = None 

828 

829 

830def _dismiss_listener_script( 

831 selector: str | None, 

832 exit_keys: tuple[str, ...] = ("tour_sp_done", "tour_sp_close", "tour_sp_skip"), 

833) -> str: 

834 """JS that lets Done / ✕ close the tour *instantly*, even mid-load. 

835 

836 The card streams to the browser early (before the ~10 s data/plot work), 

837 but its Done / ✕ are ordinary Streamlit buttons: a click only schedules a 

838 rerun, which Streamlit can't process until the in-flight first run 

839 finishes — so the card and its dimming backdrop linger for the whole load. 

840 

841 This same-origin script attaches a plain ``click`` listener to those 

842 buttons that hides the card, backdrop, and highlight outline immediately 

843 via an injected ``!important`` stylesheet — no server roundtrip. The 

844 button's native click still fires too, so ``_exit_spotlight`` runs whenever 

845 Streamlit catches up and clears ``tour_mode`` durably. Re-arming the tour 

846 re-renders this script, which first drops any stale hide style so the 

847 replayed card is visible. 

848 """ 

849 outline_clear = ( 

850 f"{selector} {{ outline: none !important; animation: none !important; }}" 

851 if selector 

852 else "" 

853 ) 

854 hide_css = ( 

855 ".st-key-tour_card, .tour-backdrop, " 

856 f"#{_GROUP_RING_ID} {{ display: none !important; }} " + outline_clear 

857 ) 

858 btn_selectors = [f".st-key-{k} button" for k in exit_keys] 

859 return f"""<script> 

860 (function () {{ 

861 const doc = window.parent.document; 

862 doc.getElementById("tour-instant-hide")?.remove(); // clear stale hide 

863 const hide = () => {{ 

864 const s = doc.createElement("style"); 

865 s.id = "tour-instant-hide"; 

866 s.textContent = {hide_css!r}; 

867 doc.head.appendChild(s); 

868 doc.querySelectorAll(".tour-backdrop").forEach((e) => e.remove()); 

869 }}; 

870 let tries = 0; 

871 (function wire() {{ 

872 const btns = {btn_selectors!r} 

873 .map((sel) => doc.querySelector(sel)).filter(Boolean); 

874 if (!btns.length) {{ 

875 if (++tries < 20) setTimeout(wire, 100); 

876 return; 

877 }} 

878 btns.forEach((b) => {{ 

879 if (b.dataset.tourHideWired) return; 

880 b.dataset.tourHideWired = "1"; 

881 b.addEventListener("click", hide, {{ once: true }}); 

882 }}); 

883 }})(); 

884 }})(); 

885 </script>""" 

886 

887 

888#: Targets that match several elements which read as one control — the nav's 

889#: links, one `stTopNavLinkContainer` each. Outlining every match drew a row of 

890#: separate brackets, so these get one ring drawn around all of them instead 

891#: (`_group_ring_script`), an element of its own the step's CSS styles. 

892_GROUP_SELECTORS = frozenset({NAV_SELECTOR}) 

893_GROUP_RING_ID = "tour-group-ring" 

894 

895 

896def _highlight_css(selector: str, accent: str) -> str: 

897 """The pulsing outline drawn around a tour/guide target. 

898 

899 Factored out of `render_spotlight_tour` (DATA-22 §4) so the setup guide can 

900 highlight wizard steps with exactly the same treatment the welcome tour uses 

901 — the guide documented its own gap ("the steps are descriptive, not anchored 

902 to specific controls") while this machinery sat 800 lines above it. 

903 

904 A `_GROUP_SELECTORS` target outlines the one ring `_group_ring_script` 

905 places around its matches, not each match. 

906 """ 

907 if not selector: 

908 return "" 

909 if selector in _GROUP_SELECTORS: 

910 selector = f"#{_GROUP_RING_ID}" 

911 return f""" 

912{selector} {{ 

913 outline: 3px solid {accent}; 

914 outline-offset: 3px; 

915 border-radius: 0.5rem; 

916 animation: tour-pulse 1.6s ease-in-out infinite; 

917}} 

918@keyframes tour-pulse {{ 

919 0%, 100% {{ box-shadow: 0 0 0 0 color-mix(in srgb, {accent} 50%, transparent); }} 

920 50% {{ box-shadow: 0 0 14px 7px color-mix(in srgb, {accent} 25%, transparent); }} 

921}} 

922""" 

923 

924 

925def _group_ring_script(selector: str) -> str: 

926 """Place one ring around every visible match of a `_GROUP_SELECTORS` target. 

927 

928 The ring is a ``position: fixed`` box in the page, sized to the matches' 

929 union (the nav's whole row, ❓ Help included, when they sit in Streamlit's 

930 nav strip) and styled by the step's own `_highlight_css`. It is added to the 

931 page by a script that runs in the page, not in this iframe, so it keeps 

932 following the nav after the iframe is gone — and it removes itself once the 

933 tour card has gone, or once the step's CSS no longer outlines it (the next 

934 step, or the tour ended), so no ring can outlive its step. 

935 """ 

936 page_js = f""" 

937(function () {{ 

938 const win = window, doc = document; 

939 if (win.__tourGroupRing) win.clearInterval(win.__tourGroupRing); 

940 const selector = {selector!r}; 

941 const visible = (el) => {{ 

942 const r = el.getBoundingClientRect(); 

943 if (!r.width || !r.height) return false; 

944 const cs = win.getComputedStyle(el); 

945 return cs.visibility !== "hidden" && cs.display !== "none" 

946 && cs.opacity !== "0"; 

947 }}; 

948 let ticks = 0, styled = false; 

949 const stop = () => {{ 

950 win.clearInterval(win.__tourGroupRing); 

951 win.__tourGroupRing = null; 

952 doc.getElementById({_GROUP_RING_ID!r})?.remove(); 

953 }}; 

954 const place = () => {{ 

955 ticks += 1; 

956 let ring = doc.getElementById({_GROUP_RING_ID!r}); 

957 if (!ring) {{ 

958 ring = doc.createElement("div"); 

959 ring.id = {_GROUP_RING_ID!r}; 

960 // Above Streamlit's header (the nav lives in it), under popovers. 

961 ring.style.cssText = "position:fixed;pointer-events:none;" 

962 + "z-index:1000050;"; 

963 doc.body.appendChild(ring); 

964 }} 

965 const outlined = win.getComputedStyle(ring).outlineStyle !== "none"; 

966 styled = styled || outlined; 

967 const cardGone = !doc.querySelector(".st-key-tour_card"); 

968 if ((styled && !outlined) || (ticks > 50 && (cardGone || !outlined))) {{ 

969 stop(); 

970 return; 

971 }} 

972 const matches = [...doc.querySelectorAll(selector)].filter(visible); 

973 const strip = matches[0]?.closest(".rc-overflow"); 

974 const boxes = strip 

975 ? [...strip.children].filter(visible) 

976 : matches; 

977 if (!boxes.length) {{ 

978 ring.style.display = "none"; 

979 return; 

980 }} 

981 const rects = boxes.map((el) => el.getBoundingClientRect()); 

982 const left = Math.min(...rects.map((r) => r.left)); 

983 const top = Math.min(...rects.map((r) => r.top)); 

984 const right = Math.max(...rects.map((r) => r.right)); 

985 const bottom = Math.max(...rects.map((r) => r.bottom)); 

986 Object.assign(ring.style, {{ 

987 display: "block", 

988 left: left + "px", 

989 top: top + "px", 

990 width: right - left + "px", 

991 height: bottom - top + "px", 

992 }}); 

993 }}; 

994 place(); 

995 win.__tourGroupRing = win.setInterval(place, 200); 

996}})(); 

997""" 

998 return f"""<script> 

999 (function () {{ 

1000 const doc = window.parent.document; 

1001 const s = doc.createElement("script"); 

1002 s.textContent = {page_js!r}; 

1003 doc.head.appendChild(s); 

1004 s.remove(); 

1005 }})(); 

1006 </script>""" 

1007 

1008 

1009#: UX-101, measured live (Streamlit 1.62, 1440×900): a popover's panel is drawn 

1010#: at ``z-index: 1000060``, above the card's 999990 — so on a step that opens 

1011#: one, the panel covered the card's left edge and cut the first word off every 

1012#: line. Raise the card **only on those steps**; everywhere else it stays under 

1013#: Streamlit's own overlays, where a floating card belongs. 

1014_CARD_OVER_POPOVER_CSS = ".st-key-tour_card { z-index: 1000070; }" 

1015 

1016 

1017def _all_popover_triggers() -> str: 

1018 """One selector matching every popover trigger the tour knows how to open. 

1019 

1020 Derived from the steps themselves rather than listed, so a step that starts 

1021 opening a second popover is closed again by the steps after it for free. 

1022 """ 

1023 declared = {step.get("popover") for step in _SPOTLIGHT_STEPS} 

1024 declared |= {step.popover for tutorial in TUTORIALS for step in tutorial.steps} 

1025 return ", ".join(sorted(trigger for trigger in declared if trigger)) 

1026 

1027 

1028def _popover_script(trigger: str) -> str: 

1029 """Open the popover this step points inside, and close the last step's. 

1030 

1031 A closed Streamlit popover renders **no body**: before UX-101 the two 

1032 *narrow the pool* steps pointed at a container that only exists while the 

1033 funnel is open, so the spotlight retried for 30 s and then gave up in 

1034 silence — no highlight, no scroll, no error, and the card still advanced. 

1035 

1036 Opening it is the one piece of state the tour touches, and it is chrome, not 

1037 data: no widget inside is read or written, so the tutorial's promise that it 

1038 "never changes a filter" holds. Clicking is gated on ``aria-expanded`` so a 

1039 funnel the user already opened is left alone (a second click would close 

1040 it), and every *other* trigger the tour opens is closed, so the panel from 

1041 the previous step cannot sit over this step's target. 

1042 """ 

1043 others = _all_popover_triggers() 

1044 if not (trigger or others): 

1045 return "" 

1046 return f"""<script> 

1047 (function () {{ 

1048 const doc = window.parent.document; 

1049 const win = doc.defaultView; 

1050 const wanted = {trigger!r}; 

1051 const all = {others!r}; 

1052 // Only ever click a trigger that is really on screen. 

1053 // Streamlit keeps a 0x0 twin of the picker row in the DOM, 

1054 // so a plain querySelector can land on a button nobody can 

1055 // see — the same trap `_scroll_into_view_script` documents. 

1056 const visible = (el) => {{ 

1057 const r = el.getBoundingClientRect(); 

1058 if (!r.width || !r.height) return false; 

1059 const cs = win.getComputedStyle(el); 

1060 return cs.visibility !== "hidden" && cs.display !== "none"; 

1061 }}; 

1062 let tries = 0; 

1063 (function attempt() {{ 

1064 if (wanted) {{ 

1065 const target = [...doc.querySelectorAll(wanted)] 

1066 .find(visible); 

1067 if (!target) {{ 

1068 // Riding out the first run, where the card 

1069 // streams to the browser well before the picker 

1070 // row it points at (see render_spotlight_tour). 

1071 if (++tries < 200) setTimeout(attempt, 150); 

1072 return; 

1073 }} 

1074 if (target.getAttribute("aria-expanded") !== "true") {{ 

1075 target.click(); 

1076 }} 

1077 }} 

1078 if (!all) return; 

1079 for (const other of doc.querySelectorAll(all)) {{ 

1080 if (wanted && other.matches(wanted)) continue; 

1081 if (other.getAttribute("aria-expanded") === "true" 

1082 && visible(other)) {{ 

1083 other.click(); 

1084 }} 

1085 }} 

1086 }})(); 

1087 }})(); 

1088 </script>""" 

1089 

1090 

1091def _scroll_into_view_script(selector: str) -> str: 

1092 """Centre `selector`'s first *visible* match within its own scroller. 

1093 

1094 Every subtlety here was observed live; see the call site in 

1095 `render_spotlight_tour` for the full list. The short version: match the first 

1096 visible element (inactive tab panels hold invisible duplicates), and scroll 

1097 the nearest scrollable ancestor instead of calling `scrollIntoView` (which 

1098 moves the document). 

1099 

1100 There is no sidebar branch any more. Targets used to need an 

1101 ``aria-expanded`` gate plus a retrying click on ``stExpandSidebarButton``, 

1102 because a *collapsed* sidebar still reports nonzero layout rects so plain 

1103 visibility couldn't tell whether the panel was really on screen. Every step 

1104 now points at something in the page or on the top menu bar, where 

1105 ``findVisible()`` answers correctly on its own. 

1106 """ 

1107 return f"""<script> 

1108 (function () {{ 

1109 const doc = window.parent.document; 

1110 const win = doc.defaultView; 

1111 const findVisible = () => 

1112 [...doc.querySelectorAll({selector!r})].find((e) => {{ 

1113 const r = e.getBoundingClientRect(); 

1114 if (r.width === 0 || r.height === 0) return false; 

1115 const cs = win.getComputedStyle(e); 

1116 return cs.visibility !== "hidden" && cs.display !== "none"; 

1117 }}); 

1118 let tries = 0; 

1119 (function attempt() {{ 

1120 const el = findVisible(); 

1121 if (!el) {{ 

1122 if (++tries < 20) setTimeout(attempt, 150); 

1123 return; 

1124 }} 

1125 for (let box = el.parentElement; box; box = box.parentElement) {{ 

1126 const cs = win.getComputedStyle(box); 

1127 if (/(auto|scroll|overlay)/.test(cs.overflowY) 

1128 && box.scrollHeight > box.clientHeight + 4) {{ 

1129 const r = el.getBoundingClientRect(); 

1130 const b = box.getBoundingClientRect(); 

1131 const slack = 8; 

1132 if (r.top >= b.top - slack 

1133 && r.bottom <= b.top + box.clientHeight + slack) {{ 

1134 return; // already visible within its scroller 

1135 }} 

1136 box.scrollTop += r.top - b.top 

1137 - Math.max(0, (box.clientHeight - r.height) / 2); 

1138 return; 

1139 }} 

1140 }} 

1141 }})(); 

1142 }})(); 

1143 </script>""" 

1144 

1145 

1146@st.fragment 

1147@guarded() 

1148def render_spotlight_tour() -> None: 

1149 """Floating tour card + pulsing highlight for the current spotlight step. 

1150 

1151 Call early in ``main()``, right after ``maybe_show_welcome_tour()``, so 

1152 the card streams to the browser before the heavy data/plot work instead 

1153 of seconds after the page opens. Replay clicks still activate it within 

1154 the same run because the button arms the tour in its ``on_click`` 

1155 callback (``_arm_tour``), which runs before the rerun starts. Runs as a 

1156 fragment: Back/Next/close rerun only this function, so the highlight moves 

1157 instantly and ✕ makes the card + CSS vanish without a full-app rerun 

1158 (the fragment then renders nothing, which clears its previous elements). 

1159 """ 

1160 if st.session_state.get("tour_mode") != "spotlight": 

1161 return 

1162 n = len(_SPOTLIGHT_STEPS) 

1163 step_idx = min(st.session_state.get("tour_step", 0), n - 1) 

1164 step = _SPOTLIGHT_STEPS[step_idx] 

1165 

1166 # BUG-6: fall back to the app's brand blue (matches the pinned theme 

1167 # `.streamlit/config.toml` primaryColor), never Streamlit's default red, so 

1168 # the tour accent stays consistent even if the runtime doesn't expose the 

1169 # theme option. 

1170 accent = st.get_option("theme.primaryColor") or "#1f77b4" 

1171 # Card colors follow the active theme when the runtime exposes it 

1172 # (st.context.theme, Streamlit ≥1.46); default to light otherwise. 

1173 theme = getattr(getattr(st, "context", None), "theme", None) 

1174 is_dark = getattr(theme, "type", "light") == "dark" 

1175 bg, border = ("#262730", "#41434e") if is_dark else ("#ffffff", "#d5d6d9") 

1176 

1177 highlight = _highlight_css(step["selector"], accent) 

1178 st.markdown( 

1179 "<style>" 

1180 + _CARD_CSS 

1181 + f".st-key-tour_card {{ background: {bg}; border: 1px solid {border}; }}" 

1182 + (_WELCOME_CSS if step_idx == 0 else "") 

1183 + (_CARD_OVER_POPOVER_CSS if step.get("popover") else "") 

1184 + highlight 

1185 + "</style>", 

1186 unsafe_allow_html=True, 

1187 ) 

1188 if step_idx == 0: 

1189 st.markdown('<div class="tour-backdrop"></div>', unsafe_allow_html=True) 

1190 

1191 with st.container(key="tour_card"): 

1192 # Close (✕) in the top-right corner — exits the tour like "Exit"/"Done" 

1193 # (CSS pins it; the dismiss listener wires it for instant close too). 

1194 st.button( 

1195 # UX-200: `spoken` names the glyph for screen readers. 

1196 f"✕ {spoken('Close the tour')}", 

1197 wrap=True, 

1198 key="tour_sp_close", 

1199 on_click=_exit_spotlight, 

1200 help="Close the tour", 

1201 ) 

1202 # <h2> keeps the page heading outline valid (the card sits right under 

1203 # the page <h1>; an <h4> here would be an h1→h4 jump). Sized back down 

1204 # to the original compact look via `.st-key-tour_card h2` in _CARD_CSS. 

1205 st.markdown(f"## {step['title']}") 

1206 st.markdown(_welcome_body(step["body"]) if step_idx == 0 else step["body"]) 

1207 st.progress((step_idx + 1) / n, text=f"Step {step_idx + 1} of {n}") 

1208 # UX-12: the opt-out sits on the two steps where a user decides they're 

1209 # finished with the tour — the welcome (bail out now) and the last step 

1210 # (done, don't greet me again). Keeping it off the middle steps preserves 

1211 # the card's tight vertical rhythm. 

1212 if step_idx in (0, n - 1): 

1213 _render_tour_optout() 

1214 # The ✕ in the corner closes the tour from any step, so the footer is 

1215 # Back / Next (Skip tour / Next on the welcome, Back / Done on the last). 

1216 back_col, next_col = st.columns(2) 

1217 if step_idx == 0: 

1218 # The welcome has nothing to go back to, so its left slot is a 

1219 # plainly labelled way out instead of a disabled Back — a first-time 

1220 # reader should not have to read the ✕ glyph to start exploring. 

1221 # Same exit as ✕ / Done; Help → Tutorials replays it. 

1222 back_col.button( 

1223 "Skip tour", 

1224 key="tour_sp_skip", 

1225 width="stretch", 

1226 on_click=_exit_spotlight, 

1227 help="Close the tour and start exploring. Replay it any time " 

1228 "from Help → Tutorials.", 

1229 ) 

1230 else: 

1231 back_col.button( 

1232 "← Back", 

1233 key="tour_sp_back", 

1234 width="stretch", 

1235 on_click=_step_back, 

1236 ) 

1237 if step_idx < n - 1: 

1238 next_col.button( 

1239 "Next →", 

1240 key="tour_sp_next", 

1241 width="stretch", 

1242 type="primary", 

1243 on_click=_step_next, 

1244 ) 

1245 else: 

1246 next_col.button( 

1247 "✓ Done", 

1248 key="tour_sp_done", 

1249 width="stretch", 

1250 type="primary", 

1251 on_click=_exit_spotlight, 

1252 ) 

1253 

1254 # Make Done / ✕ hide the tour instantly, even while the app's first 

1255 # run is still loading (the Streamlit click alone would only take 

1256 # effect once that ~10 s run finishes). See _dismiss_listener_script. 

1257 embed_html_iframe(_dismiss_listener_script(step["selector"]), height=0) 

1258 

1259 # UX-101: raise the popover this step points inside (and lower the one 

1260 # the last step raised) BEFORE the find-and-scroll below goes looking — 

1261 # a closed popover has no body for it to find. 

1262 popover_script = _popover_script(step.get("popover", "")) 

1263 if popover_script: 

1264 embed_html_iframe(popover_script, height=0) 

1265 

1266 if step["selector"] in _GROUP_SELECTORS: 

1267 embed_html_iframe(_group_ring_script(step["selector"]), height=0) 

1268 if step["selector"]: 

1269 # Bring the highlighted section into view. Same-origin iframe 

1270 # trick as _close_dialog_clientside; no-op if the selector is 

1271 # gone. Subtleties, all observed live: 

1272 # - The find+scroll retries until the target is visible, riding 

1273 # out Streamlit's re-render. 

1274 # - Match the first *visible* element, not the first match: 

1275 # Streamlit keeps inactive tab panels laid out but 

1276 # visibility-hidden, so a selector can hit an invisible 

1277 # duplicate (e.g. the Raw Data panel's inner tab strip) and 

1278 # scroll the page to nowhere. 

1279 # - No scrollIntoView: smooth gets cancelled by Streamlit's 

1280 # re-renders, and instant also scrolls the document. Instead, 

1281 # center the target within its nearest scrollable ancestor only. 

1282 # - Skip targets that are already fully on screen. 

1283 # - The iframe stays INSIDE the fixed-position card: when it sat 

1284 # at the bottom of the main column, its (re)mount could yank 

1285 # the main scroller to the page bottom to reveal it. 

1286 # The sidebar branch is gone with the sidebar: every target is now 

1287 # either in the page or on the top menu bar, both of which are 

1288 # always laid out and visible, so there is nothing to expand first 

1289 # and no `in_sidebar` flag to carry. 

1290 embed_html_iframe( 

1291 _scroll_into_view_script(step["selector"]), 

1292 height=0, 

1293 ) 

1294 

1295 

1296def tour_suppressed(query_params) -> bool: 

1297 """True when the session shouldn't be greeted by the tour. 

1298 

1299 Embeds (``?embed=true``) and deep links (``?source=…&participant=…``) 

1300 arrive mid-workflow from an external tool. Takes the params as a mapping 

1301 (rather than reading ``st.query_params`` itself) because AppTest can't 

1302 inject query params — this stays unit-testable. 

1303 """ 

1304 if (query_params.get("embed") or "").lower() in {"true", "1"}: 

1305 return True 

1306 return any(k in query_params for k in ("source", "participant", "trial", "tab")) 

1307 

1308 

1309def _start_tour() -> None: 

1310 """Kick off the configured tour style from step 0.""" 

1311 st.session_state["tour_step"] = 0 

1312 if TOUR_STYLE == "spotlight": 

1313 st.session_state["tour_mode"] = "spotlight" 

1314 else: 

1315 _tour_dialog() 

1316 

1317 

1318def _open_dataset_name() -> str | None: 

1319 """The name of the dataset this session has open, as the picker shows it.""" 

1320 from scanpath_studio.constants import DEMO_CHOICE, PUBLIC_DATASETS_CHOICE 

1321 

1322 token = st.session_state.get("data_source_choice", DEMO_CHOICE) 

1323 if token == PUBLIC_DATASETS_CHOICE: 

1324 token = st.session_state.get("public_dataset_choice") 

1325 if not token: 

1326 return None 

1327 from scanpath_studio.app import _dataset_display_name # app imports tour 

1328 

1329 return _dataset_display_name(str(token)) 

1330 

1331 

1332def _welcome_body(body: str) -> str: 

1333 """The welcome card's text, naming the dataset that is open (#374 F32).""" 

1334 name = _open_dataset_name() 

1335 if not name: 

1336 return body 

1337 lead, _, rest = body.partition("**Next**") 

1338 return f"{lead}**{name}** is open; **Next**{rest}" if rest else body 

1339 

1340 

1341def _arm_tour() -> None: 

1342 """``on_click`` callback for the replay button: arm the tour from step 0. 

1343 

1344 Callbacks run *before* the rerun, so the tour's render call early in 

1345 ``main()`` — which executes long before the menu button — picks the 

1346 request up within the same run. Dialogs can't be opened from callbacks, 

1347 so the dialog style sets a request flag that ``maybe_show_welcome_tour`` 

1348 (the early call site) serves. 

1349 """ 

1350 st.session_state["tour_step"] = 0 

1351 if TOUR_STYLE == "spotlight": 

1352 st.session_state["tour_mode"] = "spotlight" 

1353 else: 

1354 st.session_state["_tour_dialog_requested"] = True 

1355 

1356 

1357def maybe_show_welcome_tour() -> None: 

1358 """Start the welcome tour once per session, unless this is an embed/deep link. 

1359 

1360 Call from ``main()`` after the URL presets are read (the suppression 

1361 checks look at ``st.query_params``) but BEFORE the heavy data/plot work, 

1362 immediately followed by ``render_spotlight_tour()`` — Streamlit streams 

1363 elements in run order, so anything rendered after the data load appears 

1364 seconds late. The dialog style opens here and overlays whatever renders 

1365 after it; the spotlight style just arms ``tour_mode``. 

1366 """ 

1367 if st.session_state.pop("_tour_dialog_requested", False): 

1368 # Replay request from the menu button's on_click callback. 

1369 _tour_dialog() 

1370 return 

1371 if st.session_state.get("tour_seen"): 

1372 return 

1373 if tour_suppressed(st.query_params): 

1374 return 

1375 if tour_opted_out(): # UX-12: "Don't show this again", persisted in a cookie 

1376 return 

1377 st.session_state["tour_seen"] = True # before opening — see module docstring 

1378 # #374 F32: a session recovered from *Saved on this computer* belongs to 

1379 # someone who has used the app here before — a new browser, or cleared 

1380 # site data, cleared the cookie opt-out, not their experience. 

1381 from scanpath_studio.persistence import session_was_restored 

1382 

1383 if session_was_restored(st.session_state): 

1384 return 

1385 _start_tour() 

1386 

1387 

1388# ----------------------------------------------------------------------------- 

1389# Use-case tutorials (UX-40) 

1390# ----------------------------------------------------------------------------- 

1391 

1392 

1393def build_tutorial_context(words, fixations, combos) -> dict[str, object]: 

1394 """Small, serializable availability snapshot for the tutorial chooser.""" 

1395 n_trials = len(combos) if combos is not None else 0 

1396 has_words = bool(words is not None and not words.empty) 

1397 has_fixations = bool(fixations is not None and not fixations.empty) 

1398 comparable = False 

1399 corpus_variation = n_trials >= 2 

1400 if combos is not None and not combos.empty: 

1401 source = ( 

1402 fixations 

1403 if fixations is not None and not fixations.empty 

1404 else words 

1405 if words is not None and not words.empty 

1406 else combos 

1407 ) 

1408 if not {"participant_id", "trial_id"}.issubset(source.columns): 

1409 source = combos 

1410 comparison_columns = [ 

1411 column for column in ("text_id", "screen_id") if column in source.columns 

1412 ] 

1413 if "text_id" in comparison_columns: 

1414 readings = source[ 

1415 [ 

1416 "participant_id", 

1417 "trial_id", 

1418 *comparison_columns, 

1419 ] 

1420 ].drop_duplicates() 

1421 comparable = bool( 

1422 readings.groupby(comparison_columns, dropna=False).size().max() >= 2 

1423 ) 

1424 elif "text_id" in combos.columns: 

1425 comparable = bool(combos.groupby("text_id", dropna=False).size().max() >= 2) 

1426 if "text_id" in combos.columns: 

1427 corpus_variation |= combos["text_id"].nunique(dropna=True) >= 2 

1428 if "participant_id" in combos.columns: 

1429 corpus_variation |= combos["participant_id"].nunique(dropna=True) >= 2 

1430 return { 

1431 "n_trials": n_trials, 

1432 "has_words": has_words, 

1433 "has_fixations": has_fixations, 

1434 "has_comparable_readings": comparable, 

1435 "has_corpus_variation": corpus_variation, 

1436 } 

1437 

1438 

1439def tutorial_availability( 

1440 tutorial: TutorialDefinition, context: dict[str, object] 

1441) -> tuple[bool, str]: 

1442 """Whether a tutorial can start, plus an actionable explanation.""" 

1443 rule = tutorial.availability 

1444 if rule == "always": 

1445 return True, "" 

1446 if rule == "has_trials": 

1447 available = int(context.get("n_trials", 0)) >= 1 

1448 return available, "Open a dataset with trials, or loosen the filters." 

1449 if rule == "has_visual_data": 

1450 available = bool(context.get("has_words") or context.get("has_fixations")) 

1451 return ( 

1452 available, 

1453 "Open a dataset with words or fixations, or loosen the filters.", 

1454 ) 

1455 if rule == "has_comparable_readings": 

1456 available = bool(context.get("has_comparable_readings")) 

1457 return available, "Need two trials of the same text." 

1458 if rule == "has_corpus_variation": 

1459 available = bool(context.get("has_corpus_variation")) 

1460 return available, "Need variation across trials, participants, or texts." 

1461 return False, f"Unknown availability rule: {rule}." 

1462 

1463 

1464#: #374 F31 — what a rule lacks when the trial filters, not the dataset, are 

1465#: why a tutorial cannot start. 

1466_FILTERED_REASONS = { 

1467 "has_trials": "no trial is left in the pool.", 

1468 "has_visual_data": "the pool has no words or fixations.", 

1469 "has_comparable_readings": "no two trials in the pool share a text.", 

1470 "has_corpus_variation": "the pool holds one trial.", 

1471} 

1472 

1473 

1474def filtered_reason( 

1475 tutorial: TutorialDefinition, context: dict[str, object] 

1476) -> str | None: 

1477 """Why the **filters** keep ``tutorial`` from starting, or ``None``. 

1478 

1479 ``None`` unless the tutorial is unavailable on the filtered pool but would 

1480 be available on the whole dataset — the context's ``unfiltered`` snapshot, 

1481 which `app.main` stashes only while a filter narrows the pool.""" 

1482 unfiltered = context.get("unfiltered") 

1483 if not isinstance(unfiltered, dict) or tutorial_availability(tutorial, context)[0]: 

1484 return None 

1485 if not tutorial_availability(tutorial, unfiltered)[0]: 

1486 return None 

1487 return _FILTERED_REASONS.get(tutorial.availability) 

1488 

1489 

1490def _tutorial_progress() -> dict[str, int]: 

1491 return st.session_state.setdefault("tutorial_progress", {}) 

1492 

1493 

1494def _tutorial_completed() -> dict[str, bool]: 

1495 return st.session_state.setdefault("tutorial_completed", {}) 

1496 

1497 

1498def _start_use_case(tutorial_id: str, *, restart: bool = False) -> None: 

1499 """Start/resume one tutorial while remembering where Exit should return.""" 

1500 if tutorial_id not in _TUTORIAL_BY_ID: 

1501 return 

1502 context = st.session_state.get("_tutorial_context") or {} 

1503 tutorial = _TUTORIAL_BY_ID[tutorial_id] 

1504 available, _ = tutorial_availability(tutorial, context) 

1505 if not available: 

1506 return 

1507 st.session_state["tutorial_return"] = { 

1508 "main_nav": st.session_state.get("main_nav", _VIEW_SCANPATH), 

1509 "single_subtab": st.session_state.get("single_subtab", SUBTAB_ANNOTATIONS), 

1510 } 

1511 if restart: 

1512 _tutorial_progress()[tutorial_id] = 0 

1513 _tutorial_completed().pop(tutorial_id, None) 

1514 else: 

1515 _tutorial_progress().setdefault(tutorial_id, 0) 

1516 # UX-83: navigate to the step's own page right away when it names a 

1517 # different view, instead of opening the card on whatever page the user 

1518 # already stood on and offering a "Show me / Open this panel" button to 

1519 # get there. Covers resume too — the current step (not always index 0) 

1520 # is what the card is about to show. `tutorial_return` above already 

1521 # captured the page being left, so Exit still comes back to it. 

1522 step_index = int(_tutorial_progress().get(tutorial_id, 0)) 

1523 steps = steps_of(tutorial) 

1524 if 0 <= step_index < len(steps): 

1525 _open_tutorial_surface(steps[step_index]) 

1526 st.session_state["tutorial_active"] = tutorial_id 

1527 # Never stack this task card over the automatic welcome/setup card. 

1528 st.session_state["tour_mode"] = None 

1529 

1530 

1531def _move_use_case(tutorial_id: str, delta: int) -> None: 

1532 tutorial = _TUTORIAL_BY_ID[tutorial_id] 

1533 current = int(_tutorial_progress().get(tutorial_id, 0)) 

1534 _tutorial_progress()[tutorial_id] = max( 

1535 0, min(len(steps_of(tutorial)) - 1, current + delta) 

1536 ) 

1537 

1538 

1539def _finish_use_case(tutorial_id: str) -> None: 

1540 _tutorial_completed()[tutorial_id] = True 

1541 _tutorial_progress()[tutorial_id] = len(steps_of(_TUTORIAL_BY_ID[tutorial_id])) - 1 

1542 st.session_state["tutorial_active"] = None 

1543 

1544 

1545def _restore_tutorial_return() -> None: 

1546 location = st.session_state.get("tutorial_return") or {} 

1547 if location.get("main_nav") is not None: 

1548 st.session_state["main_nav"] = location["main_nav"] 

1549 if location.get("single_subtab") is not None: 

1550 st.session_state["single_subtab"] = location["single_subtab"] 

1551 st.session_state["tutorial_active"] = None 

1552 

1553 

1554def _open_tutorial_surface(step: TutorialStep) -> None: 

1555 from scanpath_studio.constants import DATASET_EDITOR_OPEN_KEY 

1556 

1557 st.session_state["main_nav"] = step.view 

1558 if step.subtab is not None: 

1559 st.session_state["single_subtab"] = step.subtab 

1560 if step.corpus_subtab is not None: 

1561 st.session_state["corpus_subtab"] = step.corpus_subtab 

1562 # DATA-35: the Data page's two screens. A step that points into the editor 

1563 # opens it; one that points at the overview closes it, so walking back up a 

1564 # tutorial does not leave the editor covering the table the previous step 

1565 # was about. 

1566 if step.view == _VIEW_DATA: 

1567 if step.dataset_editor: 

1568 st.session_state[DATASET_EDITOR_OPEN_KEY] = True 

1569 else: 

1570 st.session_state.pop(DATASET_EDITOR_OPEN_KEY, None) 

1571 

1572 

1573_KNOWN_VIEWS = (_VIEW_SCANPATH, _VIEW_CORPUS, _VIEW_DATA) 

1574 

1575 

1576def _tutorial_surface_is_open(step: TutorialStep) -> bool: 

1577 """Is the view (and subtab) this step points at the one on screen? 

1578 

1579 UX-40: this used to fold everything that was not Corpus Analysis into 

1580 Scanpath, which predates DATA-26's third view — so a step with 

1581 ``view=_VIEW_DATA`` reported "not open" *while the user was standing on the 

1582 Data page*, and the card offered to open the panel they were already 

1583 looking at (and withheld the spotlight that should have been on it). 

1584 """ 

1585 from scanpath_studio.constants import DATASET_EDITOR_OPEN_KEY 

1586 

1587 current_view = st.session_state.get("main_nav", _VIEW_SCANPATH) 

1588 if current_view not in _KNOWN_VIEWS: 

1589 current_view = _VIEW_SCANPATH 

1590 if current_view != step.view: 

1591 return False 

1592 # DATA-35: on the Data page, "which screen" is part of the answer — an 

1593 # editor step is not open while the overview is showing, and vice versa. 

1594 if step.view == _VIEW_DATA and bool( 

1595 st.session_state.get(DATASET_EDITOR_OPEN_KEY) 

1596 ) != bool(step.dataset_editor): 

1597 return False 

1598 if ( 

1599 step.corpus_subtab is not None 

1600 and st.session_state.get("corpus_subtab", "Per text") != step.corpus_subtab 

1601 ): 

1602 return False 

1603 return ( 

1604 step.subtab is None 

1605 or st.session_state.get("single_subtab", SUBTAB_ANNOTATIONS) == step.subtab 

1606 ) 

1607 

1608 

1609def _arm_tutorial_library() -> None: 

1610 """Request the tutorial chooser. Called by the ❓ Help nav entry 

1611 (``menu._arm_help_action``).""" 

1612 st.session_state["_tutorial_library_requested"] = True 

1613 

1614 

1615def maybe_show_tutorial_library() -> None: 

1616 """Open the tutorial chooser if the ❓ Help menu button armed it. 

1617 

1618 Served early in ``main()`` beside :func:`maybe_show_faq`, for the same 

1619 reason: the button renders at the bottom of the run. 

1620 """ 

1621 if st.session_state.pop("_tutorial_library_requested", False): 

1622 _tutorial_library_dialog() 

1623 

1624 

1625def stash_tutorial_context(context: dict[str, object]) -> None: 

1626 """Park the context the tutorial chooser reads, without rendering anything. 

1627 

1628 UX-65 turned the ❓ Help buttons into nav entries, so there is no longer a 

1629 widget to hang this on — but the dialog still needs to know what is loaded, 

1630 and it can be opened from any view, so the stash has to happen every run. 

1631 """ 

1632 st.session_state["_tutorial_context"] = dict(context) 

1633 

1634 

1635@st.dialog(f"{ICONS['tutorials']} Tutorials", width="large") 

1636@guarded() 

1637def _tutorial_library_dialog() -> None: 

1638 """The chooser: outcome, prerequisites, time, and progress per tutorial.""" 

1639 from scanpath_studio.menu import close_open_popovers 

1640 

1641 # Opened from a button inside the ❓ Help popover, whose open state is 

1642 # client-side — without this it floats on top of the modal. 

1643 close_open_popovers() 

1644 context = st.session_state.get("_tutorial_context") or {} 

1645 st.markdown("**Choose the outcome you want to reach.**") 

1646 st.caption( 

1647 "Each one points at the real controls and changes nothing — your data, " 

1648 "filters and settings are exactly where you left them." 

1649 ) 

1650 welcome = st.container(border=True) 

1651 welcome_head, welcome_action = welcome.columns([3, 1], vertical_alignment="center") 

1652 welcome_head.markdown("**Welcome tour**") 

1653 welcome_head.caption("A quick introduction to Scanpath Studio.") 

1654 welcome_head.caption("App overview · about 2 minutes") 

1655 if welcome_action.button("Start", key="tutorial_start_welcome", width="stretch"): 

1656 _arm_tour() 

1657 st.rerun(scope="app") 

1658 # UX-110: the welcome tour's own "Don't show this again" (UX-12), reachable 

1659 # here too — every other card in this dialog already offers its own 

1660 # opt-out (below), so the welcome card was the one place in the whole 

1661 # chooser missing it. 

1662 _render_tour_optout(welcome, key_suffix="_picker") 

1663 # UX-40: one bordered card per tutorial instead of five identical 

1664 # caption/caption/caption/two-buttons stacks separated by dividers — at that 

1665 # density the eye had nothing to land on, and "Start over" sat there at full 

1666 # weight even for a tutorial nobody had started yet. 

1667 for tutorial in TUTORIALS: 

1668 available, reason = tutorial_availability(tutorial, context) 

1669 completed = bool(_tutorial_completed().get(tutorial.id)) 

1670 progress = int(_tutorial_progress().get(tutorial.id, 0)) 

1671 started = progress > 0 and not completed 

1672 card = st.container(border=True) 

1673 head, action = card.columns([3, 1], vertical_alignment="center") 

1674 badge = " ✓" if completed else "" 

1675 head.markdown(f"**{tutorial.title}**{badge}") 

1676 head.caption(tutorial.outcome) 

1677 if started: 

1678 state = f"Paused at step {progress + 1} of {len(steps_of(tutorial))}" 

1679 elif completed: 

1680 state = "Completed — replay any time" 

1681 else: 

1682 state = f"{len(steps_of(tutorial))} steps · {tutorial.estimated_time}" 

1683 head.caption(f"{state} · needs {tutorial.prerequisite.lower()}") 

1684 # Handled by return value, not `on_click`, because **an `st.dialog` body 

1685 # is a fragment**: a callback here reruns only the dialog, so the state 

1686 # `_start_use_case` writes never reached `main()` — the chooser sat 

1687 # there unchanged and the task card it was supposed to hand over to was 

1688 # never drawn. `st.rerun(scope="app")` both closes the modal (the 

1689 # `_tutorial_library_requested` flag was already popped, so the next run 

1690 # does not re-open it) and renders the card underneath. 

1691 if action.button( 

1692 "Resume" if started else "Start", 

1693 key=f"tutorial_start_{tutorial.id}", 

1694 disabled=not available, 

1695 type="primary" if started else "secondary", 

1696 width="stretch", 

1697 ): 

1698 _start_use_case(tutorial.id) 

1699 st.rerun(scope="app") 

1700 # Only offered once there is progress to discard. 

1701 if (started or completed) and action.button( 

1702 "Start over", 

1703 key=f"tutorial_restart_{tutorial.id}", 

1704 disabled=not available, 

1705 width="stretch", 

1706 ): 

1707 _start_use_case(tutorial.id, restart=True) 

1708 st.rerun(scope="app") 

1709 if not available: 

1710 because = filtered_reason(tutorial, context) 

1711 card.caption( 

1712 f"{ICONS['warning']} Unavailable with the current filters — {because}" 

1713 if because 

1714 else f"{ICONS['warning']} Unavailable — {reason.lower()}" 

1715 ) 

1716 

1717 

1718@st.fragment 

1719@guarded() 

1720def render_use_case_tutorial() -> None: 

1721 """Render the active named tutorial with safe, explicit navigation.""" 

1722 tutorial_id = st.session_state.get("tutorial_active") 

1723 tutorial = _TUTORIAL_BY_ID.get(tutorial_id) 

1724 if tutorial is None: 

1725 return 

1726 context = st.session_state.get("_tutorial_context") or {} 

1727 available, reason = tutorial_availability(tutorial, context) 

1728 if not available: 

1729 because = filtered_reason(tutorial, context) 

1730 st.warning( 

1731 f"Tutorial paused: with the current filters, {because}" 

1732 if because 

1733 else f"Tutorial paused: {reason}" 

1734 ) 

1735 return 

1736 step_index = min( 

1737 int(_tutorial_progress().get(tutorial.id, 0)), len(steps_of(tutorial)) - 1 

1738 ) 

1739 steps = steps_of(tutorial) 

1740 step = steps[step_index] 

1741 surface_open = _tutorial_surface_is_open(step) 

1742 selector = step.selector if surface_open else None 

1743 accent = st.get_option("theme.primaryColor") or "#1f77b4" 

1744 theme = getattr(getattr(st, "context", None), "theme", None) 

1745 is_dark = getattr(theme, "type", "light") == "dark" 

1746 bg, border = ("#262730", "#41434e") if is_dark else ("#ffffff", "#d5d6d9") 

1747 highlight = _highlight_css(selector or "", accent) 

1748 st.markdown( 

1749 "<style>" 

1750 + _CARD_CSS 

1751 + f".st-key-tour_card {{ background: {bg}; border: 1px solid {border}; }}" 

1752 + (_CARD_OVER_POPOVER_CSS if step.popover else "") 

1753 + highlight 

1754 + "</style>", 

1755 unsafe_allow_html=True, 

1756 ) 

1757 with st.container(key="tour_card"): 

1758 if selector in _GROUP_SELECTORS: 

1759 # Inside the card, like every tour iframe (see render_spotlight_tour). 

1760 embed_html_iframe(_group_ring_script(selector), height=0) 

1761 st.markdown(f"## {tutorial.title}") 

1762 st.markdown(f"**{step.title}**") 

1763 st.markdown(step.body) 

1764 if not surface_open and st.button( 

1765 "Open this panel", 

1766 key="tutorial_open_surface", 

1767 type="primary", 

1768 width="stretch", 

1769 ): 

1770 _open_tutorial_surface(step) 

1771 st.rerun() 

1772 st.progress( 

1773 (step_index + 1) / len(steps), 

1774 text=f"Step {step_index + 1} of {len(steps)}", 

1775 ) 

1776 st.link_button( 

1777 "Read this in the docs ↗", 

1778 tutorial.docs_url, 

1779 width="stretch", 

1780 ) 

1781 back_col, exit_col, next_col = st.columns(3) 

1782 back_col.button( 

1783 "← Back", 

1784 key="tutorial_back", 

1785 disabled=step_index == 0, 

1786 on_click=_move_use_case, 

1787 args=(tutorial.id, -1), 

1788 width="stretch", 

1789 ) 

1790 if exit_col.button("Exit", key="tutorial_exit", width="stretch"): 

1791 _restore_tutorial_return() 

1792 st.rerun() 

1793 if step_index < len(steps) - 1: 

1794 next_col.button( 

1795 "Next →", 

1796 key="tutorial_next", 

1797 type="primary", 

1798 on_click=_move_use_case, 

1799 args=(tutorial.id, 1), 

1800 width="stretch", 

1801 ) 

1802 else: 

1803 if next_col.button( 

1804 "✓ Done", 

1805 key="tutorial_done", 

1806 type="primary", 

1807 width="stretch", 

1808 ): 

1809 # Completion leaves the app at the promised outcome even when 

1810 # the user did not press the last step's optional Open button. 

1811 _open_tutorial_surface(step) 

1812 _finish_use_case(tutorial.id) 

1813 st.rerun() 

1814 

1815 # A Streamlit popover is client-side state, so its server callback can 

1816 # start a tutorial but cannot close the chooser that contained the 

1817 # Start button. Close only the expanded Tutorials trigger once it 

1818 # appears later in this rerun; otherwise the chooser sits over the 

1819 # menu while the task card is already active. 

1820 embed_html_iframe( 

1821 """<script> 

1822 (function () { 

1823 const doc = window.parent.document; 

1824 let tries = 0; 

1825 (function closeTutorialChooser() { 

1826 const trigger = [...doc.querySelectorAll( 

1827 'button[aria-expanded="true"]' 

1828 )].find((button) => 

1829 (button.textContent || '').includes('Tutorials') 

1830 ); 

1831 if (trigger) { 

1832 trigger.click(); 

1833 return; 

1834 } 

1835 if (++tries < 200) setTimeout(closeTutorialChooser, 150); 

1836 })(); 

1837 })(); 

1838 </script>""", 

1839 height=0, 

1840 ) 

1841 

1842 popover_script = _popover_script(step.popover) 

1843 if popover_script: 

1844 embed_html_iframe(popover_script, height=0) 

1845 

1846 if selector: 

1847 embed_html_iframe( 

1848 f"""<script> 

1849 (function () {{ 

1850 const doc = window.parent.document; 

1851 let tries = 0; 

1852 (function findAndScroll() {{ 

1853 const el = [...doc.querySelectorAll({selector!r})].find((e) => {{ 

1854 const r = e.getBoundingClientRect(); 

1855 const s = window.getComputedStyle(e); 

1856 return r.width && r.height && s.display !== "none" 

1857 && s.visibility !== "hidden"; 

1858 }}); 

1859 if (!el) {{ 

1860 if (++tries < 200) setTimeout(findAndScroll, 150); 

1861 return; 

1862 }} 

1863 el.scrollIntoView({{behavior: "smooth", block: "center"}}); 

1864 }})(); 

1865 }})(); 

1866 </script>""", 

1867 height=0, 

1868 ) 

1869 

1870 

1871# ----------------------------------------------------------------------------- 

1872# FAQ (UX-15) — the handful of questions that come up over and over, answered 

1873# in-app so nobody has to leave to find out that (say) their measures are their 

1874# eye-tracker's, not ours. Deliberately SHORT: the canonical, complete version 

1875# is docs/faq.md on the docs site, linked from the bottom of the dialog. Keep 

1876# these answers in sync with that page when either changes. 

1877# ----------------------------------------------------------------------------- 

1878 

1879DOCS_FAQ_URL = f"{CITATION['docs_url']}faq/" 

1880 

1881# (question, markdown answer). Two-to-four lines each — anything longer belongs 

1882# on the docs page. 

1883_WHERE_DATA_GOES = "Where does my data go?" 

1884 

1885_FAQ_ITEMS = [ 

1886 ( 

1887 "A column was mapped to the wrong field. Where do I fix it?", 

1888 f"{ICONS['view_data']} **Data Management → {ICONS['edit']} Edit dataset → 2 · Data tables & column mapping** — an " 

1889 "editable form that " 

1890 "re-derives everything in place, no re-upload. It can only offer columns " 

1891 "that survived the import; anything dropped needs a re-upload.", 

1892 ), 

1893 ( 

1894 "What counts as a “trial”?", 

1895 "One reading event — one participant reading one text once — and it is " 

1896 "whatever your **Trial ID** mapping says it is. EyeLink's `TRIAL_INDEX` " 

1897 "only identifies a trial *within* a participant, and the text id falls back to " 

1898 "the trial id, so map your item column as **Text ID** if trial order was " 

1899 "randomized.", 

1900 ), 

1901 # #374 F22: the answer depends on where the app runs — `faq_items` fills 

1902 # it in from `_where_data_goes`, so a hosted copy never says "nowhere". 

1903 (_WHERE_DATA_GOES, ""), 

1904 ( 

1905 "My uploaded data vanished after a refresh.", 

1906 "Local and desktop runs normally recover uploaded datasets, settings and " 

1907 f"annotations automatically. Check **{ICONS['view_data']} Data Management → Saved on this computer** " 

1908 "to see whether it is enabled and where it is saved. For a portable " 

1909 f"copy, export annotations from **{ICONS['view_data']} Data Management → Annotations** and the " 

1910 f"figure's settings from **{ICONS['view_scanpath']} Scanpath → {ICONS['share']} Share → File**; neither file " 

1911 "holds dataset rows.", 

1912 ), 

1913 ( 

1914 "How do I turn the recovery copy off, or delete it?", 

1915 "Start the app with `scanpath-studio run --no-persist` (or set " 

1916 "`SCANPATH_STUDIO_PERSIST=0`) and nothing is saved. " 

1917 "`scanpath-studio cache --clear` deletes what is already stored, and " 

1918 "`SCANPATH_STUDIO_STATE_DIR=/your/folder` saves it somewhere else.", 

1919 ), 

1920 ( 

1921 "PDF or video export fails but HTML works.", 

1922 "The current figure's **PNG** and **SVG** are saved by your browser from " 

1923 "the plot on screen, so they always work. **PDF**, **GIF**/**MP4** and " 

1924 "the images in the export and Compare bundles go through Kaleido, which " 

1925 "drives a headless " 

1926 "Chrome, Chromium or Edge — install one of them (in a pip install, " 

1927 "`plotly_get_chrome -y` also works). **HTML** export is browser-free and " 

1928 "always available.", 

1929 ), 

1930 ( 

1931 "How do I cite Scanpath Studio?", 

1932 f"See **{ICONS['about']} About** under {ICONS['help']} Help (and `CITATION.cff` in the " 

1933 "repository). Cite the bundled demo data as OneStop Eye Movements too.", 

1934 ), 

1935] 

1936 

1937# PRE-21: FAQ entries that only make sense while a gated feature is exposed. 

1938# Appended by `faq_items()` rather than living in `_FAQ_ITEMS`, so the default 

1939# build never offers an answer about a control it doesn't have. 

1940_DRIFT_FAQ_ITEMS = [ 

1941 ( 

1942 "What does drift correction do?", 

1943 "It reassigns each fixation to the text **line** it most likely belongs " 

1944 "to and snaps it there — the ten algorithms from Carr et al. (2021). It " 

1945 "changes the figure, not your data. Apply one via **Fixations ⚙️ → Drift " 

1946 f"correction**, or compare all ten in the **{ICONS['line_assignment']} Line assignment** subtab.", 

1947 ), 

1948] 

1949 

1950 

1951def _where_data_goes() -> str: 

1952 """ "Where does my data go?" for where this app is running (#374 F22). 

1953 

1954 Local means the server listens on loopback only — its own configuration, 

1955 which ENG-56 made the test for the recovery copy too — as ``scanpath-studio`` 

1956 and the desktop app do. Anything else (the online demo, a bare 

1957 ``streamlit run``) processes an upload on a server other machines reach. 

1958 """ 

1959 from scanpath_studio.persistence import ( 

1960 persistence_enabled, 

1961 server_bound_to_loopback, 

1962 ) 

1963 

1964 if not server_bound_to_loopback(): 

1965 kept = ( 

1966 "a recovery copy is kept there" # opted in: SCANPATH_STUDIO_PERSIST=1 

1967 if persistence_enabled() 

1968 else "not kept" 

1969 ) 

1970 return ( 

1971 "To the server this app runs on, not your computer: a file you " 

1972 f"upload is processed there and {kept}. No accounts, no database, " 

1973 "no analytics — but don't upload identifiable data to a server you " 

1974 "don't control. Run it locally (`pip install scanpath-studio`, then " 

1975 "`scanpath-studio`) to keep it on your computer; a bare `streamlit " 

1976 "run` also needs `--server.address=127.0.0.1`." 

1977 ) 

1978 answer = ( 

1979 "Nowhere: it stays on your computer — no accounts, no database, no " 

1980 "analytics, no upload." 

1981 ) 

1982 if persistence_enabled(): 

1983 answer += ( 

1984 " This run also keeps a **recovery copy** (datasets, mappings, " 

1985 "settings, annotations), so a refresh resumes where you left off; " 

1986 f"**{ICONS['view_data']} Data Management → Saved on this computer** " 

1987 "says what is stored and where." 

1988 ) 

1989 return answer 

1990 

1991 

1992def faq_items() -> list: 

1993 """The FAQ entries this build can honestly answer (PRE-21).""" 

1994 items = [ 

1995 (question, _where_data_goes() if question == _WHERE_DATA_GOES else answer) 

1996 for question, answer in _FAQ_ITEMS 

1997 ] 

1998 if drift_correction_enabled(): 

1999 items.extend(_DRIFT_FAQ_ITEMS) 

2000 return items 

2001 

2002 

2003@st.dialog(f"{ICONS['faq']} Frequently asked questions", width="large") 

2004@guarded() 

2005def _faq_dialog() -> None: 

2006 """The in-app FAQ: short answers in expanders + links to the full docs. 

2007 

2008 Kept short on purpose — this is the "before you file an issue" list, not a 

2009 manual. The docs site carries the complete version (``docs/faq.md``), and 

2010 the link buttons at the bottom are also the app's route into the docs from 

2011 a help context. 

2012 """ 

2013 from scanpath_studio.menu import close_open_popovers 

2014 

2015 # Opened from a button inside the ❓ Help popover, whose open state is 

2016 # client-side — without this it floats on top of the modal. 

2017 close_open_popovers() 

2018 st.caption( 

2019 "Short answers to the questions that come up most. The full version — " 

2020 "with the long explanations — lives on the documentation site." 

2021 ) 

2022 for question, answer in faq_items(): 

2023 with st.expander(question): 

2024 st.markdown(answer) 

2025 

2026 st.divider() 

2027 docs_col, tutorials_col, close_col = st.columns(3) 

2028 docs_col.link_button( 

2029 f"{ICONS['docs']} Full FAQ ↗", 

2030 DOCS_FAQ_URL, 

2031 width="stretch", 

2032 help="Every question, with the long answers. Opens in a new tab.", 

2033 ) 

2034 tutorials_col.link_button( 

2035 f"{ICONS['course']} Tutorials ↗", 

2036 DOCS_TUTORIALS_URL, 

2037 width="stretch", 

2038 help="Task-by-task walkthroughs: data collection, data filtering, " 

2039 "exporting figures, corpus analysis. Opens in a new tab.", 

2040 ) 

2041 if close_col.button("✓ Close", key="faq_close", width="stretch", type="primary"): 

2042 _close_dialog_clientside() 

2043 

2044 

2045def _arm_faq() -> None: 

2046 """Request the FAQ dialog. Called by the ❓ Help nav entry 

2047 (``menu._arm_help_action``). 

2048 

2049 Dialogs can't be opened from there, so this only sets a flag that 

2050 :func:`maybe_show_faq` — called early in ``main()`` — serves. 

2051 """ 

2052 st.session_state["_faq_dialog_requested"] = True 

2053 

2054 

2055def maybe_show_faq() -> None: 

2056 """Open the FAQ dialog if the ❓ Help menu button armed it. 

2057 

2058 Call from ``main()`` next to :func:`maybe_show_welcome_tour`, BEFORE the 

2059 heavy data / plot work. The button sits at the *bottom* of ``main()``, so 

2060 opening the dialog from its return value meant the modal only streamed to 

2061 the browser after the whole rerun — including the ~10 s plot embeds — had 

2062 finished. Served here it appears immediately and overlays whatever renders 

2063 after it. 

2064 """ 

2065 if st.session_state.pop("_faq_dialog_requested", False): 

2066 _faq_dialog() 

2067 

2068 

2069# ----------------------------------------------------------------------------- 

2070# Dataset-setup guide (the "📂 Set up your dataset" wizard's own walkthrough). 

2071# 

2072# A bottom-right floating card (``render_spotlight_wizard_guide``) that walks the 

2073# user through the upload wizard while they fill it in — the same card style and 

2074# instant-dismiss machinery as the welcome spotlight tour, but keyed on its own 

2075# step counter (``wizard_guide_step``) and run under ``tour_mode == "wizard"`` so 

2076# the two never collide. Auto-opens once per session the first time the wizard is 

2077# shown (unless suppressed for embeds/deep-links, or the welcome spotlight tour 

2078# is still on screen so the two don't stack), and is replayable from the 

2079# wizard's "❓ Show setup guide" button. 

2080# 

2081# UX-110: this is the third first-visit walkthrough in the app (after the 

2082# welcome tour and the per-tutorial cards), and until now the only one with no 

2083# persistent opt-out — ``wizard_guide_seen`` gates the auto-open, but it is 

2084# plain session state, so the guide came back to greet every new tab/session 

2085# regardless of a prior "I get it, stop". It now carries the same cookie + 

2086# checkbox shape as the other two. 

2087# ----------------------------------------------------------------------------- 

2088 

2089# One year, path=/, SameSite=Lax — same shape as ``TOUR_OPTOUT_COOKIE``, no 

2090# personal data, just a UI preference. 

2091WIZARD_GUIDE_OPTOUT_COOKIE = "sps_wizard_guide_optout" 

2092 

2093 

2094def wizard_guide_opted_out() -> bool: 

2095 """True when this browser asked never to auto-open the setup guide again. 

2096 

2097 Same shape as :func:`tour_opted_out`: ``_wizard_guide_dismissed`` (a plain 

2098 flag, not the checkbox's own widget key — see that function's docstring 

2099 for why) wins within a session, falling back to the cookie, then to 

2100 ``False`` with no request context (bare mode / AppTest). 

2101 """ 

2102 if "_wizard_guide_dismissed" in st.session_state: 

2103 return bool(st.session_state["_wizard_guide_dismissed"]) 

2104 try: 

2105 return st.context.cookies.get(WIZARD_GUIDE_OPTOUT_COOKIE) == "1" 

2106 except Exception: 

2107 return False 

2108 

2109 

2110def _wizard_guide_optout_script(opted_out: bool) -> str: 

2111 """A same-origin script that writes (or clears) the opt-out cookie.""" 

2112 if opted_out: 

2113 value = f"{WIZARD_GUIDE_OPTOUT_COOKIE}=1; max-age={_TOUR_OPTOUT_MAX_AGE}" 

2114 else: 

2115 value = f"{WIZARD_GUIDE_OPTOUT_COOKIE}=; max-age=0" 

2116 return ( 

2117 "<script>window.parent.document.cookie = " 

2118 f'"{value}; path=/; SameSite=Lax";</script>' 

2119 ) 

2120 

2121 

2122def _render_wizard_guide_optout() -> None: 

2123 """The "Don't show this again" checkbox for the setup guide (UX-110). 

2124 

2125 One call site today (the guide's own running card) — there is no picker 

2126 equivalent to sync with, unlike the welcome tour and the per-tutorial 

2127 cards, so this is simpler than :func:`_render_tour_optout`: no second 

2128 widget key, no last-synced reconciliation, just seed-once-then-let-the- 

2129 widget-own-it, same as any ordinary Streamlit checkbox. Kept as its own 

2130 function (rather than inlined) so a second surface — should one ever 

2131 replay this guide from somewhere else — has one place to reuse. 

2132 """ 

2133 key = "wizard_guide_dont_show" 

2134 if key not in st.session_state: 

2135 st.session_state[key] = wizard_guide_opted_out() 

2136 opted_out = st.checkbox( 

2137 "Don't show this again", 

2138 key=key, 

2139 help=f"Skip the setup guide on future visits. **{ICONS['help']} Show setup guide** in " 

2140 "the wizard always brings it back.", 

2141 ) 

2142 st.session_state["_wizard_guide_dismissed"] = opted_out 

2143 embed_html_iframe(_wizard_guide_optout_script(opted_out), height=0) 

2144 

2145 

2146# (title, markdown body) per step — an overview, one per part of the wizard in 

2147# order, then the closing Save card. 

2148# DATA-22 §4: the guide is now *anchored*. Each step names the wizard step it 

2149# talks about (so Next drives the accordion instead of narrating beside it) and a 

2150# CSS selector to highlight + scroll to. Keyed expanders give every step a 

2151# `.st-key-wiz_open_<id>` selector for free, so no new wrapper containers were 

2152# needed; finer targets reuse existing widget keys. 

2153_WIZARD_GUIDE_STEPS = [ 

2154 { 

2155 "title": f"{ICONS['datasets']} Set up your dataset", 

2156 "body": ( 

2157 "Turn your eye-tracking tables into an interactive dataset in three " 

2158 "parts: name it, upload and map each table (and pick which extras " 

2159 "to keep), and describe the recording setup — then save it. " 

2160 "Follow along with **Next**, or **Skip** to dive in." 

2161 ), 

2162 "selector": "", 

2163 "step_id": None, 

2164 }, 

2165 { 

2166 "title": "1 · Name & description", 

2167 "body": ( 

2168 f"Name it — this is what shows up on the {ICONS['view_data']} **Data Management** page and in the " 

2169 "dataset picker, so you can switch back to it later. A description " 

2170 "is optional." 

2171 ), 

2172 "selector": ".st-key-wiz_part_name", 

2173 "step_id": "name", 

2174 }, 

2175 { 

2176 "title": "2 · Upload data tables", 

2177 "body": ( 

2178 "Every table uploads and maps in its own row here — Fixations, " 

2179 "Words (interest areas), Raw gaze, then Participant/Trial/Text " 

2180 "metadata. " 

2181 "Under each mapping, pick which extra columns to keep. " 

2182 "Anything still missing is listed " 

2183 f"above **{ICONS['confirm']} Add dataset**." 

2184 ), 

2185 "selector": ".st-key-wiz_part_data", 

2186 "step_id": "data", 

2187 }, 

2188 { 

2189 "title": "3 · Recording setup", 

2190 "body": ( 

2191 "Say how you know the screen, physical size and text size the " 

2192 "data was recorded with. Pick one answer for each — nothing is " 

2193 "chosen for you, because a wrong guess here silently rescales " 

2194 "every figure." 

2195 ), 

2196 "selector": ".st-key-wiz_part_setup", 

2197 "step_id": "setup", 

2198 }, 

2199 # The way out of the three parts, not a fourth part — no `step_id`, so the 

2200 # progress line reads "Last step" rather than "Part 4 of 3". 

2201 { 

2202 "title": f"{ICONS['confirm']} Save it", 

2203 "body": ( 

2204 f"**{ICONS['confirm']} Add dataset** saves it and opens it, ready to " 

2205 f"explore. **{ICONS['download']} Download setup file** downloads this mapping " 

2206 "and recording setup as a file — load it with *Restore a saved " 

2207 "setup* the next time you add data shaped like this." 

2208 ), 

2209 "selector": ".st-key-wizard_footer_row", 

2210 "step_id": None, 

2211 }, 

2212] 

2213 

2214 

2215#: While the setup guide is open on a wide screen, the page keeps a gutter the 

2216#: card's width on the right, so the card sits beside the wizard instead of over 

2217#: its upload rows and the mappings that open to their right. A narrow screen 

2218#: has no room for one, and keeps the card floating over the page. 

2219_WIZARD_GUIDE_GUTTER_CSS = """ 

2220@media (min-width: 1100px) { 

2221 [data-testid="stMainBlockContainer"] { 

2222 padding-right: calc(410px + 2.5rem) !important; 

2223 } 

2224} 

2225""" 

2226 

2227 

2228def _wizard_guide_go(step_idx: int) -> None: 

2229 """Move the guide to ``step_idx`` and open the wizard step it describes. 

2230 

2231 This is what makes the card *drive* the wizard rather than narrate beside it. 

2232 ``go_to_step`` is safe here because a guide button is an ``on_click`` 

2233 callback: it runs before the script re-executes, so the ``wiz_open_*`` writes 

2234 land before the expanders instantiate. 

2235 """ 

2236 from . import wizard_shell 

2237 

2238 step_idx = max(0, min(step_idx, len(_WIZARD_GUIDE_STEPS) - 1)) 

2239 st.session_state["wizard_guide_step"] = step_idx 

2240 target = _WIZARD_GUIDE_STEPS[step_idx].get("step_id") 

2241 if target: 

2242 wizard_shell.go_to_step(target) 

2243 

2244 

2245def _wizard_guide_back() -> None: 

2246 _wizard_guide_go(st.session_state.get("wizard_guide_step", 0) - 1) 

2247 

2248 

2249def _wizard_guide_next() -> None: 

2250 _wizard_guide_go(st.session_state.get("wizard_guide_step", 0) + 1) 

2251 

2252 

2253def _exit_wizard_guide() -> None: 

2254 st.session_state["tour_mode"] = None 

2255 

2256 

2257@st.fragment 

2258@guarded() 

2259def render_spotlight_wizard_guide() -> None: 

2260 """Floating bottom-right card walking through the dataset-setup wizard. 

2261 

2262 The setup guide as a spotlight card (like the welcome tour) rather than a 

2263 blocking modal, so it sits in the corner and the user follows along while 

2264 filling in each wizard step. Shares the welcome tour's card CSS + instant 

2265 dismiss machinery but uses its own ``wizard_guide_step`` counter and 

2266 ``tour_mode == "wizard"`` so the two never collide. No backdrop / highlight / 

2267 scroll — the steps are descriptive, not anchored to specific controls. 

2268 

2269 Call early in the wizard's render (``app._render_data_setup``) so the card 

2270 streams to the browser before the heavy upload/normalize work. Runs as a 

2271 fragment: Back/Next/Skip rerun only this card. 

2272 """ 

2273 if st.session_state.get("tour_mode") != "wizard": 

2274 return 

2275 n = len(_WIZARD_GUIDE_STEPS) 

2276 step_idx = min(st.session_state.get("wizard_guide_step", 0), n - 1) 

2277 step = _WIZARD_GUIDE_STEPS[step_idx] 

2278 title, body, selector = step["title"], step["body"], step["selector"] 

2279 

2280 accent = st.get_option("theme.primaryColor") or "#1f77b4" 

2281 theme = getattr(getattr(st, "context", None), "theme", None) 

2282 is_dark = getattr(theme, "type", "light") == "dark" 

2283 bg, border = ("#262730", "#41434e") if is_dark else ("#ffffff", "#d5d6d9") 

2284 st.markdown( 

2285 "<style>" 

2286 + _CARD_CSS 

2287 + f".st-key-tour_card {{ background: {bg}; border: 1px solid {border}; }}" 

2288 + _WIZARD_GUIDE_GUTTER_CSS 

2289 + _highlight_css(selector, accent) 

2290 + "</style>", 

2291 unsafe_allow_html=True, 

2292 ) 

2293 if selector: 

2294 embed_html_iframe(_scroll_into_view_script(selector), height=0) 

2295 

2296 with st.container(key="tour_card"): 

2297 # <h2> for a valid heading outline; sized down via `.st-key-tour_card h2`. 

2298 st.markdown(f"## {title}") 

2299 st.markdown(body) 

2300 # Counted in the screen's own parts ("2 · Upload data tables" is part 2 

2301 # of 3), not in cards: neither the overview card nor the closing Save 

2302 # card is a part, and "Step 3 of 4" under a "2 ·" heading contradicted 

2303 # the "three parts" it opens with. 

2304 n_parts = sum(1 for s in _WIZARD_GUIDE_STEPS if s["step_id"]) 

2305 if step["step_id"]: 

2306 progress_text = f"Part {step_idx} of {n_parts}" 

2307 elif step_idx: 

2308 progress_text = "Last step · save" 

2309 else: 

2310 progress_text = f"Overview · {n_parts} parts" 

2311 st.progress((step_idx + 1) / n, text=progress_text) 

2312 # UX-110: same placement rule as the welcome tour's own opt-out — only 

2313 # where a user decides they're done with the guide (the first step, 

2314 # bailing out now, or the last, got it, don't greet me again), so the 

2315 # short middle step keeps its tight vertical rhythm. 

2316 if step_idx in (0, n - 1): 

2317 _render_wizard_guide_optout() 

2318 back_col, exit_col, next_col = st.columns(3) 

2319 back_col.button( 

2320 "← Back", 

2321 key="wizard_sp_back", 

2322 width="stretch", 

2323 disabled=step_idx == 0, 

2324 on_click=_wizard_guide_back, 

2325 ) 

2326 if step_idx < n - 1: 

2327 exit_col.button( 

2328 "Skip", 

2329 key="wizard_sp_exit", 

2330 width="stretch", 

2331 on_click=_exit_wizard_guide, 

2332 ) 

2333 next_col.button( 

2334 "Next →", 

2335 key="wizard_sp_next", 

2336 width="stretch", 

2337 type="primary", 

2338 on_click=_wizard_guide_next, 

2339 ) 

2340 else: 

2341 next_col.button( 

2342 "✓ Got it", 

2343 key="wizard_sp_done", 

2344 width="stretch", 

2345 type="primary", 

2346 on_click=_exit_wizard_guide, 

2347 ) 

2348 # Hide the card instantly on Skip/Done, even while the wizard's first run 

2349 # is still loading (see _dismiss_listener_script). 

2350 embed_html_iframe( 

2351 _dismiss_listener_script( 

2352 None, exit_keys=("wizard_sp_exit", "wizard_sp_done") 

2353 ), 

2354 height=0, 

2355 ) 

2356 

2357 

2358def _arm_wizard_guide() -> None: 

2359 """``on_click`` callback for the wizard's "❓ Show setup guide" button. 

2360 

2361 Arms the bottom-right guide card from step 0. Callbacks run before the rerun, 

2362 so ``render_spotlight_wizard_guide`` (called as the wizard renders) picks it 

2363 up on the same run — mirroring ``_arm_tour`` for the welcome tour. 

2364 """ 

2365 st.session_state["wizard_guide_step"] = 0 

2366 st.session_state["tour_mode"] = "wizard" 

2367 

2368 

2369def maybe_show_wizard_guide() -> None: 

2370 """Arm the dataset-setup guide automatically the first time the wizard is 

2371 shown in a session (the replay button arms it on demand via ``_arm_wizard_guide``). 

2372 

2373 Call as the active wizard renders, immediately followed by 

2374 ``render_spotlight_wizard_guide()`` which draws the card. The auto-open is 

2375 skipped for embeds/deep-links, while the welcome spotlight tour is still 

2376 on screen (so the two walkthroughs never stack), and — UX-110 — for a 

2377 browser that already asked never to see it again via 

2378 :func:`wizard_guide_opted_out`. ``wizard_guide_seen`` is set *before* 

2379 arming (mirroring ``tour_seen``) so a dismissal doesn't re-open it on the 

2380 next rerun. 

2381 """ 

2382 if st.session_state.get("wizard_guide_seen"): 

2383 return 

2384 if ( 

2385 tour_suppressed(st.query_params) 

2386 or st.session_state.get("tour_mode") == "spotlight" 

2387 or wizard_guide_opted_out() 

2388 ): 

2389 return 

2390 st.session_state["wizard_guide_seen"] = True # before arming — see docstring 

2391 st.session_state["wizard_guide_step"] = 0 

2392 st.session_state["tour_mode"] = "wizard" 

2393 

2394 

2395def render_wizard_guide_button(host) -> None: 

2396 """A button inside the wizard that (re)opens the setup guide from step 1.""" 

2397 host.button( 

2398 "Show setup guide", 

2399 key="wizard_guide_replay", 

2400 icon=ICONS["help"], 

2401 # A menu row in the wizard's *Setup help* popover, matching the 

2402 # documentation link beneath it. 

2403 type="tertiary", 

2404 help="Walk through the dataset setup, step by step.", 

2405 on_click=_arm_wizard_guide, 

2406 )