Coverage for scanpath_studio/styles.py: 97%

31 statements  

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

1"""CSS styles for the Scanpath Studio Streamlit app.""" 

2 

3from __future__ import annotations 

4 

5from scanpath_studio.constants import ( 

6 SELECTOR_ROW_FLOOR_CAPS, 

7 SELECTOR_ROW_FLOORS_REM, 

8 SELECTOR_SCREEN_FLOOR_REM, 

9 SELECTOR_STEPS_FLOOR_REM, 

10) 

11 

12 

13def get_app_css() -> str: 

14 """Return custom CSS to reduce whitespace and disable animations.""" 

15 css = """ 

16 <style> 

17 /* Force LTR regardless of the browser's own OS/locale default direction. 

18 Every plot, coordinate, and reading-order concept in this app is 

19 physical left-to-right, but nothing here ever states that — so a 

20 browser whose UI language is a RTL one (Hebrew, Arabic, ...) inherits 

21 `direction: rtl` onto the page, and BaseWeb (the component library 

22 Streamlit's own widgets are built on) styles several of them with CSS 

23 *logical* properties that flip under it. The clearest case is the 

24 select-slider: its thumb is positioned with a physical `left: X%` (so 

25 it still lands at the right spot), but its filled track segment uses a 

26 logical inset that flips to the *other* end — the fill looks 

27 nowhere near the thumb it is supposed to lead up to. Rather than only 

28 patching the slider, force ltr globally: anything else BaseWeb draws 

29 with a logical property would drift the same way, silently. 

30 Bug reported with a Hebrew-locale browser (screenshot: thumb correct, 

31 fill anchored to the wrong end). */ 

32 html, body, [data-testid="stApp"] { 

33 direction: ltr !important; 

34 /* UX-129: the page itself must never grow a side-to-side scrollbar — 

35 whatever pushes past the viewport (a too-wide row, a tooltip that 

36 escapes its column) should be clipped, not turned into a reason to 

37 scroll the whole app sideways. Elements that legitimately need 

38 horizontal scroll (a wide dataframe) already carry their own, 

39 narrower `overflow-x: auto` and are unaffected by clipping here. */ 

40 overflow-x: hidden; 

41 } 

42 /* UX-99: the page's side gutters. Streamlit's wide layout reserves ~5rem 

43 either side, which on this app is ~10rem of nothing beside the widest 

44 things it draws — the scanpath canvas plus its rail, the Corpus tables, 

45 the dataset table. Trimmed to a gutter that still keeps text off the 

46 window edge. `!important` because Streamlit's own padding rule carries 

47 higher specificity than a bare class selector. */ 

48 .stMainBlockContainer, 

49 section.main > div.block-container { 

50 padding-top: 3rem; 

51 padding-bottom: 0 !important; 

52 padding-left: 1.5rem !important; 

53 padding-right: 1.5rem !important; 

54 } 

55 @media (max-width: 640px) { 

56 .stMainBlockContainer, 

57 section.main > div.block-container { 

58 padding-left: 0.75rem !important; 

59 padding-right: 0.75rem !important; 

60 } 

61 } 

62 .stMainBlockContainer > [data-testid="stVerticalBlock"] > :last-child, 

63 section.main > div.block-container > [data-testid="stVerticalBlock"] > :last-child { 

64 margin-bottom: 0 !important; 

65 } 

66 /* Remove all whitespace around plotly charts */ 

67 div[data-testid="stPlotlyChart"] {margin: 0 !important; padding: 0 !important; line-height: 0 !important;} 

68 div[data-testid="stPlotlyChart"] > div {margin: 0 !important; padding: 0 !important;} 

69 div[data-testid="stPlotlyChart"] iframe {display: block !important; margin: 0 !important; padding: 0 !important;} 

70 /* UX-188: an inline iframe sits on the text baseline, leaving a descender's 

71 gap under it that its overflow:auto container turned into a scrollbar 

72 with nothing to scroll (8px under the plot, 25px under each script-only 

73 embed). */ 

74 iframe[data-testid="stIFrame"] {display: block;} 

75 .stPlotlyChart {margin: 0 !important; padding: 0 !important;} 

76 /* Target parent containers */ 

77 div[data-testid="stVerticalBlock"] > div:has(> div[data-testid="stPlotlyChart"]) {padding: 0 !important; margin: 0 !important; gap: 0 !important;} 

78 div[data-testid="element-container"]:has(> div[data-testid="stPlotlyChart"]) {margin: 0 !important; padding: 0 !important;} 

79 /* Reduce gap in vertical blocks globally */ 

80 div[data-testid="stVerticalBlock"] {gap: 0rem !important;} 

81 div[data-testid="stVerticalBlock"] > div {margin-bottom: 0.25rem !important;} 

82 /* Target the js-plotly-plot container */ 

83 .js-plotly-plot, .plot-container, .plotly {margin: 0 !important; padding: 0 !important;} 

84 .main-svg {display: block !important;} 

85 /* Remove extra spacing from streamlit elements near charts */ 

86 div[data-testid="stMarkdown"] + div[data-testid="element-container"]:has(div[data-testid="stPlotlyChart"]) {margin-top: 0 !important;} 

87 div[data-testid="element-container"]:has(div[data-testid="stPlotlyChart"]) + div[data-testid="stExpander"] {margin-top: 0.5rem !important;} 

88 /* Reduce spacing around dataframes */ 

89 div[data-testid="stDataFrame"] {margin-bottom: 0 !important;} 

90 div[data-testid="element-container"]:has(div[data-testid="stDataFrame"]) {margin-bottom: 0.25rem !important;} 

91 /* Reduce multiselect spacing */ 

92 div[data-testid="stMultiSelect"] {margin-bottom: 0.25rem !important;} 

93 /* Disable fade in/out animations on element updates */ 

94 div[data-testid="stPlotlyChart"], div[data-testid="element-container"], .stMarkdown, .element-container { 

95 animation: none !important; 

96 transition: none !important; 

97 } 

98 div[data-testid="stPlotlyChart"] * { 

99 animation: none !important; 

100 transition: none !important; 

101 } 

102 /* Disable Streamlit's stale element fade effect */ 

103 [data-stale="true"] { 

104 opacity: 1 !important; 

105 } 

106 /* Navigation (Scanpath ⇄ Corpus Analysis) is Streamlit's own top nav — 

107 `st.navigation(position="top")`, rendered into the header strip. It needs 

108 no CSS from us: it is platform chrome, it costs no page height, and 

109 styling it would just make it look less like the rest of Streamlit. The 

110 old right-aligned `.st-key-header_buttons` rule went with the single 

111 toggle button it aligned. */ 

112 /* === The top menu bar ==================================================== 

113 Replaced the left sidebar: every group that used to be an 

114 `st.sidebar` section is a popover in this one row (see menu.py). 

115 

116 UX-38 got it down to two triggers (❓ Help · 💾 Session, plus 🐛 Debug), 

117 at which point a whole page row for two buttons was the wrong trade — so 

118 it now shares the **title row**, right-aligned over where the Scanpath 

119 view's control rail begins. That is plain `st.columns`, not CSS: there is 

120 no supported way to put widgets in Streamlit's own header strip, and 

121 positioning them into it means `position: fixed` against an internal test 

122 id whose width depends on which toolbar buttons that deployment shows. 

123 

124 So all this rule does now is stop the buttons stretching to fill their 

125 column; the alignment is the container's own `horizontal_alignment`. 

126 

127 (UX-8's sidebar collapse/expand styling lived here. It is gone with the 

128 sidebar: there is no longer any chrome to collapse.) */ 

129 

130 /* === The Data page, off-screen =========================================== 

131 DATA-26. The setup widgets — the loaders' directory input and ⬇ Download 

132 button, the source options, the column-mapping selectboxes — *drive* 

133 `prepare_data` on every rerun, and Streamlit drops the key of a widget 

134 that did not render. So `app.main` builds the page every run and switches 

135 only its key: visible under `data_setup_page`, hidden under this one. 

136 

137 `display: none` (not `visibility`/`opacity`/off-viewport): the widgets 

138 must keep executing but must contribute no layout, and the tour's 

139 `findVisible()` picks targets by their layout rect, so a hidden copy of a 

140 spotlight target has to measure zero rather than sit off to one side. */ 

141 .st-key-data_setup_page_offscreen { display: none !important; } 

142 

143 /* DATA-35 — the Data page's two screens: the overview (the dataset table + 

144 what's in the open dataset) and the ✏️ Edit dataset screen. Same 

145 mechanism and same reason as the page above, one level in: the editor is 

146 *made* of the widgets that drive `prepare_data`, so it renders every run 

147 and is hidden by key rather than skipped. */ 

148 .st-key-data_overview_offscreen { display: none !important; } 

149 .st-key-data_dataset_editor_offscreen { display: none !important; } 

150 

151 /* UX-174 — 📂 Available datasets as a focused table, built from keyed 

152 containers (`app.render_dataset_table`): `dsrow_head` + one 

153 `dsrow_<slug>` line per dataset (`dsrow_current_<slug>` for the open 

154 one), each cell a fixed-width `dsc_<column>_<slug>` / `dsh_<column>` box 

155 so the columns line up down the list. The grid scrolls sideways on its 

156 own when it is wider than the page, with the name held in view. */ 

157 .st-key-dataset_table_grid { 

158 overflow-x: auto; 

159 gap: 0 !important; 

160 } 

161 /* The page title sits straight above the table (UX-177): give the header 

162 row a little air so the title does not read as part of it. */ 

163 .st-key-dataset_table { 

164 margin-top: 0.75rem; 

165 } 

166 .st-key-dataset_table_grid > div { margin-bottom: 0 !important; } 

167 .st-key-dataset_table_grid [class*="st-key-dsrow_"] { 

168 min-width: max-content; 

169 padding: 0.45rem 0.5rem; 

170 border-bottom: 1px solid var(--sps-border); 

171 } 

172 .st-key-dataset_table_grid .st-key-dsrow_head { 

173 padding-top: 0.1rem; 

174 padding-bottom: 0.1rem; 

175 border-bottom-color: rgba(128, 128, 128, 0.45); 

176 } 

177 /* The open dataset: a tint *and* the Current badge — never color alone. */ 

178 .st-key-dataset_table_grid [class*="st-key-dsrow_current_"] { 

179 background: var(--sps-accent-soft); 

180 } 

181 /* A keyed container's class sits on its inner block; the flex item that 

182 takes the width is the `stLayoutWrapper` around it — so the cell rules 

183 below select that wrapper, by what it holds. Every cell but the name 

184 keeps its width; the name gives way first, down to its minimum, and past 

185 that the grid scrolls instead. */ 

186 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_"]), 

187 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsh_"]) { 

188 flex-shrink: 0 !important; 

189 } 

190 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]), 

191 [data-testid="stLayoutWrapper"]:has(> .st-key-dsh_name) { 

192 flex-shrink: 1 !important; 

193 min-width: 15rem; 

194 position: sticky; 

195 left: 0; 

196 z-index: 1; 

197 } 

198 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]) { 

199 background: var(--sps-page-bg); 

200 } 

201 /* Opaque (it scrolls over the counts), in the open row's tint. */ 

202 [class*="st-key-dsrow_current_"] > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]) { 

203 background: 

204 linear-gradient(var(--sps-accent-soft), var(--sps-accent-soft)), 

205 var(--sps-page-bg); 

206 } 

207 /* UX-174 r2 — the whole row opens its dataset. Its first child is the 

208 row's own button, stretched over the row; every cell is drawn above it 

209 and lets a click fall through to it, except the one holding Remove. The 

210 button's label ("Open <name>") is for screen readers — the name is 

211 drawn in its cell. */ 

212 .st-key-dataset_table_grid [class*="st-key-dsrow_"] { position: relative; } 

213 [class*="st-key-dsrow_"] > [class*="st-key-dataset_open_"] { 

214 position: absolute !important; 

215 inset: 0; 

216 width: auto !important; 

217 margin: 0 !important; 

218 z-index: 0; 

219 } 

220 [class*="st-key-dataset_open_"] .stButton, 

221 [class*="st-key-dataset_open_"] button { 

222 width: 100%; 

223 height: 100%; 

224 } 

225 [class*="st-key-dataset_open_"] button { 

226 border: 0; 

227 border-radius: 0; 

228 background: transparent; 

229 cursor: pointer; 

230 } 

231 [class*="st-key-dataset_open_"] button [data-testid="stMarkdownContainer"] { 

232 position: absolute !important; width: 1px; height: 1px; 

233 overflow: hidden; clip-path: inset(50%); white-space: nowrap; 

234 } 

235 [class*="st-key-dsrow_current_"] > [class*="st-key-dataset_open_"] button { 

236 cursor: default; 

237 } 

238 [class*="st-key-dsrow_"] > [data-testid="stLayoutWrapper"] { 

239 position: relative; 

240 z-index: 1; 

241 pointer-events: none; 

242 } 

243 [class*="st-key-dsrow_"] > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]) { 

244 position: sticky; 

245 } 

246 [class*="st-key-dsrow_"] > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_actions_"]), 

247 [class*="st-key-dsc_name_"] [data-testid="stTooltipHoverTarget"] { 

248 pointer-events: auto; 

249 } 

250 .st-key-dataset_table_grid [class*="st-key-dsrow_"]:not(.st-key-dsrow_head):not([class*="st-key-dsrow_current_"]):hover, 

251 .st-key-dataset_table_grid [class*="st-key-dsrow_"]:not(.st-key-dsrow_head):not([class*="st-key-dsrow_current_"]):hover > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]) { 

252 background: 

253 linear-gradient(var(--sps-hover-soft), var(--sps-hover-soft)), 

254 var(--sps-page-bg); 

255 } 

256 .st-key-dataset_table_grid [class*="st-key-dsrow_"]:has([class*="st-key-dataset_open_"] button:focus-visible) { 

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

258 outline-offset: -2px; 

259 } 

260 .sps-ds-name { font-weight: 600; } 

261 /* Streamlit pulls each block up by a negative bottom margin and gives a 

262 paragraph its own, which in one-line cells puts their text at different 

263 heights. */ 

264 [class*="st-key-dsc_"] [data-testid="stMarkdownContainer"] { margin-bottom: 0 !important; } 

265 [class*="st-key-dsc_"] [data-testid="stMarkdownContainer"] p { margin: 0; } 

266 [class*="st-key-dsc_"] > div { margin-bottom: 0 !important; } 

267 .st-key-dataset_table_grid [class*="st-key-dsrow_"]:not(.st-key-dsrow_head) { 

268 min-height: 2.75rem; 

269 } 

270 .sps-ds-num { 

271 display: block; 

272 text-align: right; 

273 font-variant-numeric: tabular-nums; 

274 white-space: nowrap; 

275 } 

276 .sps-ds-gap { opacity: 0.6; font-size: 0.85rem; } 

277 .sps-ds-status { white-space: nowrap; font-size: 0.9rem; } 

278 /* Header labels are sort buttons; keep them quiet and on one line. */ 

279 .st-key-dsrow_head button { 

280 padding: 0 !important; 

281 min-height: 0; 

282 font-size: 0.875rem; 

283 } 

284 .st-key-dsrow_head button p { white-space: nowrap; font-size: 0.875rem; } 

285 /* Remove is its icon; the label names the dataset for screen readers. */ 

286 [class*="st-key-dataset_row_remove_"] button { 

287 padding: 0 0.35rem !important; 

288 min-height: 0; 

289 } 

290 [class*="st-key-dataset_row_remove_"] button [data-testid="stMarkdownContainer"] { 

291 position: absolute !important; width: 1px; height: 1px; 

292 overflow: hidden; clip-path: inset(50%); white-space: nowrap; 

293 } 

294 .st-key-dataset_table_grid [class*="st-key-dataset_row_remove_"] button:focus-visible, 

295 .st-key-dsrow_head button:focus-visible { 

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

297 outline-offset: 2px; 

298 border-radius: 0.25rem; 

299 } 

300 /* Phone width: the name, its Current badge, the key count and Remove. 

301 Everything else is in *What's in the dataset*, once it is open. */ 

302 @media (max-width: 640px) { 

303 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_"]):not(:has(> [class*="st-key-dsc_name_"])):not(:has(> [class*="st-key-dsc_participants_"])):not(:has(> [class*="st-key-dsc_actions_"])), 

304 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsh_"]):not(:has(> .st-key-dsh_name)):not(:has(> .st-key-dsh_participants)):not(:has(> .st-key-dsh_actions)) { 

305 display: none !important; 

306 } 

307 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]), 

308 [data-testid="stLayoutWrapper"]:has(> .st-key-dsh_name) { 

309 min-width: 0; 

310 } 

311 [class*="st-key-dsrow_"] > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_name_"]), 

312 [class*="st-key-dsrow_"] > [data-testid="stLayoutWrapper"]:has(> .st-key-dsh_name) { 

313 position: relative; 

314 } 

315 .st-key-dataset_table_grid [class*="st-key-dsrow_"] { min-width: 0; } 

316 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-dsc_participants_"]), 

317 [data-testid="stLayoutWrapper"]:has(> .st-key-dsh_participants) { 

318 width: auto !important; 

319 min-width: 3.5rem; 

320 } 

321 } 

322 

323 /* UX-53 — the 🗂️ Data page was "too much space and text, and text too 

324 small". Scoped to the page's own key so the plot rail and the analysis 

325 views keep the metrics they were tuned against (#UX-51 sized the rail 

326 deliberately, and a global type change would move it). 

327 

328 Two levers, both conservative: close the vertical gap Streamlit puts 

329 between every block, and lift the *smallest* type — captions carry most 

330 of this page's prose, and they were the part that read as too small. */ 

331 .st-key-data_setup_page [data-testid="stVerticalBlock"] { gap: 0.55rem; } 

332 .st-key-data_setup_page [data-testid="stCaptionContainer"], 

333 .st-key-data_setup_page [data-testid="stCaptionContainer"] p { 

334 font-size: 0.86rem; 

335 line-height: 1.45; 

336 } 

337 /* The dividers between the page's four stages were doing the spacing job 

338 twice — a rule plus a margin either side of it. */ 

339 .st-key-data_setup_page hr { margin: 0.7rem 0; } 

340 .st-key-data_setup_page [data-testid="stExpander"] { margin-bottom: 0.35rem; } 

341 

342 div[data-testid="stPopover"] button { border-radius: 999px; } 

343 div[data-testid="stPopover"] button p { white-space: nowrap; } 

344 /* BUG-89 — a click on a popover's ▾ must land on the button, not the 

345 chevron glyph. Opening swaps that glyph (expand_more → expand_less), so 

346 the span the pointer hit is detached by the time the click bubbles to 

347 `document`. There Streamlit's own outside-click handler ignores the 

348 opening click only within 50 ms of it; if the popover takes longer to 

349 render (the rail's big ones often do), the detached target fails its 

350 "inside the trigger?" test and the popover closes itself 2 ms after 

351 opening. The next click then closes an already-closed popover, which is 

352 why it took 2–3 clicks. With the glyph transparent to the pointer the 

353 target is always an element that survives the re-render. The chevron is 

354 the one `aria-hidden` child of the trigger; its label and icon keep 

355 their nodes. */ 

356 [data-testid="stPopoverButton"] [aria-hidden="true"], 

357 [data-testid="stPopoverButton"] [aria-hidden="true"] * { 

358 pointer-events: none; 

359 } 

360 /* BUG-108: a mode or rail-section trigger is named ("Fixation settings") 

361 so a screen reader can tell them apart, but only its chevron is drawn 

362 (UX-80 r2): the label stays in the accessibility tree, clipped to 

363 nothing, as `.sps-sr-only` does. 

364 UX-200 named the other icon-only popovers the same way: the + menu, 

365 the table previews, the chip-field editor and the ⇅ sorts, each keyed 

366 `iconpop_*` (the + menu kept its own key). A plain label, so the 

367 popover's dialog, which takes the label as its `aria-label`, is named 

368 the same. */ 

369 .st-key-add_dataset_menu [data-testid="stPopoverButton"] 

370 [data-testid="stMarkdownContainer"], 

371 [class*="st-key-iconpop_"] [data-testid="stPopoverButton"] 

372 [data-testid="stMarkdownContainer"], 

373 [class*="st-key-split_mode_"] [data-testid="stPopoverButton"] 

374 [data-testid="stMarkdownContainer"] { 

375 position: absolute !important; width: 1px; height: 1px; 

376 margin: -1px; padding: 0; border: 0; overflow: hidden; 

377 clip-path: inset(50%); white-space: nowrap; 

378 } 

379 /* …and ⇅ is a typographic glyph, not an icon `icon=` can take, so the 

380 sorts draw it here. The second `content` gives it empty alt text, which 

381 keeps it out of the accessible name; a browser that does not know the 

382 syntax drops that line and keeps the first. */ 

383 [class*="st-key-iconpop_sort_"] [data-testid="stPopoverButton"]::before { 

384 content: "⇅"; 

385 content: "⇅" / ""; 

386 } 

387 /* UX-200: an icon-only *button* names itself with `constants.spoken`, an 

388 `<em>` in its label clipped the same way, so the glyph or icon stays 

389 all that is drawn. No button label uses emphasis for anything else 

390 (`tests/test_popover_names.py`). */ 

391 button [data-testid="stMarkdownContainer"] em { 

392 position: absolute !important; width: 1px; height: 1px; 

393 margin: -1px; padding: 0; border: 0; overflow: hidden; 

394 clip-path: inset(50%); white-space: nowrap; 

395 } 

396 div[data-testid="stPopoverBody"] { 

397 min-width: min(28rem, 90vw); 

398 } 

399 /* The wizard's *Setup help* is a two-row menu, not a panel of controls: 

400 as wide as its rows, like the nav's own ❓ Help menu, rather than 28rem 

401 of empty popover to the right of two short labels. The body is 

402 portalled out of the wizard, so it is found by the row it holds. */ 

403 div[data-testid="stPopoverBody"]:has(.st-key-wizard_guide_replay) { 

404 min-width: 0; 

405 } 

406 div[data-testid="stPopoverBody"] p { line-height: 1.45; } 

407 

408 /* === Streamlit's spinners (UX-165) ====================================== 

409 The cache_data spinners and st.spinner — calm and inline now. They used 

410 to be a pulsing blue banner; the long waits have loading cards 

411 (loading.py) since UX-165, and what is left is short enough that a 

412 banner shouted louder than the wait deserved. */ 

413 div[data-testid="stSpinner"] { 

414 width: fit-content; 

415 padding: 0.35rem 0.75rem !important; 

416 margin: 0.3rem 0 !important; 

417 border: 1px solid var(--sps-border); 

418 border-radius: 999px; 

419 background: var(--sps-page-bg); 

420 } 

421 div[data-testid="stSpinner"] p { font-size: 0.9rem; margin: 0; } 

422 

423 /* === UX-165 · loading states ============================================= 

424 One card for every long wait (loading.py): hidden for its first 

425 loading.DELAY_S, then revealed by a timer thread writing `.sps-reveal` 

426 into it. A region card sits over a size box that holds its area at the 

427 height the content will take; the page card sits over a skeleton of the 

428 view. Nothing here animates for readers who ask for reduced motion. */ 

429 [class*="st-key-sps_cardbody_"] { display: none !important; } 

430 /* A card opens hidden — the page card on every run — so until it shows, 

431 its slot is out of the layout: an empty slot still takes a gap, and the 

432 page card's margins would push the whole view down and back on each 

433 rerun. A size box keeps it in: holding the area is that box's job. */ 

434 /* Flat on purpose: `:has()` may not nest inside `:has()`, and a browser 

435 drops a rule that tries. The wrapper holds only its card, so "no 

436 .sps-reveal in the wrapper" is "the card isn't showing". */ 

437 [data-testid="stLayoutWrapper"]:has(> [class*="st-key-sps_card_"]):not(:has(.sps-reveal)):not(:has(.sps-size-box)) { display: none !important; } 

438 [class*="st-key-sps_card_"]:has(.sps-reveal) [class*="st-key-sps_cardbody_"] { 

439 display: flex !important; 

440 flex-direction: column; 

441 gap: 0.4rem !important; 

442 width: 100%; 

443 box-sizing: border-box; 

444 padding: 0.8rem 1rem 0.6rem; 

445 border: 1px solid var(--sps-border); 

446 border-radius: 12px; 

447 background: var(--sps-page-bg); 

448 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08); 

449 } 

450 /* One track, as wide as the card's container — which the size box's own 

451 rule caps at the figure's width (loading.size_box_html) — so "center" 

452 means over the figure, and every width resolves from the column down: a 

453 track sized by its content would grow past a narrow column, or collapse 

454 to the card. */ 

455 [class*="st-key-sps_card_"]:not(.st-key-sps_card_page) { 

456 display: grid !important; 

457 grid-template-columns: minmax(0, 1fr); 

458 } 

459 [class*="st-key-sps_card_"]:not(.st-key-sps_card_page) > * { grid-area: 1 / 1; } 

460 /* Streamlit stretches a block's wrapper to the full width, and no 

461 `justify-self` moves a stretched box — so it takes the card's width. */ 

462 [class*="st-key-sps_card_"] > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-sps_cardbody_"]) { 

463 width: min(24rem, 100%) !important; 

464 align-self: center; 

465 justify-self: center; 

466 z-index: 2; 

467 } 

468 /* A card with no area to hold sits where it is written, like its neighbours. */ 

469 [class*="st-key-sps_card_"]:not(.st-key-sps_card_page):not(:has(.sps-size-box)) > [data-testid="stLayoutWrapper"]:has(> [class*="st-key-sps_cardbody_"]) { justify-self: start; } 

470 /* The reveal marker is an empty element in the body's flex column; hidden, it 

471 still counts for the gap, leaving a blank strip under the last row. It 

472 stays in the DOM, so the `:has(.sps-reveal)` rules keep matching. */ 

473 [class*="st-key-sps_cardbody_"] > [data-testid="stElementContainer"]:has(.sps-reveal) { display: none !important; } 

474 .sps-size-box { width: 100%; pointer-events: none; } 

475 [class*="st-key-sps_card_"]:has(.sps-reveal) .sps-size-box { 

476 border-radius: 8px; 

477 background: 

478 linear-gradient(rgba(128, 128, 128, 0.10), rgba(128, 128, 128, 0.10)), 

479 color-mix(in srgb, var(--sps-page-bg) 60%, transparent); 

480 animation: sps-sk-pulse 1.6s ease-in-out infinite; 

481 } 

482 /* Streamlit pulls every markdown block 1rem up (margin-bottom: -1rem on its 

483 container), so the head and the step list add it back to keep a gap. */ 

484 .sps-card-head { display: flex; align-items: center; gap: 0.55rem; margin-bottom: 1.2rem; } 

485 .sps-card-title { font-weight: 600; flex: 1; min-width: 0; } 

486 /* Spoken, not drawn: the current step in the card's live region, which the 

487 detail line or step list already shows (loading.head_html). */ 

488 .sps-sr-only { 

489 position: absolute !important; width: 1px; height: 1px; 

490 margin: -1px; padding: 0; border: 0; overflow: hidden; 

491 clip-path: inset(50%); white-space: nowrap; 

492 } 

493 .sps-card-time { 

494 font-size: 0.8rem; opacity: 0.7; white-space: nowrap; 

495 font-variant-numeric: tabular-nums; 

496 } 

497 .sps-card-detail, .sps-step-count, .sps-step-time { 

498 font-size: 0.85rem; opacity: 0.8; font-variant-numeric: tabular-nums; 

499 } 

500 .sps-ring { 

501 display: inline-block; flex: none; 

502 width: 0.95rem; height: 0.95rem; box-sizing: border-box; 

503 border-radius: 50%; 

504 border: 2px solid var(--sps-accent-border); 

505 border-top-color: var(--sps-accent); 

506 animation: sps-spin 0.8s linear infinite; 

507 } 

508 .sps-steps { 

509 list-style: none; margin: 0 0 1.3rem; padding: 0; 

510 display: flex; flex-direction: column; gap: 0.3rem; 

511 } 

512 .sps-step { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; margin: 0 !important; } 

513 .sps-step .sps-icon { font-size: 1rem; } 

514 .sps-step-done .sps-icon { color: #2e9d5b; } 

515 .sps-step-todo { opacity: 0.5; } 

516 .sps-step-label { flex: 1; min-width: 0; } 

517 .sps-bar { height: 4px; border-radius: 2px; overflow: hidden; background: var(--sps-accent-soft); } 

518 .sps-bar > span { display: block; height: 100%; background: var(--sps-accent); transition: width 0.25s ease; } 

519 .sps-bar-indeterminate > span { width: 35%; animation: sps-slide 1.3s ease-in-out infinite; } 

520 /* Breathing room between the bar and the card's Cancel button. */ 

521 [class*="st-key-sps_cancel_"] { margin-top: 0.75rem; } 

522 /* The page card: a skeleton of the view with the card over it. While it 

523 shows, everything else in the view's area — the previous page, or the 

524 new one being laid out underneath — stays hidden. */ 

525 .st-key-sps_view:has(.sps-reveal-page) > :not(:first-child) { display: none !important; } 

526 /* On the Data view the area holds only the last view's leftovers (the Data page draws outside it). */ 

527 .st-key-sps_view:has(.sps-view-hidden) > :not(:first-child) { display: none !important; } 

528 .st-key-sps_card_page { display: grid !important; grid-template-columns: minmax(0, 1fr); } 

529 .st-key-sps_card_page > * { grid-area: 1 / 1; } 

530 .st-key-sps_card_page:has(.sps-reveal) > [data-testid="stLayoutWrapper"]:has(> .st-key-sps_cardbody_page) { 

531 align-self: start; justify-self: center; margin-top: 7rem; 

532 } 

533 .st-key-sps_card_page:has(.sps-sk-scanpath) > [data-testid="stLayoutWrapper"]:has(> .st-key-sps_cardbody_page) { 

534 justify-self: start; margin-left: max(0px, calc(40% - 12rem)); 

535 } 

536 .sps-page-skeleton { pointer-events: none; } 

537 .sps-sk { 

538 border-radius: 0.5rem; background: rgba(128, 128, 128, 0.12); 

539 animation: sps-sk-pulse 1.6s ease-in-out infinite; 

540 } 

541 .sps-sk-scanpath { display: grid; grid-template-columns: 4fr 1fr; gap: 3rem; } 

542 .sps-sk-main { display: flex; flex-direction: column; gap: 0.7rem; min-width: 0; } 

543 .sps-sk-selectors { display: grid; gap: 1rem; } 

544 .sps-sk-field { height: 2.5rem; } 

545 .sps-sk-chips { display: flex; gap: 0.45rem; flex-wrap: wrap; } 

546 .sps-sk-chip { width: 7.5rem; height: 1.7rem; border-radius: 999px; } 

547 .sps-sk-rail { 

548 display: flex; flex-direction: column; gap: 0.5rem; 

549 padding-left: 1rem; border-left: 1px solid var(--sps-border); 

550 } 

551 .sps-sk-row { height: 2.3rem; } 

552 .sps-sk-corpus { display: flex; flex-direction: column; gap: 1rem; } 

553 .sps-sk-tabs { height: 2.4rem; width: 60%; } 

554 .sps-sk-chart { height: 22rem; } 

555 .sps-sk-table { display: flex; flex-direction: column; gap: 0.4rem; } 

556 .sps-sk-table .sps-sk-row { height: 2rem; } 

557 /* UX-167 — the Scanpath plot area is a stage: its first child (the loading 

558 card) and its second (the figure) share one cell, so a card can open over 

559 a figure already on screen without moving it. Notes written after the 

560 figure flow into the rows below. */ 

561 .st-key-tour_grp_plot { display: grid !important; grid-template-columns: minmax(0, 1fr); } 

562 .st-key-tour_grp_plot > :nth-child(-n + 2) { grid-area: 1 / 1; } 

563 .st-key-tour_grp_plot > :first-child { z-index: 3; } 

564 /* A card already says what the app is waiting on. */ 

565 .stApp:has(.sps-reveal) div[data-testid="stSpinner"] { display: none !important; } 

566 @keyframes sps-spin { to { transform: rotate(360deg); } } 

567 @keyframes sps-sk-pulse { 50% { opacity: 0.55; } } 

568 @keyframes sps-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } } 

569 @media (prefers-reduced-motion: reduce) { 

570 .sps-ring, .sps-sk, .sps-bar-indeterminate > span, 

571 [class*="st-key-sps_card_"]:has(.sps-reveal) .sps-size-box { animation: none !important; } 

572 .sps-bar > span { transition: none !important; } 

573 } 

574 

575 /* === Visual polish ========================================================== 

576 Tasteful, theme-robust chrome styling (header, tabs, chips, cards, buttons). 

577 Colors are either the brand blue (which reads on both the light and dark 

578 themes) or translucent neutrals (gray/blue at low alpha) that tint whatever 

579 background sits behind them, so a single rule set works in both themes 

580 without depending on a theme class Streamlit doesn't expose. The scientific 

581 scanpath plot itself is untouched — only the surrounding UI is styled. */ 

582 .stApp { 

583 --sps-accent: #1f77b4; 

584 --sps-accent-soft: rgba(31, 119, 180, 0.10); 

585 --sps-accent-border: rgba(31, 119, 180, 0.22); 

586 --sps-border: rgba(128, 128, 128, 0.22); 

587 /* UX-174 r2 — a row under the pointer (the dataset table). */ 

588 --sps-hover-soft: rgba(128, 128, 128, 0.08); 

589 --sps-code-fg: #15639c; 

590 --sps-shadow-hover: 0 6px 18px rgba(31, 119, 180, 0.16); 

591 /* UX-145 — the page background, for the few surfaces that must be 

592 opaque (a sticky bar content scrolls under). Streamlit exposes no 

593 CSS variable for it on the main page, and prefers-color-scheme is 

594 the OS preference, not the theme picked in ⋮ → Settings. But 

595 Streamlit does set `color-scheme` on `.stApp` to match the active 

596 theme, and `light-dark()` resolves against it — so this follows a 

597 theme switch instantly, without a rerun. The two colors are 

598 `constants.APP_THEME` / `APP_THEME_DARK`'s backgroundColor, pinned 

599 by tests/test_theme.py. */ 

600 --sps-page-bg: light-dark(#ffffff, #0e1117); 

601 } 

602 /* In dark mode the brand blue is too dark for badge text; brighten it. 

603 The app's theme is "Auto" (follows the OS) in the common case, so the OS 

604 preference and prefers-color-scheme agree here. */ 

605 @media (prefers-color-scheme: dark) { 

606 .stApp { --sps-code-fg: #8fc7f5; } 

607 } 

608 

609 /* UX-7 empty-state panels — "no trials match" and "this corpus isn't here 

610 yet". Both used to be a warning banner + a caption + a body paragraph + 

611 a button: four blocks, three background colors, one message. They are now 

612 a single amber-tinted card, so the diagnosis visibly belongs to the 

613 headline above it. Amber (not red) on purpose: nothing is broken, the user 

614 just has to choose something. */ 

615 .st-key-empty_state_panel, 

616 .st-key-dataset_unavailable_panel { 

617 background: rgba(240, 173, 78, 0.09); 

618 border-color: rgba(240, 173, 78, 0.42) !important; 

619 border-radius: 0.6rem; 

620 } 

621 .st-key-empty_state_panel [data-testid="stHeading"] h4, 

622 .st-key-dataset_unavailable_panel [data-testid="stHeading"] h4 { 

623 margin-top: 0; 

624 padding-top: 0; 

625 font-weight: 700; 

626 } 

627 /* The per-filter Clear buttons sit in a right-hand column; keep them quiet 

628 so the primary "Clear all filters" stays the obvious escape hatch. */ 

629 .st-key-empty_state_panel div[class*="st-key-clear_one_filter_"] button { 

630 padding: 0.15rem 0.5rem; 

631 min-height: 1.9rem; 

632 font-size: 0.8rem; 

633 } 

634 

635 /* Page title: a restrained brand-blue gradient + tighter tracking. One <h1> 

636 exists (st.title in the header), so this scopes cleanly to it. */ 

637 [data-testid="stHeading"] h1 { 

638 background: linear-gradient(95deg, #1f77b4 0%, #4a9fd4 70%); 

639 -webkit-background-clip: text; 

640 background-clip: text; 

641 -webkit-text-fill-color: transparent; 

642 color: transparent; 

643 font-weight: 800; 

644 letter-spacing: -0.015em; 

645 } 

646 /* Section headings (### / st.subheader) — a touch heavier and tighter. */ 

647 [data-testid="stHeading"] h2, 

648 [data-testid="stHeading"] h3 { font-weight: 700; letter-spacing: -0.005em; } 

649 

650 /* Tabs: hover affordance, bolder labels, brand-tinted active label. */ 

651 [data-testid="stTabs"] [data-baseweb="tab-list"] { gap: 0.25rem; } 

652 [data-testid="stTab"] { 

653 padding: 0.45rem 0.85rem; 

654 border-radius: 8px 8px 0 0; 

655 transition: background 0.15s ease, color 0.15s ease; 

656 } 

657 [data-testid="stTab"]:hover { background: var(--sps-accent-soft); } 

658 [data-testid="stTab"] p { font-weight: 600; } 

659 [data-testid="stTab"][aria-selected="true"] p { color: var(--sps-accent); } 

660 

661 /* Inline code chips (Trial / Participant / Text ids, etc.) -> clean pill 

662 badges. `:not(pre code)` leaves multi-line code blocks (e.g. the BibTeX in 

663 the About popover) alone. */ 

664 [data-testid="stMarkdownContainer"] code:not(pre code) { 

665 background: var(--sps-accent-soft); 

666 border: 1px solid var(--sps-accent-border); 

667 border-radius: 6px; 

668 padding: 0.05rem 0.4rem; 

669 font-weight: 600; 

670 color: var(--sps-code-fg); 

671 } 

672 

673 /* Expander / bordered-container cards: rounder corners + a subtle hover lift. 

674 Covers the in-page expanders (Annotations, Trial metadata, Export) and the 

675 rail's grouped layer sections. The former sidebar group cards are gone — 

676 those groups are menu popovers now. */ 

677 [data-testid="stExpander"] details { 

678 border: 1px solid var(--sps-border); 

679 border-radius: 10px; 

680 transition: border-color 0.15s ease, box-shadow 0.15s ease; 

681 } 

682 [data-testid="stExpander"] details:hover { 

683 border-color: var(--sps-accent-border); 

684 box-shadow: var(--sps-shadow-hover); 

685 } 

686 [data-testid="stExpander"] summary { font-weight: 600; border-radius: 10px; } 

687 

688 /* Buttons: smooth hover with a slight lift + brand-blue glow. Scoped to real 

689 buttons, so the app-wide "animation: none" rules (which target plot/element 

690 containers, not buttons) don't apply. */ 

691 [data-testid="stBaseButton-secondary"], 

692 [data-testid="stBaseButton-primary"] { 

693 transition: transform 0.12s ease, box-shadow 0.15s ease, 

694 border-color 0.15s ease, background 0.15s ease; 

695 } 

696 [data-testid="stBaseButton-secondary"]:hover, 

697 [data-testid="stBaseButton-primary"]:hover { 

698 transform: translateY(-1px); 

699 box-shadow: var(--sps-shadow-hover); 

700 } 

701 

702 /* === Scanpath screen: condition chips + control rail ==================== 

703 The viz controls moved out of the sidebar into a rail beside the plot, so 

704 the trial's key experiment conditions ride above the plot as a compact 

705 chip table and the rail reads as a tidy inspector panel. */ 

706 /* UX-190 / UX-195 — the chips as a table, in place of the wrapping chip 

707 strip (UX-11): one column per field and one row per reading — Compare's 

708 A above B, with a value they share written in both rows, quieter, so 

709 what differs stands out. A header label, and a text value at its 

710 spaces, wrap only once the table would otherwise be wider than its 

711 column; past that the table scrolls sideways (with a scrollbar that 

712 stays drawn) rather than push the plot down. The selectors are long on purpose — they must 

713 beat the table styles Streamlit's markdown gives every table. */ 

714 .sps-chip-table-wrap { 

715 overflow-x: auto; 

716 margin: 0.1rem 0 0.5rem; 

717 } 

718 /* macOS hides an overlay scrollbar until you scroll, so a table wider than 

719 its column read as clipped columns, not as one to scroll. Styling the 

720 scrollbar keeps it drawn whenever the table overflows. */ 

721 .sps-chip-table-wrap::-webkit-scrollbar, 

722 .st-key-dataset_table_grid::-webkit-scrollbar { 

723 height: 6px; 

724 } 

725 .sps-chip-table-wrap::-webkit-scrollbar-thumb, 

726 .st-key-dataset_table_grid::-webkit-scrollbar-thumb { 

727 background: color-mix(in srgb, currentColor 30%, transparent); 

728 border-radius: 3px; 

729 } 

730 .sps-chip-table-wrap::-webkit-scrollbar-track, 

731 .st-key-dataset_table_grid::-webkit-scrollbar-track { 

732 background: transparent; 

733 } 

734 /* Firefox has no ::-webkit-scrollbar (and Chrome ignores those rules once 

735 the standard properties are set, hence the guard). */ 

736 @supports not selector(::-webkit-scrollbar) { 

737 .sps-chip-table-wrap, 

738 .st-key-dataset_table_grid { 

739 scrollbar-width: thin; 

740 } 

741 } 

742 /* #374 F11 — and an edge shadow on the side that has more, so a cut 

743 column reads as "scroll" even where scrollbars are hidden. The two 

744 `local` covers scroll with the content and hide each shadow at its 

745 end; the two `scroll` shadows stay at the edges. */ 

746 .sps-chip-table-wrap, 

747 .st-key-dataset_table_grid { 

748 background: 

749 linear-gradient(to right, var(--sps-page-bg) 40%, transparent) left / 2rem 100% no-repeat local, 

750 linear-gradient(to left, var(--sps-page-bg) 40%, transparent) right / 2rem 100% no-repeat local, 

751 radial-gradient(farthest-side at 0 50%, rgba(128, 128, 128, 0.35), transparent) left / 0.7rem 100% no-repeat scroll, 

752 radial-gradient(farthest-side at 100% 50%, rgba(128, 128, 128, 0.35), transparent) right / 0.7rem 100% no-repeat scroll; 

753 } 

754 .sps-chip-table-wrap table.sps-chip-table { 

755 width: auto; 

756 margin: 0; 

757 border: none; 

758 border-collapse: collapse; 

759 font-size: 0.875rem; 

760 line-height: 1.45; 

761 color: inherit; 

762 } 

763 .sps-chip-table-wrap table.sps-chip-table th, 

764 .sps-chip-table-wrap table.sps-chip-table td { 

765 padding: 0.2rem 0.55rem; 

766 border: none; 

767 border-bottom: 1px solid var(--sps-border); 

768 background: transparent; 

769 text-align: left; 

770 vertical-align: middle; 

771 white-space: nowrap; 

772 font-weight: 500; 

773 } 

774 .sps-chip-table-wrap table.sps-chip-table thead th { 

775 padding-top: 0; 

776 vertical-align: bottom; 

777 white-space: normal; 

778 font-size: 0.75rem; 

779 font-weight: 600; 

780 line-height: 1.25; 

781 color: color-mix(in srgb, currentColor 62%, transparent); 

782 } 

783 /* Values never wrap: each reading is one line (2026-10-07), so a long 

784 trial id ("l37_1129 · 2_2_1_Adv") no longer doubles its row's height. 

785 A table too wide for its column scrolls sideways instead. */ 

786 .sps-ct-tint { 

787 white-space: nowrap; 

788 display: inline-block; 

789 padding: 0 0.45rem; 

790 border-radius: 999px; 

791 } 

792 .sps-chip-table-wrap table.sps-chip-table tbody tr:last-child > * { 

793 border-bottom: none; 

794 } 

795 .sps-chip-table-wrap table.sps-chip-table th.sps-ct-side { 

796 padding-left: 0; 

797 font-weight: 700; 

798 } 

799 .sps-chip-table-wrap table.sps-chip-table .sps-ct-num { 

800 text-align: right; 

801 font-variant-numeric: tabular-nums; 

802 } 

803 .sps-chip-table-wrap table.sps-chip-table td.sps-ct-same, 

804 .sps-chip-table-wrap table.sps-chip-table td.sps-ct-missing { 

805 font-weight: 400; 

806 color: color-mix(in srgb, currentColor 62%, transparent); 

807 } 

808 .sps-ct-dot { 

809 display: inline-block; 

810 width: 0.6rem; 

811 height: 0.6rem; 

812 margin-right: 0.4rem; 

813 border-radius: 50%; 

814 vertical-align: 0.02em; 

815 } 

816 /* UX-42: Data source and Filter by share a row but are separate tasks (and 

817 separate tour targets). A quiet rule makes that boundary legible; the 

818 inset keeps the Filter-by label from sitting directly against it. */ 

819 .st-key-tour_grp_narrow_by { 

820 box-sizing: border-box; 

821 border-left: 1px solid var(--sps-border); 

822 padding-left: 0.7rem; 

823 } 

824 /* UX-68 — the Animate / Compare split buttons. Python puts the mode toggle 

825 and its ▾ settings trigger on one row (`tabs.render_single_trial_tab`); 

826 these rules are what make the two read as ONE control rather than as a 

827 switch that happens to have a button parked beside it. 

828 

829 The outline goes on the row, not on either half, because the halves are 

830 different kinds of thing: Streamlit's toggle is a bare switch + label with 

831 no chrome of its own, and only the popover trigger arrives as a bordered 

832 button. So the row draws the border and the radius, `overflow: hidden` 

833 clips the button's square corners back to it, and the button gives up 

834 everything that would read as a second control — its own border, its 

835 radius, its background — keeping only a 1px left edge as the divider 

836 between the halves. That divider is the whole visual claim: one control, 

837 two things you can press. 

838 

839 `align-items: stretch` is what makes the divider span the full height 

840 instead of floating as a short dash beside the switch; it overrides the 

841 `vertical_alignment="center"` the container is built with, which is still 

842 right for the no-CSS fallback. 

843 

844 Shrinking the button is not cosmetic. Streamlit's default popover trigger 

845 is ~55px wide around a 16px glyph, and the widest of the two toggles is 

846 ~137px against a ~195px rail — with the default the row fits by about 

847 3px, and anything that nudges either half (a longer label, a wider rail 

848 font) wraps the ▾ onto its own line. At a glyph's width there is room to 

849 spare. */ 

850 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] { 

851 width: 100%; 

852 max-width: 100%; 

853 min-height: 2.7rem; 

854 align-items: stretch !important; 

855 border: 1px solid var(--sps-border); 

856 /* A rounded rectangle, not a pill — it is what the Zoom control being 

857 copied actually is, and a 999px radius on the wrapped two-line state 

858 turns into a lozenge. */ 

859 border-radius: 0.6rem; 

860 padding-left: 0.55rem; 

861 } 

862 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

863 [data-testid="stElementContainer"] { 

864 display: flex; 

865 align-items: center; 

866 } 

867 /* ENG-43: Streamlit 1.62's native `wrap=False` now owns the one-line 

868 contract. CSS only allocates the flexible and fixed halves. */ 

869 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

870 > div:not(:has([data-testid="stPopover"])) { 

871 min-width: 0; 

872 flex: 1 1 auto; 

873 padding-right: 0.35rem; 

874 } 

875 /* UX-153 — a rail row with no switch (Filters & highlights, 📐 Figure & canvas) is 

876 one control, so its name opens the popover. The ▾ trigger's click target 

877 is stretched over the whole row by an `::after` overlay, which keeps the 

878 row's look, and the popover still anchors on the ▾ itself. The row is 

879 the overlay's containing block, so nothing between it and the button 

880 may be positioned. `transform: none` matters for the same reason: 

881 the app-wide hover lift (`translateY(-1px)`) would make the button 

882 the containing block mid-hover, shrinking the overlay out from under 

883 the pointer. The rows with a switch don't get this: their name is the 

884 switch's label and flips it, as on Animate and Compare. */ 

885 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_rail_"]:not( 

886 :has([data-testid="stCheckbox"]) 

887 ) { 

888 position: relative; 

889 } 

890 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_rail_"]:not( 

891 :has([data-testid="stCheckbox"]) 

892 ) [data-testid="stPopover"] button { 

893 position: static !important; 

894 transform: none !important; 

895 } 

896 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_rail_"]:not( 

897 :has([data-testid="stCheckbox"]) 

898 ) [data-testid="stPopover"] button::after { 

899 content: ""; 

900 position: absolute; 

901 inset: 0; 

902 } 

903 /* ...and the whole row lights up on hover, not only the ▾ half, since 

904 the whole row is what a click opens. */ 

905 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_rail_"]:not( 

906 :has([data-testid="stCheckbox"]) 

907 ):has([data-testid="stPopover"] button:hover:enabled) { 

908 background: var(--sps-accent-soft); 

909 } 

910 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

911 [data-testid="stWidgetLabel"] p, 

912 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

913 [data-testid="stMarkdownContainer"] p { 

914 white-space: nowrap; 

915 overflow: hidden; 

916 text-overflow: ellipsis; 

917 } 

918 /* #374 F19 — the picker and rail-switch names are bold here, not as `**` 

919 in the label string: Streamlit reads a label verbatim as the widget's 

920 accessible name, so the markdown was announced. */ 

921 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

922 [data-testid="stCheckbox"] [data-testid="stWidgetLabel"] p, 

923 .st-key-data_source_picker [data-testid="stWidgetLabel"] p, 

924 .st-key-single_trial_id [data-testid="stWidgetLabel"] p, 

925 .st-key-single_compare_trial [data-testid="stWidgetLabel"] p, 

926 .st-key-cmp_dataset [data-testid="stWidgetLabel"] p { 

927 font-weight: 600; 

928 } 

929 /* UX-153 — every toggle in these rows takes `wrap=True`, which switches 

930 off Streamlit's truncate mode (and the native `title=` tooltip it 

931 stamps), and with it the `min-width: 0` chain that let the label 

932 shrink to an ellipsis. Both are put back here. The `p *` arm is for a 

933 bold label's <strong>, which must not wrap "Raw gaze" onto two lines in 

934 a row that is one line by contract. */ 

935 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

936 [data-testid="stWidgetLabel"] p * { 

937 white-space: nowrap; 

938 } 

939 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

940 [data-testid="stCheckbox"], 

941 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

942 [data-testid="stCheckbox"] label, 

943 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

944 [data-testid="stWidgetLabel"], 

945 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

946 [data-testid="stWidgetLabel"] [data-testid="stMarkdownContainer"] { 

947 min-width: 0; 

948 max-width: 100%; 

949 } 

950 /* UX-102 — and the same `p` must give up its bottom margin, or the row 

951 grows a scrollbar. Streamlit 1.62 gives every `wrap=False` horizontal 

952 container `overflow-x: auto`, and CSS then promotes `overflow-y` from 

953 `visible` to `auto` along with it: each row is a scroll box, so anything 

954 that overhangs it by a pixel shows a scrollbar on hover. A markdown `p` 

955 carries `margin-bottom: 1rem` that Streamlit cancels with a matching 

956 negative margin on `stMarkdownContainer` — which fixes the layout but not 

957 `scrollHeight`, and the margin still counts there. So exactly the three 

958 name-only sections (📄 Stimulus · Filters & highlights · 📐 Figure & canvas, the ones 

959 drawn with a name instead of a switch) scrolled 8px and lost 11px of 

960 width to the scrollbar's gutter, while the five with a toggle did not. 

961 Zeroing both margins is the fix rather than `overflow: visible`, because 

962 it removes the overhang instead of hiding it — the row keeps the 

963 horizontal clipping Streamlit put there. Nothing moves: the label is 

964 centerd by the flex row either way. */ 

965 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

966 [data-testid="stMarkdownContainer"], 

967 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

968 [data-testid="stMarkdownContainer"] p { 

969 margin-bottom: 0; 

970 } 

971 /* The divider is drawn on the popover's SLOT — the row's own child — and not 

972 on the button, which is the obvious place and does not work. A trigger 

973 given `help=` is wrapped by Streamlit in a tooltip chain 

974 (stPopover > div > div > stTooltipIcon > stTooltipHoverTarget > button), 

975 every link of which sizes to the glyph, so a border on the button renders 

976 as an 18px dash floating in a 24px row rather than as a seam. Stretching 

977 that whole chain means naming emotion classes; the slot is already 

978 full-height because it is a flex child of the row. `:has()` picks it out 

979 without depending on `stLayoutWrapper` being the wrapper's name. */ 

980 /* The corner rounding is on the slot rather than clipped off the row with 

981 `overflow: hidden`, and the row is left free to WRAP. Both were tried the 

982 other way and both are traps: the switch will not shrink below about 

983 131px (its own min-content), so on a 142px rail a non-wrapping row put the 

984 ▾ 38px past the right edge, where `overflow: hidden` deleted it — a 

985 control that silently cannot be reached. Wrapping costs a rounded box with 

986 the ▾ tucked under the switch, which reads more like a card than a split 

987 button; that is the right way to lose. */ 

988 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

989 > div:has([data-testid="stPopover"]) { 

990 display: flex; 

991 align-items: center; 

992 justify-content: stretch; 

993 /* 2.6rem, not 2.8: at a 1280px window the rail's longest layer name, 

994 "Word boxes", missed by 2px (round 9) — the ▾ needs no more. */ 

995 flex: 0 0 2.6rem; 

996 min-width: 2.6rem; 

997 border-left: 1px solid var(--sps-border); 

998 border-radius: 0 0.6rem 0.6rem 0; 

999 } 

1000 /* Streamlit wraps a helped popover trigger in several glyph-sized divs. 

1001 Stretch that chain too; widening only the outer slot leaves the actual 

1002 click target at the chevron's width. */ 

1003 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1004 > div:has([data-testid="stPopover"]) [data-testid="stPopover"], 

1005 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1006 > div:has([data-testid="stPopover"]) [data-testid="stPopover"] > div, 

1007 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1008 > div:has([data-testid="stPopover"]) [data-testid="stTooltipIcon"], 

1009 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1010 > div:has([data-testid="stPopover"]) [data-testid="stTooltipHoverTarget"] { 

1011 display: flex; 

1012 width: 100%; 

1013 height: 100%; 

1014 } 

1015 /* Target the popover's own button, never `… button`: the toggle's label 

1016 carries Streamlit's `?` help icon, which is also a button and would 

1017 otherwise be restyled along with it. */ 

1018 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1019 [data-testid="stPopover"] button { 

1020 min-width: 100% !important; 

1021 min-height: 100% !important; 

1022 height: 100% !important; 

1023 width: 100% !important; 

1024 padding: 0 0.65rem !important; 

1025 border: 0 !important; 

1026 border-radius: 0 !important; 

1027 background: transparent !important; 

1028 box-shadow: none !important; 

1029 } 

1030 /* UX-200: `box-shadow: none` above also took Streamlit's focus ring, so a 

1031 keyboard user tabbing along the rail lost track of which ▾ was focused. 

1032 An outline instead, inset because the row is a scroll box (UX-102) that 

1033 would clip one drawn outside the button, in the accent the dataset 

1034 table's rows already focus with. */ 

1035 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1036 [data-testid="stPopover"] button:focus-visible { 

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

1038 outline-offset: -2px; 

1039 } 

1040 /* The hover tint goes on the slot too, so the whole half lights up to the 

1041 seam instead of only the glyph's own box. `:has(button:hover:enabled)` 

1042 rather than `:hover` keeps a disabled ▾ inert — it is disabled exactly 

1043 when its mode is off, and a hover response would promise a menu that, 

1044 while it does open, is entirely grayed. */ 

1045 [data-testid="stHorizontalBlock"][class*="st-key-split_mode_"] 

1046 > div:has([data-testid="stPopover"] button:hover:enabled) { 

1047 background: var(--sps-accent-soft); 

1048 } 

1049 /* UX-27 / CMP-10 — ONE button shape for the control rows stacked above the 

1050 plot. They are built in three different functions across two modules 

1051 (the Narrow-by/More row and the chip strip's Details/✏️ in tabs.py, the 

1052 comparison picker's ◀ ▶ ⇅ in tabs.py, and the main trial picker's ◀ ▶ ⇅ 

1053 in utils.py), each with its own `st.columns` and its own width unit, so 

1054 they used to render at different heights and two different shapes — 

1055 square icon buttons beside pill-shaped labeled ones. 

1056 Rather than hand-tuning each call site, every trigger in the block goes in 

1057 a container keyed `railbtn_*` and takes its geometry from here. 

1058 

1059 The shape is the chip pill (it was already tuned to sit against the chip 

1060 strip, and it is the smallest of the three, so adopting it shrinks the 

1061 cluster rather than growing it). `min-width` is what squares up the 

1062 icon-only buttons: without it ◀, ▶ and ⇅ each collapse to their own glyph 

1063 width. */ 

1064 [class*="st-key-railbtn_"] button { 

1065 min-height: 0 !important; 

1066 min-width: 2.3rem; 

1067 padding: 0.1rem 0.65rem !important; 

1068 border-radius: 999px !important; 

1069 font-size: 0.9rem !important; 

1070 line-height: 1.55 !important; 

1071 white-space: nowrap; 

1072 } 

1073 /* Right-pack every cluster, at one spacing. Each row ends in a trailing 

1074 column that already stops at the container's right edge, but a Streamlit 

1075 vertical block is a flex COLUMN of full-width children, so a content-width 

1076 button sat at the LEFT of its slot: the three rows' ends were ragged by up 

1077 to 43px, and once the right-most three were flushed the *second* control 

1078 in each row (◀ ▶, "Summary stats") was still adrift — one full column gutter in 

1079 from its neighbour, at a different offset per row. 

1080 

1081 So a row's trailing controls now share ONE `railbtn_*` container and this 

1082 rule makes every such container a right-packed flex row. "More" alone, 

1083 both ◀ ▶ ⇅ picker clusters, and the chip row's ✏️ therefore all end on the 

1084 same edge with the same 3px between neighbours, whatever each row's own 

1085 column split is. Nesting is intentional (`_trail` > `_step`): the outer cluster 

1086 fixes the display order, and the inner containers let a trigger be *filled* 

1087 out of order — the sort popover has to run before the ◀ ▶ it precedes in 

1088 the DOM. */ 

1089 [class*="st-key-railbtn_"] { 

1090 flex-direction: row; 

1091 justify-content: flex-end; 

1092 align-items: center; 

1093 } 

1094 /* Streamlit gives each child of a vertical block `width: 100%`, so in a flex 

1095 ROW every child claims the container's full width and the group overflows 

1096 to the LEFT instead of packing to the right — `width: auto` is what makes 

1097 each one content-sized. The 3px between neighbours is a margin, not the 

1098 container's `gap`: Streamlit's own two-class `.stVerticalBlock.st-emotion-*` 

1099 gap rule outranks a single attribute selector, so `gap` here computes to 

1100 0 and the pill outlines butt into one double-thick line. */ 

1101 [class*="st-key-railbtn_"] > div { 

1102 flex: 0 0 auto !important; 

1103 width: auto !important; 

1104 } 

1105 [class*="st-key-railbtn_"] > div + div { margin-left: 3px !important; } 

1106 /* UX-181: the floors under the `SELECTOR_ROW_GRID` tracks 

1107 (`SELECTOR_ROW_FLOORS_REM`). A row of this grid is a column row whose 

1108 last column holds a `railbtn_*` cluster directly (◀ ▶ ⇅ 🔎 on the trial 

1109 rows, ◀ ▶ on the screen navigator, ✏️ on the chip strip). The nested 

1110 `_step` / `_sort` containers don't count. 

1111 - Every column gets `min-width: 0` first. Otherwise a column's automatic 

1112 minimum is its content, and A's dataset cell (dropdown + "+") stopped 

1113 at a different width than B's, so the two rows drifted apart. 

1114 - Then each track gets the same floor on every row: the dataset track on 

1115 any row of three or four columns, the trial track on four-column rows 

1116 only (on a three-column row the second column is trial + scrubber 

1117 merged), and the actions track wherever it is. A column holding its 

1118 floor leaves the rest of the shrinking to the columns beside it, so 

1119 the tracks still share their edges. 

1120 - Streamlit's column row wraps, so a floor wider than the column's share 

1121 would push it onto a line of its own. The row is kept on one line 

1122 instead, above the 640px width where Streamlit stacks columns on 

1123 purpose. 

1124 On the column rules, `:where()` keeps the row match at zero specificity, 

1125 so each floor outranks the `min-width: 0`. The `nowrap` rule keeps its 

1126 specificity, since it has to outrank Streamlit's own `flex-wrap`. */ 

1127 @media (min-width: 640px) { 

1128 [data-testid="stHorizontalBlock"]:has(> [data-testid="stColumn"] 

1129 > [data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] 

1130 > [class*="st-key-railbtn_"]) { 

1131 flex-wrap: nowrap; 

1132 } 

1133 :where([data-testid="stHorizontalBlock"]:has(> [data-testid="stColumn"] 

1134 > [data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] 

1135 > [class*="st-key-railbtn_"])) > [data-testid="stColumn"] { 

1136 min-width: 0; 

1137 } 

1138 :where([data-testid="stHorizontalBlock"]:has(> [data-testid="stColumn"] 

1139 > [data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] 

1140 > [class*="st-key-railbtn_"])):has(> [data-testid="stColumn"]:nth-child(3)) 

1141 > [data-testid="stColumn"]:first-child { 

1142 min-width: __SELECTOR_FLOOR_0__; 

1143 } 

1144 :where([data-testid="stHorizontalBlock"]:has(> [data-testid="stColumn"] 

1145 > [data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] 

1146 > [class*="st-key-railbtn_"])):has(> [data-testid="stColumn"]:nth-child(4)) 

1147 > [data-testid="stColumn"]:nth-child(2) { 

1148 min-width: __SELECTOR_FLOOR_1__; 

1149 } 

1150 [data-testid="stColumn"]:has(> [data-testid="stVerticalBlock"] 

1151 > [data-testid="stLayoutWrapper"] > [class*="st-key-railbtn_"]) { 

1152 min-width: __SELECTOR_FLOOR_3__; 

1153 } 

1154 /* A trial row with a screen cell splits its actions: ◀ ▶ stay by the 

1155 slider on a narrow track of their own, and the row's last track 

1156 (`SELECTOR_SCREEN_TRACK`) holds the screen dropdown + ◀ ▶ followed by 

1157 ⇅ 🔎 ✏️ (`utils.row_tail`). Both tracks keep the same floor on A's 

1158 row and B's, so the two rows line up. Later than the actions floor 

1159 above, which they override. */ 

1160 [data-testid="stColumn"]:has(> [data-testid="stVerticalBlock"] 

1161 > [data-testid="stLayoutWrapper"] > [class*="_trail_steps"]) { 

1162 min-width: __SELECTOR_STEPS_FLOOR__; 

1163 } 

1164 [data-testid="stColumn"]:has(> [data-testid="stVerticalBlock"] 

1165 > [data-testid="stLayoutWrapper"] > [class*="_row_tail"]) { 

1166 min-width: __SELECTOR_SCREEN_FLOOR__; 

1167 } 

1168 } 

1169 [class*="_row_tail"] { 

1170 flex-wrap: nowrap !important; 

1171 } 

1172 [class*="_row_tail"] > [data-testid="stLayoutWrapper"]:has([class*="screen_picker"]) { 

1173 flex: 1 1 0 !important; 

1174 min-width: 0 !important; 

1175 } 

1176 /* Its own control keeps the label and value on one line; the value 

1177 ellipsises rather than wrapping the cell taller. */ 

1178 [class*="_screen_cell"] { 

1179 flex-wrap: nowrap !important; 

1180 } 

1181 [class*="_screen_cell"] > [data-testid="stElementContainer"] { 

1182 flex: 1 1 0 !important; 

1183 width: auto !important; 

1184 min-width: 0 !important; 

1185 } 

1186 /* UX-181: a slider's end labels stay on one line. The trial scrubber's 

1187 labels are `1/24 · <trial id>`, and a long id used to wrap onto a second 

1188 line under the slider, where the chip strip below covered it. Each label 

1189 takes at most half the bar and ellipsises past that. The thumb's own 

1190 value above the track still shows the full id, and so does the 

1191 dropdown. */ 

1192 [data-testid="stSliderTickBar"] > [data-testid="stMarkdownContainer"] { 

1193 min-width: 0; 

1194 max-width: 50%; 

1195 } 

1196 [data-testid="stSliderTickBar"] > [data-testid="stMarkdownContainer"] p { 

1197 white-space: nowrap; 

1198 overflow: hidden; 

1199 text-overflow: ellipsis; 

1200 } 

1201 /* UX-9: the number box paired with each slider (`<key>__num`, `__num_lo`, 

1202 `__num_hi`) exists for typing an *exact* value — the slider beside it 

1203 already handles stepping. It holds a number, not a sentence, so drop the 

1204 +/- buttons, cap its width and tighten its padding: it sits on the same 

1205 line as the slider and must not steal the row. */ 

1206 div[class*="st-key-"][class*="__num"] [data-testid="stNumberInputStepUp"], 

1207 div[class*="st-key-"][class*="__num"] [data-testid="stNumberInputStepDown"] { 

1208 display: none !important; 

1209 } 

1210 div[class*="st-key-"][class*="__num"] [data-testid="stNumberInputContainer"] { 

1211 min-width: 0; 

1212 max-width: 5rem; 

1213 } 

1214 div[class*="st-key-"][class*="__num"] input { 

1215 padding-left: 0.4rem !important; 

1216 padding-right: 0.2rem !important; 

1217 text-align: right; 

1218 font-variant-numeric: tabular-nums; 

1219 } 

1220 

1221 /* UX-51 — the rail's `label | field` rows. Every control in the rail and in 

1222 its ⚙️/🧹 popovers used to stack its title ABOVE its field, so one style 

1223 panel ran well past a screen. The title now sits in a column to the LEFT 

1224 of the field. The split itself is built in Python (controls._labeled, one 

1225 `st.columns` per row) precisely so it does not depend on Streamlit's own 

1226 label DOM; all this rule set owns is how the title TEXT behaves inside the 

1227 column we made for it. 

1228 It must never wrap — a two-line title would push its own field down and 

1229 undo the compaction — so it truncates instead, and hands the full text to 

1230 the browser's native hover title. That title is also where the `?` tooltip 

1231 went: the help text is appended to it, which buys back the icon's width on 

1232 every row. Scoped to our own class name, so it cannot reach any other 

1233 widget label in the app. */ 

1234 /* UX-53: a wizard topic heading. It replaced a per-topic expander, so it has 

1235 to read as a divider *and* cost about one line — hence the rule above it 

1236 rather than padding around it. Not an `<h4>`: the Data page keeps its four 

1237 stage headings as the only h3/h4s (#UX-52). */ 

1238 .sps-wiz-section { 

1239 /* UX-90: the bottom margin was 0.15rem, which Streamlit's own negative 

1240 block gap ate entirely — the heading and the first control's label 

1241 overlapped. A heading has to clear what it names. */ 

1242 margin: 0.85rem 0 0.6rem; 

1243 padding-top: 0.55rem; 

1244 border-top: 1px solid rgba(128, 128, 128, 0.22); 

1245 font-weight: 600; 

1246 font-size: 0.95rem; 

1247 line-height: 1.4; 

1248 } 

1249 .sps-flabel { 

1250 display: block; 

1251 max-width: 100%; 

1252 overflow: hidden; 

1253 text-overflow: ellipsis; 

1254 white-space: nowrap; 

1255 /* Match the widget labels this replaced (see the VIZ-2 block below). */ 

1256 font-size: 0.92rem; 

1257 line-height: 1.4; 

1258 } 

1259 /* A row whose title carries help. The dotted underline is the only remaining 

1260 hint that there is something to hover, now that the `?` icon is folded 

1261 into the title itself. */ 

1262 /* UX-158 — a row's caption inside a titled group of rows (`controls._sub_row`): 

1263 the group's title leads the first row, and each row's own caption is 

1264 quieter so the title still reads as the heading of the run. */ 

1265 .sps-fsub { 

1266 font-size: 0.85rem; 

1267 opacity: 0.72; 

1268 } 

1269 /* UX-158/159 — a rail popover's rows (`controls._popover_rows`), a little 

1270 further apart than the app-wide gap:0 (the later rule wins the tie). */ 

1271 div[class*="st-key-rail_rows_"] { gap: 0.5rem !important; } 

1272 .sps-flabel-help { 

1273 text-decoration: underline dotted; 

1274 text-decoration-color: rgba(128, 128, 128, 0.6); 

1275 text-underline-offset: 3px; 

1276 cursor: help; 

1277 } 

1278 /* UX-113 — a title that should stand out among plainer field titles on the 

1279 same screen (the upload wizard's own table titles: Fixations, Words/IA, 

1280 Raw gaze, Participant/Trial metadata). Bolder and a touch larger than 

1281 the ordinary `.sps-flabel`, short of a full heading. */ 

1282 .sps-flabel-emph { 

1283 font-weight: 700; 

1284 font-size: 1.05rem; 

1285 } 

1286 /* UX-129 — the wizard's "Metadata" sub-heading (over the Participants / 

1287 Trials / Texts rows) shares `.sps-flabel-emph` with the Fixations / 

1288 AOI / Raw gaze table titles above it, which made it read as a fourth 

1289 table name rather than the section label for the three below it. 

1290 Confined to the left (name) column in wizard.py and centered within 

1291 it here, rather than the full row — this heading belongs with the 

1292 titles underneath it, not the wide mapping side. Bolder and a size up 

1293 from `.sps-flabel-emph` itself (not down) and in the app's accent 

1294 color: it introduces three titles at once, so it reads as *more* 

1295 prominent than any one of them, not less. The tooltip re-centers with 

1296 it so it still opens under the (now centered) text. */ 

1297 A prefix match, not the exact class: the ✏️ Edit dataset screen draws the 

1298 same three rows under the same heading (`wiz_map_meta_heading_edit` — a 

1299 key may be used once per run, and the offscreen editor is built even 

1300 while the wizard is open). */ 

1301 [class*="st-key-wiz_map_meta_heading"] .sps-flabel-emph { 

1302 font-weight: 800; 

1303 font-size: 1.2rem; 

1304 letter-spacing: 0.02em; 

1305 color: var(--sps-accent); 

1306 } 

1307 [class*="st-key-wiz_map_meta_heading"] .sps-fhelp, 

1308 [class*="st-key-wiz_map_meta_heading"] .sps-flabel { 

1309 text-align: center; 

1310 } 

1311 [class*="st-key-wiz_map_meta_heading"] .sps-fhelp::after { 

1312 left: 50%; 

1313 transform: translateX(-50%); 

1314 } 

1315 /* …and the tooltip it opens. Deliberately NOT the browser's native `title=` 

1316 (UX-51 shipped that first): a native tooltip waits about a second, which 

1317 reads as broken when every row in a dense form keeps its description 

1318 there. This one opens in 120 ms — the same feel as the `?` icons 

1319 Streamlit draws elsewhere, which sit on Base Web's 200 ms default. 

1320 The wrapper is what carries `position: relative`, because `.sps-flabel` 

1321 itself is `overflow: hidden` for the ellipsis and would clip its own 

1322 tooltip. Painted below-left of the title, non-interactive, and animated on 

1323 `opacity` alone so it never takes part in layout or intercepts a click. */ 

1324 .sps-fhelp { 

1325 position: relative; 

1326 display: block; 

1327 max-width: 100%; 

1328 } 

1329 /* BUG-91: the box exists only while it is shown. It used to sit there at 

1330 `opacity: 0` all the time, and an absolutely-positioned box still counts 

1331 towards its scroll container's overflow — so a long tooltip on a popover's 

1332 last rows let the popover scroll down into empty space. `content: none` 

1333 removes the box outright; the fade is an animation that starts once it is 

1334 created, not an opacity transition on a box that is always there. */ 

1335 .sps-fhelp::after { 

1336 content: none; 

1337 position: absolute; 

1338 top: calc(100% + 0.3rem); 

1339 left: 0; 

1340 z-index: 1000; 

1341 width: max-content; 

1342 max-width: 17rem; 

1343 padding: 0.35rem 0.55rem; 

1344 border-radius: 0.5rem; 

1345 background: rgba(38, 39, 48, 0.96); 

1346 color: #fafafa; 

1347 font-size: 0.78rem; 

1348 font-weight: 400; 

1349 line-height: 1.35; 

1350 white-space: normal; 

1351 text-align: left; 

1352 box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28); 

1353 pointer-events: none; 

1354 } 

1355 .sps-fhelp:hover::after, 

1356 .sps-fhelp:focus-within::after { 

1357 content: attr(data-tip); 

1358 animation: sps-tip-in 80ms linear 120ms both; 

1359 } 

1360 @keyframes sps-tip-in { 

1361 from { opacity: 0; } 

1362 to { opacity: 1; } 

1363 } 

1364 

1365 /* UX-53 round 3 — the wizard's descriptive prose is hover-only, so it reuses 

1366 the tooltip above. Two adjustments for this context: the carrier is a 

1367 heading or a short chip rather than a full-width field label, so it hugs 

1368 its text instead of filling the row; and it needs a visible cue that 

1369 something is there, which in the rail is supplied by the neighbouring `?` 

1370 affordance and here is not. */ 

1371 .sps-wiz-section .sps-fhelp, 

1372 .sps-wiz-part .sps-fhelp, 

1373 .sps-wiz-note .sps-fhelp { 

1374 display: inline-block; 

1375 max-width: none; 

1376 text-decoration: underline dotted; 

1377 text-decoration-color: rgba(128, 128, 128, 0.55); 

1378 text-underline-offset: 3px; 

1379 cursor: help; 

1380 } 

1381 .sps-wiz-note { 

1382 margin: 0.1rem 0 0.5rem; /* UX-90 */ 

1383 font-size: 0.86rem; 

1384 opacity: 0.85; 

1385 } 

1386 .sps-wiz-note a { text-decoration: none; } 

1387 

1388 /* UX-53 round 8 — a wizard part's headline. The two parts are linear, so 

1389 this labels rather than navigates: one line, a numbered chip, and a rule 

1390 to separate it from the part above. Heavier than `.sps-wiz-section` (its 

1391 topics sit *inside* a part) and lighter than a real heading, since the 

1392 page already has its own. */ 

1393 .sps-wiz-part { 

1394 display: flex; 

1395 align-items: center; 

1396 gap: 0.45rem; 

1397 margin: 1rem 0 0.6rem; /* UX-90 — same collision as the section rule */ 

1398 padding-top: 0.6rem; 

1399 border-top: 2px solid rgba(128, 128, 128, 0.28); 

1400 font-weight: 700; 

1401 font-size: 1.02rem; 

1402 letter-spacing: 0.01em; 

1403 } 

1404 .sps-wiz-part-n { 

1405 display: inline-flex; 

1406 align-items: center; 

1407 justify-content: center; 

1408 width: 1.35rem; 

1409 height: 1.35rem; 

1410 border-radius: 999px; 

1411 background: rgba(128, 128, 128, 0.22); 

1412 font-size: 0.8rem; 

1413 font-weight: 700; 

1414 } 

1415 /* UX-113 — `_FOOTER_ROW_W`'s compact desktop ratio (1.4 : 1.4 : 8 rest, 

1416 matching the ✕ Cancel width elsewhere) gives each button column too few 

1417 pixels to keep "Download setup file" / "Add dataset" on one line, at any width 

1418 (not only a mobile one — the ratio itself is the problem). Streamlit 

1419 sets each `stColumn`'s own `flex: 1 1 calc(<share>% - Npx)` via a real 

1420 stylesheet class (not inline), so a bare `min-width` floor doesn't just 

1421 clamp the final size — the flex-grow redistribution the browser then 

1422 runs (still 1 for every column) hands the two button columns most of 

1423 the row instead. Pinning the button columns to a fixed, 

1424 ungrowing/unshrinking `flex-basis` sidesteps that fight, but leaving 

1425 the empty third (`_rest`) column flexible then wraps it onto an 

1426 invisible second line (an empty flex item, `flex-wrap`'s default) — 

1427 harmless (zero height) but it pads the row with dead space below the 

1428 buttons. Collapsing `_rest` to zero instead avoids that: three fixed 

1429 widths always fit the first line, so nothing wraps. `!important` 

1430 because these override Streamlit's own stylesheet `flex`. */ 

1431 A prefix match, not `.st-key-wizard_footer_row`: the ✏️ Edit dataset 

1432 screen wears this same footer under its own key 

1433 (`wizard_footer_row_edit`, since the offscreen editor and an open wizard 

1434 can both exist in one run and two containers may not share a key), and 

1435 the two screens' last rows have to line up to the pixel. */ 

1436 [class*="st-key-wizard_footer_row"] [data-testid="stHorizontalBlock"] { 

1437 flex-wrap: nowrap !important; 

1438 } 

1439 [class*="st-key-wizard_footer_row"] [data-testid="stColumn"]:nth-of-type(1), 

1440 [class*="st-key-wizard_footer_row"] [data-testid="stColumn"]:nth-of-type(2) { 

1441 flex: 0 0 12.5rem !important; 

1442 width: 12.5rem !important; 

1443 min-width: 12.5rem !important; 

1444 } 

1445 [class*="st-key-wizard_footer_row"] [data-testid="stColumn"]:nth-of-type(3) { 

1446 flex: 0 0 0 !important; 

1447 width: 0 !important; 

1448 min-width: 0 !important; 

1449 padding: 0 !important; 

1450 } 

1451 /* UX-113 — real breathing room around the footer's own divider, on both 

1452 sides: Streamlit's default `st.divider()` margin reads as barely more 

1453 than the mapping blocks' own tight `.sps-wiz-blockgap` hairline, which 

1454 undersells that this line is the boundary before the page's one commit, 

1455 not another block gap. */ 

1456 [class*="st-key-wizard_footer_divider"] hr { 

1457 margin-top: 1.25rem !important; 

1458 margin-bottom: 1.5rem !important; 

1459 } 

1460 

1461 /* The dataset name leads the wizard and names the whole thing, so it is set 

1462 larger than an ordinary field rather than looking like the first of them. */ 

1463 .st-key-wiz_name_box input { 

1464 font-size: 1.05rem; 

1465 font-weight: 600; 

1466 padding-top: 0.55rem; 

1467 padding-bottom: 0.55rem; 

1468 } 

1469 .st-key-wiz_name_box label p { font-weight: 700; } 

1470 

1471 /* UX-119 — a wizard table upload's "👁️ Preview" trigger + its row/column 

1472 count caption pack together with no gap, the same trick `railbtn_*` 

1473 uses: turn the container into a packed flex ROW and let the trigger 

1474 hug its own content instead of Streamlit's default one-child-per-row, 

1475 full-width stacking. `st.columns` was tried first and rejected — a 

1476 ratio-based column reserves its ratio's share of the row regardless of 

1477 its content's own `width="content"` sizing, which is what left a gap 

1478 between an icon-sized button and the caption a whole column over. 

1479 UX-123: this container is now also the narrow `wiz_map_upload_*` name 

1480 column (UX-122), so the count caption — the last child, whether or not 

1481 the trigger rendered before it — wraps onto its own line(s) instead of 

1482 overflowing: `flex: 1 1 auto; min-width: 0` lets it shrink below its 

1483 one-line width, which is what lets it wrap at all. */ 

1484 [class*="st-key-wiz_upload_stats_"] { 

1485 flex-direction: row; 

1486 flex-wrap: wrap; 

1487 align-items: flex-start; 

1488 } 

1489 [class*="st-key-wiz_upload_stats_"] > div:first-child { 

1490 flex: 0 0 auto !important; 

1491 width: auto !important; 

1492 } 

1493 [class*="st-key-wiz_upload_stats_"] > div:last-child { 

1494 flex: 1 1 auto !important; 

1495 width: auto !important; 

1496 min-width: 0 !important; 

1497 } 

1498 [class*="st-key-wiz_upload_stats_"] > div:last-child p { 

1499 white-space: normal !important; 

1500 } 

1501 [class*="st-key-wiz_upload_stats_"] > div + div { 

1502 margin-left: 0.35rem !important; 

1503 } 

1504 

1505 /* UX-122 — each Fixations/AOI/Raw gaze row's own uploader sits where the 

1506 row's plain name label used to; a thin rule (not the full column gap 

1507 either side of it) marks it off from the field-mapping pickers that 

1508 follow. UX-125: `st.columns(vertical_alignment="center")` centers the 

1509 *column*, not the stack of title/uploader/caption inside it against 

1510 the block's full height — and that column is only row 1's own height, 

1511 shorter than the uploader itself, so centering it was a no-op. The 

1512 uploader now floats free of row 1 entirely: `wiz_map_block_*` 

1513 (`fix_block`/`words_block`/raw gaze's own `s3`, wrapping row 1 + row 2 

1514 + the keep-picker) is the positioning parent, and the upload column is 

1515 an absolutely-positioned overlay spanning and centering against that 

1516 *whole* block — row 1's own first cell stays reserved (unchanged 

1517 width, so the pickers beside it don't move) but empty, since its 

1518 content now lives in the overlay instead. The border-right rides 

1519 along for the full block height as a side effect, reading as one 

1520 continuous divider rather than just row 1's own short one. */ 

1521 /* UX-127: before any file is uploaded, row 1's remaining cells (the 

1522 picker columns — nothing to map yet) are empty, and the uploader that 

1523 used to hold row 1's own height now lives in an absolutely-positioned 

1524 overlay (see below) that contributes nothing to normal flow. Left 

1525 alone the block collapsed to a few px of padding, and the overlay's 

1526 `overflow: hidden` then clipped the title + Browse-files button down 

1527 to that same sliver — the "looks awful when closed" bug. A min-height 

1528 covering the title + collapsed dropzone (measured ~54px) keeps the 

1529 block, and so the overlay it sizes itself against, tall enough to 

1530 show the uploader whole even with nothing uploaded yet — measured at 

1531 ~92px (title + the collapsed-instructions dropzone), rounded up with 

1532 a little breathing room. 

1533 UX-129: 104px -> 128px — the overlay centers its content 

1534 (`justify-content: center`) against the *whole* block height, and the 

1535 title + collapsed dropzone measure ~96px on their own, so 104px left 

1536 only ~2px above the title and ~6px below the button: close enough to 

1537 read as the title being cut off at the top of its block, not merely 

1538 "close to the edge". 128px leaves a real ~16px on each side. */ 

1539 [class*="st-key-wiz_map_block_"] { 

1540 position: relative; 

1541 min-height: 128px; 

1542 } 

1543 [class*="st-key-wiz_map_upload_"] { 

1544 position: absolute; 

1545 top: 0; 

1546 bottom: 0; 

1547 left: 0; 

1548 /* UX-127: widened from 9% to match `_ID_ROW1_W`/`_META_ROW_W`'s own 

1549 widened first cell in wizard.py (0.09 -> 0.135) — the Browse-files 

1550 button didn't fit at 9%. */ 

1551 width: 13.5%; 

1552 border-right: 1px solid rgba(128, 128, 128, 0.3); 

1553 /* UX-129: 0.5rem -> 0.65rem, paired with wizard.py's `_ID_ROW1_W` 

1554 (and its row-2/meta siblings) widening from 0.135 to 0.155 — that 

1555 gives the divider room on the picker side, this gives it room on 

1556 the upload side, so the line no longer reads as glued to either 

1557 the Browse-files button or the first mapping field. */ 

1558 padding-right: 0.65rem; 

1559 display: flex; 

1560 flex-direction: column; 

1561 justify-content: center; 

1562 overflow: hidden; 

1563 } 

1564 /* UX-129 — the title's own `.sps-fhelp` tooltip (the row's `emphasis=True` 

1565 title, e.g. "Fixations") lives inside this same `overflow: hidden` box, 

1566 so its `::after` popup got clipped to the ~13.5%-wide column the 

1567 moment it tried to render past that edge — cut off mid-sentence 

1568 instead of floating over the mapping fields like every other tooltip 

1569 on the page. `overflow: hidden` still has to stay the *default* here 

1570 (it is what keeps the native dropzone/Browse-files button from 

1571 spilling into the mapping columns at this width — see UX-127 above), 

1572 so this only lifts it while the tooltip itself is open, via `:has()`, 

1573 rather than removing it outright. */ 

1574 [class*="st-key-wiz_map_upload_"]:has(.sps-fhelp:hover), 

1575 [class*="st-key-wiz_map_upload_"]:has(.sps-fhelp:focus-within) { 

1576 overflow: visible; 

1577 } 

1578 /* UX-147 — a metadata row (Participants / Trials / Texts, on ➕ Add and 

1579 ✏️ Edit alike) is one row, so its upload column needs no overlay to 

1580 span the block: it is the row's own first column. Left absolute, the 

1581 column was only as tall as the id + keep pickers beside it, and an 

1582 attached file (chip + preview + row counts) outgrew it — centering then 

1583 pushed the title above the top edge, where `overflow: hidden` cut it 

1584 off. In normal flow the row grows to fit instead. `overflow-x: clip` 

1585 keeps UX-127's guard against the dropzone spilling sideways without 

1586 clipping vertically (`hidden` on one axis would force the other to 

1587 scroll); the column stretches so the divider still runs the row's full 

1588 height. */ 

1589 [class*="st-key-wiz_map_upload_meta_"] { 

1590 position: static; 

1591 width: auto; 

1592 height: 100%; 

1593 overflow-x: clip; 

1594 overflow-y: visible; 

1595 } 

1596 [data-testid="stColumn"]:has([class*="st-key-wiz_map_upload_meta_"]) { 

1597 align-self: stretch; 

1598 } 

1599 /* UX-124 — the "5GB per file • CSV, TSV, …" line `st.file_uploader` 

1600 prints under its own dropzone doesn't fit this width; the same 

1601 information now reaches the title's hover tooltip instead (appended to 

1602 `help_text` in wizard.py). */ 

1603 [class*="st-key-wiz_map_upload_"] [data-testid="stFileUploaderDropzoneInstructions"] { 

1604 display: none; 

1605 } 

1606 /* UX-124 — the uploaded-file chip is sized for a full-width column; in 

1607 this ~9%-wide one it clipped outright rather than shrinking (its 

1608 `stFileChips` wrapper has its own `overflow: hidden`, so the chip's 

1609 natural ~140px width just got cut, delete button and all). Shrink the 

1610 icon and the delete button, tighten the chip's own padding, and let it 

1611 use the column's full width instead of a fixed one — the filename 

1612 stays truncated with an ellipsis (`stFileChipName` already carries the 

1613 untruncated name as a native `title=`, so hovering shows it in full, 

1614 no extra work needed there). */ 

1615 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChips"] { 

1616 width: 100%; 

1617 overflow: visible; 

1618 } 

1619 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChip"] { 

1620 width: 100% !important; 

1621 max-width: 100% !important; 

1622 min-width: 0 !important; 

1623 padding: 0.15rem 0.25rem !important; 

1624 gap: 0.25rem !important; 

1625 box-sizing: border-box !important; 

1626 } 

1627 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChip"] svg { 

1628 width: 11px !important; 

1629 height: 11px !important; 

1630 } 

1631 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChipName"] { 

1632 font-size: 0.7rem !important; 

1633 max-width: 100% !important; 

1634 } 

1635 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChipDeleteBtn"] button { 

1636 width: 1rem !important; 

1637 min-width: 1rem !important; 

1638 height: 1rem !important; 

1639 padding: 0 !important; 

1640 } 

1641 [class*="st-key-wiz_map_upload_"] [data-testid="stFileChipDeleteBtn"] svg { 

1642 width: 10px !important; 

1643 height: 10px !important; 

1644 } 

1645 

1646 /* UX-62 r2 — the header wordmark. `st.logo`'s only sizing control is 

1647 small/medium/large, and even `large` leaves it small beside the nav, in a 

1648 link that carries its own padding. So the height is set here instead, and 

1649 the wrapper's spacing zeroed — "get rid of the white margins around it". 

1650 

1651 The other half of that margin was baked into the PNG (36 px either side, 

1652 24% of the canvas) and was cropped out of the file itself; CSS cannot 

1653 reach inside an image. Sized in `rem` so it tracks the browser's text 

1654 size rather than pinning to one display. */ 

1655 [data-testid="stLogo"] { 

1656 height: 2.75rem !important; 

1657 max-height: none !important; 

1658 width: auto !important; 

1659 margin: 0 !important; 

1660 padding: 0 !important; 

1661 object-fit: contain; 

1662 } 

1663 [data-testid="stLogoLink"] { 

1664 margin: 0 !important; 

1665 padding: 0 !important; 

1666 display: inline-flex; 

1667 align-items: center; 

1668 } 

1669 /* The spacer Streamlit reserves beside the logo assumes the default height; 

1670 with a taller mark it leaves a gap the nav then starts after. */ 

1671 [data-testid="stLogoSpacer"] { display: none !important; } 

1672 

1673 /* UX-66 — the add-dataset screen's one permanent row: title, guide, docs 

1674 link, cancel. It stays put while the page scrolls. 

1675 

1676 `top` clears Streamlit's own header strip, which occupies the top of the 

1677 viewport — without it the row slides *under* the nav rather than resting 

1678 below it. The background is opaque so the fields scrolling beneath do not 

1679 show through, and the z-index keeps it over them. 

1680 

1681 Scoped to this container's key: only the wizard gets a sticky bar, not 

1682 every page. */ 

1683 /* DATA-35 — the ✏️ Edit dataset screen wears the add-dataset screen's bar, 

1684 because the ask was that the two look the same. Same rule, two keys. */ 

1685 .st-key-dataset_editor_bar, 

1686 .st-key-wiz_sticky_bar { 

1687 position: sticky; 

1688 top: 3.2rem; 

1689 z-index: 60; 

1690 background: var(--sps-page-bg); 

1691 padding: 0.35rem 0 0.4rem; 

1692 margin-bottom: 0.2rem; 

1693 border-bottom: 1px solid rgba(128, 128, 128, 0.25); 

1694 } 

1695 .sps-wiz-title { 

1696 font-size: 1.35rem; 

1697 font-weight: 700; 

1698 line-height: 1.2; 

1699 white-space: nowrap; 

1700 overflow: hidden; 

1701 text-overflow: ellipsis; 

1702 } 

1703 /* "get rid of all white spaces and margins at the top and bottom" — the 

1704 page's own padding, and the gap Streamlit leaves under the last block. */ 

1705 .st-key-data_setup_page > div:first-child { margin-top: 0 !important; } 

1706 .st-key-data_setup_page > div:last-child { margin-bottom: 0 !important; } 

1707 

1708 /* UX-55 — a sub-group heading inside a wizard section (AOI features, Raw 

1709 gaze features). Lighter and tighter than the bold markdown it replaced: 

1710 the section above it already carries the weight, and these only need to 

1711 separate one table's fields from the next. */ 

1712 .sps-wiz-subhead { 

1713 margin: 0.5rem 0 0.1rem; 

1714 padding-top: 0.35rem; 

1715 border-top: 1px solid rgba(128, 128, 128, 0.18); 

1716 font-weight: 600; 

1717 font-size: 0.88rem; 

1718 opacity: 0.85; 

1719 } 

1720 

1721 /* UX-57 — the Word box heading. It labels a group (a format radio plus a 

1722 row of four coordinates), so it is set like the other group headings 

1723 rather than like a field label, and its description is on hover. */ 

1724 .sps-box-title { 

1725 font-weight: 600; 

1726 font-size: 0.95rem; 

1727 margin: 0.2rem 0 0.15rem; 

1728 } 

1729 

1730 /* UX-53 r15 — the table name at the head of an identity row. It labels the 

1731 line once so the three field titles beside it need not each repeat it, 

1732 and it sits on the titles' baseline rather than the controls'. */ 

1733 .sps-id-row-name { 

1734 padding-bottom: 0.3rem; /* UX-90 — clear of the fields it names */ 

1735 font-weight: 700; 

1736 font-size: 0.9rem; 

1737 opacity: 0.85; 

1738 white-space: nowrap; 

1739 overflow: hidden; 

1740 text-overflow: ellipsis; 

1741 } 

1742 /* UX-55 r2 — the same name column on the geometry rows, sitting on the 

1743 selects' baseline (their own titles are stacked above them). */ 

1744 .sps-geo-row-name { padding-bottom: 0.45rem; } 

1745 

1746 /* UX-92 — the ✨ flag is a button while its row is amber (pressing it is 

1747 the "I chose this" that a same-value re-pick cannot report). It has to 

1748 keep reading as the icon it replaced: no chrome, no button box, no width 

1749 of its own — only the pointer and a lift on hover say it is pressable. */ 

1750 [class*="st-key-"][class*="_cell_confirm"] button { 

1751 min-height: 0; 

1752 padding: 0; 

1753 border: none; 

1754 background: none; 

1755 line-height: 1.2; 

1756 } 

1757 [class*="st-key-"][class*="_cell_confirm"] button:hover { 

1758 background: none; 

1759 transform: translateY(-1px); 

1760 } 

1761 [class*="st-key-"][class*="_cell_confirm"] button p { font-size: 0.95rem; } 

1762 

1763 /* UX-89 — the hairline between the Fixations block and the AOI block (and, 

1764 since UX-113, every other block boundary in stage 3: filename-derive vs. 

1765 Fixations, Raw gaze vs. whatever sits above it). The mapping is grouped 

1766 by *table* now (each table's identity row and its feature row together), 

1767 so the only thing separating two blocks is this: one line, deliberately 

1768 fainter and far tighter than `st.divider`, whose margins would 

1769 reintroduce the vertical cost the page keeps fighting. UX-113 gave it 

1770 more room underneath, so the line reads as a clear break between blocks 

1771 rather than sitting glued to the one below it. */ 

1772 .sps-wiz-blockgap { 

1773 border-top: 1px solid var(--sps-line, rgba(128, 128, 128, 0.22)); 

1774 margin: 0.55rem 0 1rem; 

1775 } 

1776 

1777 /* AN-32 — the Corpus Analysis page with no reading measures to show: its 

1778 sections, drawn as a grayed tab strip so what it offers stays visible. */ 

1779 .sps-corpus-off { 

1780 display: flex; 

1781 gap: 1.5rem; 

1782 margin-top: 1rem; 

1783 padding-bottom: 0.5rem; 

1784 border-bottom: 1px solid var(--sps-line, rgba(128, 128, 128, 0.22)); 

1785 opacity: 0.45; 

1786 pointer-events: none; 

1787 user-select: none; 

1788 } 

1789 

1790 /* UX-113 — the filename-derive row's Apply button: a gentle tint of the 

1791 app's own accent (the same tokens the code chips and tab hover use), 

1792 not the loud filled `primary` blue reserved for the page's one commit 

1793 (✅ Add dataset). */ 

1794 .st-key-wizard_filename_apply button { 

1795 background: var(--sps-accent-soft); 

1796 border-color: var(--sps-accent-border); 

1797 color: var(--sps-accent); 

1798 } 

1799 .st-key-wizard_filename_apply button:hover { 

1800 background: var(--sps-accent-border); 

1801 border-color: var(--sps-accent); 

1802 color: var(--sps-accent); 

1803 } 

1804 

1805 /* UX-72 — the two halves of the rail's Filters & highlights section. A rule and a 

1806 small label: enough to group, cheap in height. (UX-74 briefly used this 

1807 for every section's contents and was reverted — the sections read better 

1808 with their `⚙️ …` popovers.) */ 

1809 .sps-rail-subhead { 

1810 /* UX-191: above the rows' 0.9rem labels, so a block's title outranks 

1811 its own fields instead of reading as a footnote to them. */ 

1812 font-size: 0.95rem; 

1813 font-weight: 700; 

1814 letter-spacing: 0.01em; 

1815 opacity: 0.8; 

1816 /* More room above than below, so the label still reads as belonging to 

1817 the block under it — but 0.15rem below put "🖥️ Screen & framing" 

1818 almost on the baseline of the "Show full monitor" switch, which made 

1819 the two look like one control. */ 

1820 margin: 0.8rem 0 0.45rem; 

1821 padding-top: 0.4rem; 

1822 border-top: 1px solid rgba(128, 128, 128, 0.28); 

1823 } 

1824 /* The first block in a section needs no rule — the expander's own header is 

1825 the boundary. */ 

1826 [data-testid="stExpander"] [data-testid="stVerticalBlock"] 

1827 > div:first-child .sps-rail-subhead { 

1828 border-top: none; 

1829 padding-top: 0; 

1830 margin-top: 0.1rem; 

1831 } 

1832 

1833 /* UX-71 — see `mapping_menu_css()` below: the option list is widened only 

1834 on the two mapping surfaces, so this global sheet leaves dropdowns alone. */ 

1835 

1836 /* UX-138 — `constants.icon_html`: a Material Symbols glyph inside raw HTML, 

1837 where a `:material/…:` shortcode is inert. Same font Streamlit loads for 

1838 its own icons; the span's text is the ligature (the icon's name). */ 

1839 .sps-icon { 

1840 font-family: "Material Symbols Rounded"; 

1841 font-weight: normal; 

1842 font-style: normal; 

1843 font-size: 1.2em; 

1844 line-height: 1; 

1845 letter-spacing: normal; 

1846 text-transform: none; 

1847 white-space: nowrap; 

1848 direction: ltr; 

1849 font-feature-settings: "liga"; 

1850 vertical-align: -0.2em; 

1851 user-select: none; 

1852 } 

1853 /* UX-53 round 4 — the auto-detection flag beside a mapping row is the ✨ and 

1854 nothing else; which column was detected is on its tooltip. The old inline 

1855 sentence ("✨ auto-detected `CURRENT_FIX_INDEX`") ran wider than the 

1856 select it annotated, on every row. */ 

1857 .sps-map-flag { 

1858 display: inline-block; 

1859 font-size: 0.85rem; 

1860 line-height: 1; 

1861 opacity: 0.75; 

1862 cursor: help; 

1863 } 

1864 .sps-map-flag:hover { opacity: 1; } 

1865 /* The tooltip is anchored to a one-glyph carrier at the right-hand edge of 

1866 the row, so it opens leftwards rather than off the panel. */ 

1867 .sps-map-flag.sps-fhelp::after { left: auto; right: 0; } 

1868 

1869 /* Data Management's read-only mappings use the same label-over-control 

1870 grammar as Add dataset. The value is intentionally select-like without 

1871 being a disabled widget: built-in schemas are informative, not editable. */ 

1872 .sps-readonly-map-table { 

1873 margin: 0.65rem 0 0.2rem; 

1874 padding-top: 0.35rem; 

1875 border-top: 1px solid rgba(128, 128, 128, 0.18); 

1876 font-weight: 700; 

1877 font-size: 0.9rem; 

1878 opacity: 0.85; 

1879 } 

1880 .sps-readonly-map-label { 

1881 min-height: 1.35rem; 

1882 margin-bottom: 0.2rem; 

1883 font-size: 0.82rem; 

1884 font-weight: 600; 

1885 opacity: 0.88; 

1886 } 

1887 .sps-readonly-map-value { 

1888 min-height: 2.4rem; 

1889 display: flex; 

1890 align-items: center; 

1891 padding: 0.45rem 0.65rem; 

1892 border: 1px solid var(--sps-border); 

1893 border-radius: 0.5rem; 

1894 background: rgba(128, 128, 128, 0.07); 

1895 font-family: var(--sps-mono, ui-monospace, SFMono-Regular, Menlo, monospace); 

1896 font-size: 0.82rem; 

1897 overflow: hidden; 

1898 text-overflow: ellipsis; 

1899 white-space: nowrap; 

1900 } 

1901 .sps-readonly-map-note { 

1902 margin-top: 0.2rem; 

1903 font-size: 0.76rem; 

1904 opacity: 0.72; 

1905 } 

1906 

1907 /* Control rail: set off from the plot by one hairline on its left edge, on 

1908 the page's own background — UX-151 retired the tinted, bordered card, 

1909 whose fill put a second surface behind rows that already carry their own 

1910 outline. A hair more breathing room between the stacked toggles than the 

1911 app-wide gap:0 rule. UX-173 keeps the controls in page flow, beside both 

1912 the plot and its subtabs, so every control can use the space below the 

1913 figure without a nested scrollbar. */ 

1914 .st-key-scanpath_rail { 

1915 border-left: 1px solid var(--sps-border); 

1916 padding: 0.1rem 0.35rem 1.5rem 1rem; 

1917 box-sizing: border-box; 

1918 /* UX-29: lets the Quick-view rule below query the rail's own rendered 

1919 width, not the viewport's — the rail is a fraction of the row, so its 

1920 width changes with the window without necessarily crossing a viewport 

1921 breakpoint. (It used to swing with the sidebar opening and closing 

1922 too; that is gone, but querying the rail is still the right test.) */ 

1923 container-type: inline-size; 

1924 container-name: sps-rail; 

1925 } 

1926 .st-key-scanpath_rail div[data-testid="stVerticalBlock"] { gap: 0.3rem !important; } 

1927 .st-key-scanpath_rail h5 { margin: 0.15rem 0 0.1rem; } 

1928 /* Section dividers default to 32px top+bottom margin — far too airy for the 

1929 narrow rail. Tighten them so the sections sit close together. */ 

1930 .st-key-scanpath_rail hr { margin: 0.5rem 0 !important; } 

1931 /* The palette divider meets the first bordered layer card; leave a small 

1932 extra pause so the rule and the Fixations border do not crowd together. */ 

1933 .st-key-scanpath_rail .st-key-palette_layers_divider { 

1934 margin-bottom: 0.4rem !important; 

1935 } 

1936 /* Plot-rail triggers need enough height for their labels and switch tracks; 

1937 the app-wide compact button treatment otherwise leaves them cramped. */ 

1938 .st-key-scanpath_rail .st-key-viz_view_scanpath button, 

1939 .st-key-scanpath_rail .st-key-viz_view_heatmap button, 

1940 .st-key-scanpath_rail .st-key-viz_view_illustration button, 

1941 .st-key-scanpath_rail .st-key-viz_view_custom button { 

1942 min-height: 2.6rem; 

1943 padding-top: 0.25rem; 

1944 padding-bottom: 0.25rem; 

1945 } 

1946 .st-key-scanpath_rail .st-key-reset_viz_settings_btn button { 

1947 min-height: 3rem; 

1948 } 

1949 /* The rail is deliberately narrow — keep its short headers + toggle labels on 

1950 one line so they don't break mid-word (e.g. "Anima\nte") when it's tight. */ 

1951 .st-key-scanpath_rail h5, 

1952 .st-key-scanpath_rail [data-testid="stWidgetLabel"] p { white-space: nowrap; } 

1953 /* Per-layer styling popovers + the comparison-styling expander: full-width, 

1954 left-aligned triggers so the rail stays a clean single column. */ 

1955 .st-key-scanpath_rail [data-testid="stPopover"] button { 

1956 width: 100%; 

1957 justify-content: flex-start; 

1958 } 

1959 /* VIZ-31 layer groups (👁️ Scanpath / 📄 Stimulus / 🔥 Overlays / 🖥️ Canvas & 

1960 text / 📐 Figure & canvas). Streamlit gives the expander label 

1961 `word-break: break-word`, which in a rail this narrow snaps the header 

1962 mid-word ("Scanpa\nth") — the same defect the nowrap rule above prevents 

1963 for toggle labels. Break at spaces only, and give the label the row's 

1964 spare width (the chevron is a fixed-size flex sibling, so the label needs 

1965 `min-width: 0` to be allowed to use it). Trimmed side padding buys back 

1966 ~16px, which is what keeps these headers on one line at real rail widths. */ 

1967 .st-key-scanpath_rail [data-testid="stExpander"] summary { 

1968 padding-left: 0.35rem; 

1969 padding-right: 0.35rem; 

1970 } 

1971 .st-key-scanpath_rail [data-testid="stExpander"] summary p { 

1972 word-break: normal; 

1973 overflow-wrap: normal; 

1974 flex: 1 1 auto; 

1975 min-width: 0; 

1976 } 

1977 /* A group's contents are inset 16px per side by default. Nesting the layer 

1978 toggles one level deeper means that inset now comes out of an already 

1979 narrow column — enough to clip the full-width popover triggers ("⚙️ 

1980 Fixation styl…") against the card edge. Give it back most of the width; 

1981 the group's own border still reads as the grouping. */ 

1982 .st-key-scanpath_rail [data-testid="stExpanderDetails"] { 

1983 padding-left: 0.4rem; 

1984 padding-right: 0.4rem; 

1985 padding-top: 0.1rem; 

1986 } 

1987 /* Design presets and Palette use the same quietly muted label treatment. */ 

1988 .sps-control-label { 

1989 color: inherit; 

1990 opacity: 0.72; 

1991 font-size: 0.875rem; 

1992 line-height: 1.4; 

1993 margin-bottom: 0; 

1994 } 

1995 .st-key-scanpath_rail .st-key-global_palette [data-testid="stWidgetLabel"] { 

1996 opacity: 0.72; 

1997 } 

1998 /* Give the heading a small breathing space, while keeping the 2×2 grid 

1999 itself tighter than ordinary Streamlit column rows. */ 

2000 .st-key-scanpath_rail .st-key-quick_views_grid { 

2001 margin-top: 0; 

2002 padding-top: 0.45rem; 

2003 margin-bottom: 0.05rem; 

2004 } 

2005 .st-key-scanpath_rail .st-key-quick_views_grid > [data-testid="stVerticalBlock"] { 

2006 gap: 0.15rem !important; 

2007 } 

2008 /* VIZ-39 — 🎨 My designs. 💾 Save is drawn *into* the expander's own title 

2009 bar: `design_shell` is the positioning context, and the header row's 

2010 right-hand side is empty (the chevron sits left), so the list underneath 

2011 keeps the full width of the rail instead of losing a fifth of it to one 

2012 icon in a column of its own. */ 

2013 .st-key-scanpath_rail .st-key-design_shell { 

2014 position: relative; 

2015 } 

2016 .st-key-scanpath_rail .st-key-design_save { 

2017 position: absolute; 

2018 top: 0.3rem; 

2019 right: 0.4rem; 

2020 width: auto !important; 

2021 z-index: 3; 

2022 } 

2023 .st-key-scanpath_rail .st-key-design_save button { 

2024 min-height: 1.9rem; 

2025 padding: 0 0.4rem; 

2026 } 

2027 /* Keep the title itself clear of the button it now shares a line with. */ 

2028 .st-key-scanpath_rail .st-key-design_shell summary { 

2029 padding-right: 2.4rem; 

2030 } 

2031 /* One saved design is one bordered card, not three loose buttons: the row's 

2032 own container carries the border, and the controls inside it are borderless 

2033 so the card reads as a single object. */ 

2034 .st-key-scanpath_rail [class*="st-key-design_row_"] { 

2035 padding: 0.15rem 0.3rem; 

2036 margin-bottom: 0.3rem; 

2037 border-radius: 0.5rem; 

2038 } 

2039 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stHorizontalBlock"] { 

2040 gap: 0.1rem !important; 

2041 } 

2042 .st-key-scanpath_rail [class*="st-key-design_row_"] button { 

2043 min-height: 1.9rem; 

2044 padding: 0.1rem 0.3rem; 

2045 justify-content: center; 

2046 } 

2047 /* An icon-only button still carries the label's right margin, which is what 

2048 pushes these two off-center. */ 

2049 .st-key-scanpath_rail [class*="st-key-design_row_"] button [data-testid="stIconMaterial"] { 

2050 margin: 0 !important; 

2051 font-size: 1.1rem; 

2052 } 

2053 /* ✏️ swaps the name for a field in the same slot, so the field has to end up 

2054 the height of the button it replaces — otherwise the card grows by a 

2055 third the moment you click, and the list shuffles under the cursor. The 

2056 field is wrapped in a form (for ⏎), and every layer of that wrapping 

2057 brings its own margin; measured in the browser, zeroing these four brings 

2058 the editing card to 46px against the resting card's 45. */ 

2059 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stForm"] { 

2060 border: 0; 

2061 padding: 0; 

2062 } 

2063 .st-key-scanpath_rail [class*="st-key-design_row_"] > [data-testid="stLayoutWrapper"], 

2064 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stForm"] [data-testid="stElementContainer"], 

2065 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stForm"] [data-testid="stLayoutWrapper"] { 

2066 margin-bottom: 0 !important; 

2067 } 

2068 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stForm"] [data-testid="stVerticalBlock"] { 

2069 gap: 0 !important; 

2070 } 

2071 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stTextInput"] input { 

2072 padding: 0.1rem 0.4rem; 

2073 min-height: 1.9rem; 

2074 } 

2075 .st-key-scanpath_rail [class*="st-key-design_row_"] [data-testid="stTextInputRootElement"] { 

2076 height: 2.1rem; 

2077 min-height: 0; 

2078 } 

2079 /* The 2×2 Quick-view grid keeps full labels at ordinary rail widths and 

2080 falls back to icons only at the narrowest size. UX-138: the label's own 

2081 Material icon is what stays — the text collapses around it — so the 

2082 fallback no longer re-draws each glyph from a `content:` rule. 

2083 UX-194: the rail is a fifth of the row, and these queries measure its 

2084 *content* box — 191px on a 1280px laptop — so the old 320px cut-off hid 

2085 the names on any window narrower than ~1800px. The full-size label 

2086 needs ~240px ("✎ Illustration", the longest, is 84px at 14px plus 12px 

2087 padding a side); under that the buttons tighten (padding, gap, a point 

2088 smaller: 75px), which holds one line down to ~175px. The icon fallback 

2089 starts at 178px, a few pixels of slack for other platforms' fonts — a 

2090 window under ~1220px. */ 

2091 @container sps-rail (max-width: 240px) { 

2092 .st-key-quick_views_grid [data-testid="stHorizontalBlock"] { 

2093 gap: 0.4rem !important; 

2094 } 

2095 .st-key-viz_view_scanpath button, 

2096 .st-key-viz_view_heatmap button, 

2097 .st-key-viz_view_illustration button, 

2098 .st-key-viz_view_custom button { 

2099 padding-left: 0.25rem; 

2100 padding-right: 0.25rem; 

2101 } 

2102 .st-key-viz_view_scanpath button p, 

2103 .st-key-viz_view_heatmap button p, 

2104 .st-key-viz_view_illustration button p, 

2105 .st-key-viz_view_custom button p { 

2106 font-size: 0.8125rem; 

2107 white-space: nowrap; 

2108 } 

2109 } 

2110 @container sps-rail (max-width: 178px) { 

2111 .st-key-viz_view_scanpath button p, 

2112 .st-key-viz_view_heatmap button p, 

2113 .st-key-viz_view_illustration button p, 

2114 .st-key-viz_view_custom button p { 

2115 font-size: 0; 

2116 } 

2117 /* A markdown `:material/…:` renders as `span[role="img"]` — the 

2118 `stIconMaterial` test id is only on the `icon=` slot. */ 

2119 .st-key-viz_view_scanpath button p span[role="img"], 

2120 .st-key-viz_view_heatmap button p span[role="img"], 

2121 .st-key-viz_view_illustration button p span[role="img"], 

2122 .st-key-viz_view_custom button p span[role="img"] { 

2123 font-size: 1.1rem; 

2124 } 

2125 } 

2126 /* BUG-24: the rail's heading row holds nothing but the heading. UX-44 put a 

2127 compact Reset pill beside it in a second column, which did not fit — the 

2128 rail is ~150px wide inside at ordinary desktop widths — so a container 

2129 query stacked the two below 240px. That rule set `flex-direction: column` 

2130 without clearing Streamlit's `flex-wrap: wrap`, making the header a 

2131 column-WRAPPING flex container: the Reset column wrapped into a second 

2132 track ~100px to the RIGHT of the rail, which the rail's `overflow-y: auto` 

2133 (`overflow-x` computes to `auto` with it) then clipped. Reset was 

2134 invisible at every width but a zoomed-out one. It now sits at the foot of 

2135 the rail (`plot_reset_footer`), full width like every other trigger there, 

2136 so neither the two-column header nor the query that patched it remains. 

2137 Keep the heading a single element: a second column here is what broke. */ 

2138 .st-key-plot_reset_footer { margin-top: 0.35rem; } 

2139 

2140 /* ── Accessibility (WCAG AA) ────────────────────────────────────────── 

2141 Streamlit renders captions as theme-text-color at opacity 0.6, which on 

2142 the #f5f7fa panels measures 4.07:1 — below the 4.5:1 AA threshold. Lift 

2143 to 0.72 (~5.5:1 in light, still well-clear in dark). Theme-safe: the 

2144 underlying color is each theme's own text color, so dark mode stays 

2145 readable rather than getting a hardcoded gray. */ 

2146 div[data-testid="stCaptionContainer"] { opacity: 0.72 !important; } 

2147 

2148 /* Multiselect placeholder text ("All texts", "Choose options", …) is 

2149 BaseWeb's theme-text at 0.6 alpha → 4.07:1, same sub-AA problem as the 

2150 caption. Fix it the same theme-agnostic way: take the full-strength theme 

2151 text color (`inherit`) and mute it with opacity to 0.72 (~5.5:1) — works 

2152 in whichever theme is active, unlike a hardcoded color. The selector 

2153 hits only the placeholder (the div following the search input); once 

2154 chips replace it there's no match, so selected tags keep their color. */ 

2155 [data-testid="stMultiSelect"] [data-baseweb="select"] div:has(> input) + div { 

2156 color: inherit !important; 

2157 opacity: 0.72 !important; 

2158 } 

2159 

2160 /* Section headers now use proper heading levels so screen-reader users get 

2161 a valid outline (no h1→h5 jump): the rail/export sections are <h2>, their 

2162 sub-sections <h3>. Pin the visual size back to the original compact look 

2163 (by Streamlit's stable text-derived ids) so the layout is unchanged. 

2164 BUG-88: the rail's heading is pinned by its container key instead. The 

2165 text-derived id folds an icon's name into it, so UX-138's Material icon 

2166 turned the id `plot-controls` into `tune-plot-controls` — the pin stopped 

2167 matching and the heading fell back to Streamlit's 36px h2, which the 

2168 narrow rail wraps onto two lines. A heading that carries an icon has to 

2169 be pinned by a key. */ 

2170 .st-key-plot_controls_header h2, #scope, #figures, #also-include { 

2171 font-size: 20px !important; line-height: 24px !important; 

2172 font-weight: 600 !important; padding: 6px 0 16px !important; 

2173 /* In the narrow plot-side rail these can wrap; only ever break at a 

2174 space, never mid-word ("Visualizatio↵n"). */ 

2175 word-break: normal !important; overflow-wrap: normal !important; 

2176 } 

2177 .st-key-plot_controls_header h2 { 

2178 margin: 2.4px 0 1.6px !important; 

2179 white-space: nowrap; 

2180 } 

2181 #this-trial, #multiple-trials { 

2182 font-size: 24px !important; line-height: 28.8px !important; 

2183 font-weight: 600 !important; padding: 8px 0 16px !important; 

2184 } 

2185 

2186 /* ── VIZ-2: nudge the smallest UI text up a little for readability ────── 

2187 A gentle, uniform lift on the smallest *native* Streamlit text — captions, 

2188 widget labels, radio / checkbox / toggle option labels, and help tooltips 

2189 (the app's tiniest fonts). Kept modest (~+5-10%) and scoped to small text 

2190 only, so the pinned header sizes and the dense-layout spacing rules above 

2191 are untouched and no panel reflows. */ 

2192 [data-testid="stCaptionContainer"], 

2193 [data-testid="stCaptionContainer"] p { 

2194 font-size: 0.92rem !important; 

2195 } 

2196 [data-testid="stWidgetLabel"] p, 

2197 [data-testid="stWidgetLabel"] label { 

2198 font-size: 0.92rem !important; 

2199 } 

2200 [data-baseweb="radio"] label, 

2201 [data-testid="stCheckbox"] label, 

2202 [data-testid="stExpander"] summary p, 

2203 div[data-testid="stTooltipContent"] p { 

2204 font-size: 0.92rem !important; 

2205 } 

2206 /* BUG-48 — a `help=` tooltip must never intercept a click. Streamlit's 

2207 tooltip is a portalled panel whose open state lives in React, so it can 

2208 outlive the hover that opened it; being an ordinary positioned element it 

2209 then ate the next click that landed on whatever it covered, which is the 

2210 likeliest reason a rail's ▾ sometimes did nothing on the first press. 

2211 Safe because no `help=` in this app contains a link — every tooltip is 

2212 read, never clicked. */ 

2213 div[data-testid="stTooltipContent"] { 

2214 pointer-events: none; 

2215 } 

2216 /* BUG-86 — …and it is shown only while *its own* trigger is under the 

2217 pointer or holds *keyboard* focus. Streamlit 1.64's trigger will not 

2218 close on pointer-leave while focus is inside it, clicking a button puts 

2219 focus there, and it can leave several panels in the page at once (some 

2220 stuck half-closed) — so every button or popover with `help=` that was 

2221 clicked (the ◀ ▶ ⇅ funnel row, the rail's ▾, the presets) kept its panel 

2222 floating after the pointer moved on. `:hover` and `:focus-visible` are 

2223 the browser's own bookkeeping, right even when no event reached React, 

2224 and `:focus-visible` is what tells a keyboard user's focus, which should 

2225 keep its tooltip, from the focus a click leaves behind, which should not. 

2226 Two selectors, because CSS cannot relate a portalled panel to the 

2227 trigger that owns it: 

2228 · once `app._TOOLTIP_OWNER_SCRIPT` is running (its flag on `<html>`), 

2229 a panel shows only while marked `[data-sps-tooltip-owned]` — its own 

2230 trigger (the element whose `aria-describedby` names it) is hovered or 

2231 keyboard-focused. That is what stops a hover on one button reviving 

2232 every other stale panel, and a panel is judged before it is painted; 

2233 · `body:not(:has(…))` hides every panel while no trigger at all is, the 

2234 floor if that script cannot run. 

2235 The hide is immediate: a delayed one (for a fade that Streamlit's own 

2236 entrance animation, holding opacity at 1, never let run) flashed a stale 

2237 panel for 100 ms. This replaced BUG-48/51's JavaScript sweeper, which 

2238 waited for a Base Web `[data-baseweb="tooltip"]` layer that Streamlit no 

2239 longer renders and so never closed anything; 

2240 `tests/test_tooltip_visibility.py` fails if the DOM named here leaves 

2241 Streamlit's bundle. */ 

2242 html[data-sps-tooltip-owners] [role="tooltip"]:not([data-sps-tooltip-owned]) :is([data-testid="stTooltipContent"], [data-testid="stTooltipErrorContent"]), 

2243 body:not(:has( 

2244 [data-testid="stTooltipHoverTarget"]:hover, 

2245 [data-testid="stTooltipHoverTarget"] :focus-visible, 

2246 [data-testid="stTooltipErrorHoverTarget"]:hover, 

2247 [data-testid="stTooltipErrorHoverTarget"] :focus-visible 

2248 )) :is([data-testid="stTooltipContent"], [data-testid="stTooltipErrorContent"]) { 

2249 visibility: hidden; 

2250 } 

2251 

2252 /* ── UX-19: width breakpoints ──────────────────────────────────────────── 

2253 Every layout decision above was fixed-width — the only @media rule in this 

2254 file was `prefers-color-scheme` — so on an ordinary laptop (a 13" screen, 

2255 or a half-width window on a big display) the controls, chips and plot 

2256 column crowded or overlapped. These target ≥1280px down to ~1024px. 

2257 

2258 The scanpath plot itself needs nothing here: `tabs._render_true_scale_chart` 

2259 renders at the figure's exact pixel size and CSS-scales the whole block 

2260 *uniformly* to the column, capped at 1×. It only ever shrinks, and a 

2261 uniform transform can't distort — so the true-to-scale guarantee holds at 

2262 every width by construction. What actually broke is chrome: the chip strip 

2263 (fixed by UX-11's wrapping strip), the rail's no-wrap labels, the header 

2264 nav, and the page's generous side padding. */ 

2265 

2266 /* First: reclaim the page's horizontal padding, which is the cheapest way to 

2267 give the plot + rail split more room before anything has to reflow. */ 

2268 @media (max-width: 1400px) { 

2269 .stMainBlockContainer, 

2270 section.main > div.block-container { 

2271 padding-left: 1.5rem !important; 

2272 padding-right: 1.5rem !important; 

2273 } 

2274 /* The pinned section-header sizes (see the heading-level rules above) 

2275 are what push the narrow rail's headers to two lines first. */ 

2276 .st-key-plot_controls_header h2, #scope, #figures, #also-include { 

2277 font-size: 18px !important; line-height: 22px !important; 

2278 } 

2279 } 

2280 

2281 /* #374 F11 — the rail keeps a floor of 260px; the figure takes the rest 

2282 (it scales uniformly, so a narrower plot column costs nothing but 

2283 size). At a ⅕ share the rail fell to ~170px at 1024px, and its one-line 

2284 rows (UX-153) cut every name to "An…", "Fi…". Only side by side: below 

2285 640px Streamlit stacks the columns, and the rail is full width anyway. 

2286 This replaces a ≤1200px rule that let rail labels wrap, which never 

2287 took effect on the switch rows — UX-153 keeps those on one line. */ 

2288 @media (min-width: 640px) { 

2289 [data-testid="stHorizontalBlock"]:has( 

2290 > [data-testid="stColumn"] > [data-testid="stVerticalBlock"] 

2291 > [data-testid="stLayoutWrapper"] > .st-key-scanpath_rail 

2292 ) { 

2293 /* The floor would otherwise wrap the rail under the figure: the 

2294 two flex bases (⅘ + 260px) no longer fit one line. */ 

2295 flex-wrap: nowrap; 

2296 } 

2297 [data-testid="stHorizontalBlock"]:has( 

2298 > [data-testid="stColumn"] > [data-testid="stVerticalBlock"] 

2299 > [data-testid="stLayoutWrapper"] > .st-key-scanpath_rail 

2300 ) > [data-testid="stColumn"]:first-child { 

2301 min-width: 0; 

2302 } 

2303 [data-testid="stColumn"]:has( 

2304 > [data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] 

2305 > .st-key-scanpath_rail 

2306 ) { 

2307 min-width: 260px; 

2308 } 

2309 } 

2310 /* ...and where the floor bites, the 4rem gutter beside it gives 2.5rem 

2311 back to the picker row above the figure. */ 

2312 @media (min-width: 640px) and (max-width: 1300px) { 

2313 [data-testid="stHorizontalBlock"]:has( 

2314 > [data-testid="stColumn"] > [data-testid="stVerticalBlock"] 

2315 > [data-testid="stLayoutWrapper"] > .st-key-scanpath_rail 

2316 ) { 

2317 column-gap: 1.5rem !important; 

2318 } 

2319 } 

2320 @media (max-width: 1200px) { 

2321 .st-key-scanpath_rail { padding-left: 0.6rem; padding-right: 0.6rem; } 

2322 /* A button label must never break mid-word ("Scanp/ath"). */ 

2323 .st-key-scanpath_rail button p { 

2324 word-break: normal; 

2325 overflow-wrap: normal; 

2326 } 

2327 /* The typed boxes beside each slider (UX-9) give up width first — the 

2328 slider is the primary control. */ 

2329 div[class*="st-key-"][class*="__num"] [data-testid="stNumberInputContainer"] { 

2330 max-width: 4rem; 

2331 } 

2332 } 

2333 

2334 /* Last resort at the bottom of the target range: nothing may overflow its 

2335 container, even a long unbroken id or a translated label. */ 

2336 @media (max-width: 1024px) { 

2337 .stMainBlockContainer, 

2338 section.main > div.block-container { 

2339 padding-left: 0.75rem !important; 

2340 padding-right: 0.75rem !important; 

2341 } 

2342 .st-key-scanpath_rail [data-testid="stWidgetLabel"] p { overflow-wrap: anywhere; } 

2343 /* A figure that somehow can't scale down far enough scrolls rather than 

2344 being squeezed out of true scale (the one guarantee that must hold). */ 

2345 /* Match both iframe titles: Streamlit titles these "st.iframe" now, 

2346 "components.html" on historical builds. */ 

2347 [data-testid="stElementContainer"]:has(iframe[title*="components.html"]), 

2348 [data-testid="stElementContainer"]:has(iframe[title*="st.iframe"]) { 

2349 overflow-x: auto; 

2350 /* Without this, overflow-x alone makes the y axis auto as well. */ 

2351 overflow-y: hidden; 

2352 } 

2353 } 

2354 </style> 

2355 """ 

2356 for i in (0, 1, 3): 

2357 css = css.replace(f"__SELECTOR_FLOOR_{i}__", selector_track_floor(i)) 

2358 css = css.replace( 

2359 "__SELECTOR_SCREEN_FLOOR__", f"{SELECTOR_SCREEN_FLOOR_REM}rem" 

2360 ).replace("__SELECTOR_STEPS_FLOOR__", f"{SELECTOR_STEPS_FLOOR_REM}rem") 

2361 return css 

2362 

2363 

2364def menu_width_css(widget_key: str, labels) -> str: 

2365 """Open ``widget_key``'s dropdown as wide as its longest option. 

2366 

2367 The menu is portalled to ``<body>`` and virtualized (see 

2368 `mapping_menu_css`), so it cannot size itself to its content; it is given 

2369 a width from the labels instead, never narrower than the control and never 

2370 wider than the window. The rule keys on the open combobox: one menu is open 

2371 at a time, and while this widget's input is expanded the menu is its own. 

2372 """ 

2373 longest = max((len(str(label)) for label in labels), default=0) 

2374 if not longest: 

2375 return "" 

2376 # ~0.58em a character in the app's sans, plus the menu's padding. 

2377 want = f"{longest * 0.58 + 2.5:.1f}em" 

2378 scope = f'body:has(.st-key-{widget_key} [aria-expanded="true"])' 

2379 menu = 'div:has(> [role="listbox"])' 

2380 return ( 

2381 f"<style>{scope} {menu} {{" 

2382 f" width: max(var(--trigger-width, 0px), min({want}, 92vw)) !important;" 

2383 " max-width: 92vw !important; }" 

2384 f' {scope} {menu} [role="option"] {{ white-space: nowrap; }}</style>' 

2385 ) 

2386 

2387 

2388def widen_menu(widget_key: str, labels) -> None: 

2389 """Inject `menu_width_css` for ``widget_key`` (a style-only `st.html`, 

2390 which takes no room in the layout).""" 

2391 import streamlit as st 

2392 

2393 css = menu_width_css(widget_key, labels) 

2394 if css: 

2395 st.html(css) 

2396 

2397 

2398def selector_track_floor(index: int) -> str: 

2399 """The CSS `min-width` of one `SELECTOR_ROW_GRID` track (UX-181). 

2400 

2401 Shared by the app's rule and the loading skeleton's grid, so the skeleton 

2402 draws the row the page is about to show. 

2403 """ 

2404 floor = SELECTOR_ROW_FLOORS_REM[index] 

2405 cap = SELECTOR_ROW_FLOOR_CAPS[index] 

2406 if floor is None: 

2407 return "0" 

2408 if cap is None: 

2409 return f"{floor}rem" 

2410 return f"min({floor}rem, {cap}%)" 

2411 

2412 

2413def mapping_menu_css() -> str: 

2414 """UX-71 r3 — the option-list widening, for the mapping screens only. 

2415 

2416 A mapping row packs four or five selects across, so the *control* is narrow 

2417 by design and its dropdown inherits that width — and a clipped option is 

2418 ambiguous, not just ugly, since two columns routinely share a visible prefix 

2419 (``CURRENT_FIX_INTEREST_AREA_ID`` vs ``…_INDEX``). 

2420 

2421 Two earlier attempts (UX-53 r14, UX-71 r1) failed for a reason neither 

2422 recorded: they styled ``div[data-baseweb="popover"]``, and **Streamlit no 

2423 longer renders a selectbox with BaseWeb**. It is a `react-aria` ComboBox 

2424 whose list is portalled to ``<body>`` inside a popover div carrying the 

2425 trigger's width as an *inline* style, next to a ``--trigger-width`` variable. 

2426 Every rule aimed at the old markup matched nothing, which is why "wrap the 

2427 option text" changed nothing on screen. 

2428 

2429 Wrapping is also the wrong lever now: the list is **virtualized** (fixed row 

2430 heights, absolutely positioned rows), so a two-line option would overlap its 

2431 neighbour — and for the same reason ``width: max-content`` collapses back to 

2432 the container's own width. The list has to be given a width, and it is 

2433 given one relative to its trigger. 

2434 

2435 **Why this is injected per screen rather than added to the global sheet**: 

2436 the popover is portalled out of our DOM, so it cannot be scoped by an 

2437 ancestor selector — a global rule would widen *every* dropdown in the app, 

2438 including the plot rail's, which sits against the right edge of the window 

2439 where a wider menu has nowhere to grow into. The mapping screens have no 

2440 right rail, so there the extra width is free. Emitted by the add-dataset 

2441 wizard and by the 🗂️ Data page's mapping editor, which are the two places a 

2442 column name is the thing being read. 

2443 """ 

2444 return """ 

2445 <style> 

2446 div:has(> [role="listbox"]) { 

2447 width: min(calc(var(--trigger-width, 12rem) + 9rem), 92vw) !important; 

2448 max-width: 92vw !important; 

2449 } 

2450 div:has(> [role="listbox"]) [role="option"] { 

2451 /* The row is as wide as the menu now, so the label has the room it 

2452 needs; keep it on one line so the virtualizer's row height holds. */ 

2453 white-space: nowrap; 

2454 } 

2455 /* #374 F13 — a mapped column's chip wraps instead of being cut to a stub 

2456 ("T…", "RECORDI…"): the mapping cells are a seventh of the row, less 

2457 with the setup guide open, and the chip is the user's one check that the 

2458 detection is right. Hovering still shows the full name (`title`). */ 

2459 [class*="st-key-col_map_"][class*="_cell"] { 

2460 container-type: inline-size; 

2461 } 

2462 [class*="st-key-col_map_"][class*="_cell"] [data-tag] { 

2463 height: auto; 

2464 max-width: 100%; 

2465 } 

2466 [class*="st-key-col_map_"][class*="_cell"] [data-tag] > span[title] { 

2467 white-space: normal; 

2468 overflow-wrap: anywhere; 

2469 text-overflow: clip; 

2470 line-height: 1.25; 

2471 } 

2472 /* Narrow (the guide open, or a small window): the clear-all ⊗ goes — each 

2473 chip has its own × — and the caret floats over the corner, so the chips 

2474 get the cell's whole width rather than the third of it left beside two 

2475 buttons. */ 

2476 @container (max-width: 190px) { 

2477 [class*="st-key-col_map_"][class*="_cell"] [data-testid="stMultiSelect"] 

2478 button[aria-label="Clear all"] { 

2479 display: none; 

2480 } 

2481 [class*="st-key-col_map_"][class*="_cell"] [data-testid="stMultiSelect"] 

2482 [role="group"] { 

2483 position: relative; 

2484 } 

2485 [class*="st-key-col_map_"][class*="_cell"] [data-testid="stMultiSelect"] 

2486 button[aria-label="Open"] { 

2487 position: absolute; 

2488 right: 0; 

2489 top: 0.25rem; 

2490 } 

2491 [class*="st-key-col_map_"][class*="_cell"] 

2492 [data-testid="stMultiSelectTagsContainer"] { 

2493 flex: 1 1 100%; 

2494 min-width: 0; 

2495 padding-right: 1.1rem; 

2496 } 

2497 [class*="st-key-col_map_"][class*="_cell"] [data-tag] > span[title] { 

2498 font-size: 0.8rem; 

2499 } 

2500 } 

2501 </style> 

2502 """