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
« 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."""
3from __future__ import annotations
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)
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).
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.
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`.
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.) */
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.
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; }
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; }
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 }
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).
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; }
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; }
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; }
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 }
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 }
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 }
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; }
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); }
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 }
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; }
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 }
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.
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.
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.
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.
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.
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 }
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 }
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; }
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 }
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; }
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 }
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 }
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".
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; }
1673 /* UX-66 — the add-dataset screen's one permanent row: title, guide, docs
1674 link, cancel. It stays put while the page scrolls.
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.
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; }
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 }
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 }
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; }
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; }
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 }
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 }
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 }
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 }
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. */
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; }
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 }
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; }
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; }
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 }
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 }
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 }
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.
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. */
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 }
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 }
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
2364def menu_width_css(widget_key: str, labels) -> str:
2365 """Open ``widget_key``'s dropdown as wide as its longest option.
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 )
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
2393 css = menu_width_css(widget_key, labels)
2394 if css:
2395 st.html(css)
2398def selector_track_floor(index: int) -> str:
2399 """The CSS `min-width` of one `SELECTOR_ROW_GRID` track (UX-181).
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}%)"
2413def mapping_menu_css() -> str:
2414 """UX-71 r3 — the option-list widening, for the mapping screens only.
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``).
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.
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.
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 """