diff --git a/docs/2026-07-02-network-spinner-smoke-checklist.md b/docs/2026-07-02-network-spinner-smoke-checklist.md new file mode 100644 index 0000000..a0475ea --- /dev/null +++ b/docs/2026-07-02-network-spinner-smoke-checklist.md @@ -0,0 +1,26 @@ +# Network spinner — manual smoke (2026-07-02) + +Run each app: `micromamba run -n shiny shiny run --launch-browser app.py`. + +## SESPy (app.py) +- [ ] CLD Visualization: spinner shows over the graph on first load and clears + when it draws (physics-off view — proves the _ready hide, not _stabilized). +- [ ] Leverage Points (physics-on) and back: spinner re-shows on each network + (re)render, then clears. +- [ ] Node/edge modal opens ABOVE where the spinner was (z-index ok); toolbar + buttons clickable after load. + +## MosaicSES (../MosaicSES/app.py) — separate skin copy; MUST be checked here +- [ ] Topology: spinner shows over the 6-node graph, clears when drawn. Confirms + MosaicSES/www/sespy-skin.css got the CSS (else you'll see an UNSTYLED veil + or nothing). +- [ ] Drill into a compartment (larger CLD): spinner visibly useful, then clears. + +## Themes / a11y +- [ ] Switch to a dark theme: veil + text still legible over the canvas. +- [ ] OS "reduce motion" on: veil + text + static ring, no rotation. + +## Automated coverage (already green) +- `tests/test_cld_e2e.py` asserts the show→hide cycle via the sticky + `data-sespy-net-shown` / `data-sespy-net-hidden="ready"` markers (CI-gated, + 5/5 local). It does NOT assert the visible pixels — hence this manual pass. diff --git a/docs/superpowers/plans/2026-07-02-network-loading-spinner.md b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md new file mode 100644 index 0000000..9bd7623 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md @@ -0,0 +1,345 @@ +# Network Loading Spinner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a centered spinner over every pyvis network output while it builds, auto-hidden the moment the graph is drawn — in both the SESPy and MosaicSES apps. + +**Architecture:** A pure-CSS spinner rendered as `::before`/`::after` pseudo-elements on the `.pyvis-network-output` element, toggled by an `is-loading` class. A short shell script (`sespy/dashboard.py`) adds the class on Shiny's `shiny:recalculating` event and removes it on the fork's always-sent `_ready` input (with an 8 s fallback). Pseudo-elements + a class are used (not an injected `
`) because the fork runs `el.innerHTML = ''` on every render, which would destroy an injected child. The CSS is mirrored into both apps' separate `www/sespy-skin.css` copies. + +**Tech Stack:** Python Shiny, the razinka `pyvis.shiny` 4.2 fork (vis-network 10.0.2), vanilla JS + jQuery (`$` present), CSS. Tests: standalone Playwright e2e scripts run via `tests/run_e2e.py`. + +## Global Constraints + +- Python env: run everything through the existing micromamba env — `micromamba run -n shiny …`. Do NOT create venvs or `pip install`. +- Spinner CSS goes in BOTH `SESPy/www/sespy-skin.css` and `MosaicSES/www/sespy-skin.css` (the apps serve separate copies; the SESPy JS reaches MosaicSES via the editable `sespy` install, but the CSS does not). +- Hide signal is the fork's `_ready` input (always sent, every render, physics-independent — `bindings.js:836`), NOT `_stabilized` (which never fires for the physics-off CLD view). +- Do NOT inject a child node into `.pyvis-network-output`; the fork's `innerHTML=''` (`bindings.js:90`) would wipe it. Use pseudo-elements + the `is-loading` class (both survive `innerHTML=''`). +- Spinner pseudo-element z-index MUST be below `1000` (the fork's `.pyvis-modal-overlay`) so node/edge modals stay on top. Use 5 (veil) / 6 (ring). +- Pseudo-elements MUST be `pointer-events: none` so they never block the canvas. +- e2e asserts the END state only (sticky show-marker present + not stuck loading); never race to catch the transient visible spinner (condition-based-waiting lesson from the leverage/simulation de-flake). +- ruff is blocking in CI; `tests/**` already ignores E402/E501. Keep `ruff check` clean. +- Overlay label text is exactly `Rendering network…` (real ellipsis, U+2026 — in CSS written as the escape `\2026`). Fallback timeout is exactly `8000` ms. + +--- + +## File Structure + +- `sespy/dashboard.py` — add `network_spinner_js = ui.tags.script(...)` next to `theme_js`, include it in the existing `ui.head_content(...)`. Class-toggle behavior only. Ships to both apps via the editable `sespy` install. +- `tests/test_cld_e2e.py` — extend the existing CLD e2e with sticky-marker + not-stuck assertions. CI-gated. Depends only on the JS (Task 1), not the CSS. +- `www/sespy-skin.css` (SESPy) and `../MosaicSES/www/sespy-skin.css` — `position: relative` on `.pyvis-network-output` + the `.is-loading::before`/`::after` spinner styles. Identical block in both. Visual; smoke-verified. +- `docs/2026-07-02-network-spinner-smoke-checklist.md` — cross-app manual smoke. + +--- + +## Task 1: Shell `is-loading` toggle + CLD e2e (TDD, behavioral core) + +The e2e checks the class/attribute contract only (not pixels), so it depends on the JS, not the CSS — hence test-first here, CSS in Task 2. + +**Files:** +- Modify: `sespy/dashboard.py` (add `network_spinner_js`, include in `head_content`) +- Test: `tests/test_cld_e2e.py` (extend the existing script) + +**Interfaces:** +- Consumes (from the environment): the pyvis fork sends `Shiny.setInputValue(outputId + '_ready', …)` on every render (`bindings.js:836`); Shiny fires `shiny:recalculating` on the `.pyvis-network-output` output element. +- Produces (consumed by Task 2 CSS + smoke): on show, the `.pyvis-network-output` element gets class `is-loading` AND attribute `data-sespy-net-shown="1"` (sticky, never removed); on `_ready`, `is-loading` is removed. + +- [ ] **Step 1: Write the failing test** — in `tests/test_cld_e2e.py`, insert after the existing delay-edge assertions (after the `assert not all(flags), …` line, currently line 35) and before `await page.screenshot(...)` (currently line 37): + +```python + # --- network loading spinner (shared shell) --- + # End-state only (do NOT race the transient visible spinner): + # (1) the sticky marker proves the show path ran (is-loading was added); + # (2) is-loading must clear once the graph is up (via _ready or the + # 8s fallback). + shown = await page.evaluate( + "() => document.getElementById('cld-network')" + " && document.getElementById('cld-network').getAttribute('data-sespy-net-shown') === '1'" + ) + assert shown, "#cld-network never entered loading state (spinner show path dead)" + not_loading = False + for _ in range(24): # up to ~12s: covers _ready and the 8s fallback + not_loading = await page.evaluate( + "() => { const el = document.getElementById('cld-network');" + " return !!el && !el.classList.contains('is-loading'); }" + ) + if not_loading: + break + await page.wait_for_timeout(500) + assert not_loading, "#cld-network stuck in .is-loading after the graph rendered" + # Provenance: the physics-off CLD MUST hide via the fork's _ready signal, + # not the 8s fallback. Without this, a broken _ready hide is masked by the + # fallback (poll window > fallback) and the test would pass while users see + # an 8s frozen spinner. + hidden_by = await page.evaluate( + "() => { const el = document.getElementById('cld-network');" + " return el && el.getAttribute('data-sespy-net-hidden'); }" + ) + assert hidden_by == "ready", \ + f"CLD hid via {hidden_by!r}, not '_ready' — fallback masked a broken hide path" + print("network spinner show->hide (via _ready): OK") +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd "/c/Users/arturas.baziukas/OneDrive - ku.lt/HORIZON_EUROPE/Marine-SABRES/SESPy" +micromamba run -n shiny shiny run --port 8000 app.py & # wait until :8000 serves +PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py +``` + +Expected: FAIL at `assert shown` — nothing sets `data-sespy-net-shown` yet. + +- [ ] **Step 3: Add the script block** — in `sespy/dashboard.py`, immediately after the `theme_js = ui.tags.script(""" … """)` block, add: + +```python + network_spinner_js = ui.tags.script(""" + (function () { + var FALLBACK_MS = 8000; + var timers = {}; + + // Bound at parse time (jQuery only; no Shiny object needed), so the + // handlers exist before the first output renders — no first-load race. + $(document).on('shiny:recalculating', function (e) { + var el = e.target; + if (!el || !el.classList || !el.classList.contains('pyvis-network-output')) return; + el.classList.add('is-loading'); + el.setAttribute('data-sespy-net-shown', '1'); // sticky proof the spinner showed + el.removeAttribute('data-sespy-net-hidden'); // reset provenance for this cycle + if (el.id) { + clearTimeout(timers[el.id]); + timers[el.id] = setTimeout(function () { + el.classList.remove('is-loading'); + el.setAttribute('data-sespy-net-hidden', 'fallback'); // hid via safety net + }, FALLBACK_MS); + } + }); + + // The fork sends _ready when the network is created/drawn — every + // render, physics or not. Hide on that (NOT _stabilized, which never + // fires for physics-off graphs like the hierarchical CLD). + $(document).on('shiny:inputchanged', function (e) { + if (!e.name || e.name.slice(-6) !== '_ready') return; + var id = e.name.slice(0, -6); + var el = document.getElementById(id); + if (el && el.classList.contains('pyvis-network-output')) { + el.classList.remove('is-loading'); + el.setAttribute('data-sespy-net-hidden', 'ready'); // hid via the real _ready path + clearTimeout(timers[id]); + } + }); + })(); + """) +``` + +- [ ] **Step 4: Include it in the head** — in the same file's `ui.head_content( … )` call, add `network_spinner_js` immediately after `theme_js,`: + +```python + burger_js, + bookmark_js, + theme_js, + network_spinner_js, + ), +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +# restart shiny run so the new dashboard.py is served, wait for :8000, then: +PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py +``` + +Expected: PASS — prints `network spinner show->hide: OK` and `cld e2e assertions pass`. + +- [ ] **Step 6: Flake guard — run the CLD e2e 5 times** + +```bash +for i in 1 2 3 4 5; do PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py >/dev/null 2>&1; echo "run $i EXIT=$?"; done +``` + +Expected: all `EXIT=0`. + +- [ ] **Step 7: Lint** + +```bash +micromamba run -n shiny ruff check sespy tests app.py +``` + +Expected: `All checks passed!` + +- [ ] **Step 8: Commit** + +```bash +git add sespy/dashboard.py tests/test_cld_e2e.py +git commit -m "feat(shell): network spinner is-loading toggle + CLD e2e + +Adds is-loading (+ sticky data-sespy-net-shown) on shiny:recalculating and +removes it on the fork's always-sent _ready input, with an 8s fallback. +CLD e2e asserts the show->hide cycle (sticky marker + not stuck), non-racy." +``` + +--- + +## Task 2: Spinner CSS (pseudo-elements) in both skins + +**Files:** +- Modify: `www/sespy-skin.css:122-145` +- Modify: `../MosaicSES/www/sespy-skin.css` (its `.pyvis-network-output` rule — find by search; divergent copy, different line numbers) + +**Interfaces:** +- Consumes: the `is-loading` class contract from Task 1. +- Produces: the visible spinner (veil + ring) via pseudo-elements. + +- [ ] **Step 1: Add the CSS to the SESPy skin** — in `www/sespy-skin.css`, change the rule at lines 122-124 from: + +```css +.pyvis-network-output { + display: block !important; +} +``` + +to: + +```css +.pyvis-network-output { + display: block !important; + position: relative; /* positioning context for the loading pseudo-elements */ +} +``` + +Then, immediately after the `.pyvis-network-output .pyvis-network-canvas { border: none !important; }` rule (currently ending line 145) and before the `TITLE BAR` comment block, append: + +```css +/* (4) Network loading spinner (shared shell). Rendered as pseudo-elements so it + survives the pyvis fork's `el.innerHTML=''` on every render (bindings.js:90); + toggled by the `is-loading` class from network_spinner_js. ::before is the + veil + label, ::after is the ring. z-index stays below the fork's + .pyvis-modal-overlay (1000) so node/edge modals sit on top. */ +.pyvis-network-output.is-loading::before { + content: "Rendering network\2026"; /* \2026 = U+2026 ellipsis */ + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding-top: 3.2rem; /* leave room for the ring above the text */ + background: rgba(255, 255, 255, 0.68); /* fallback for engines without color-mix */ + background: color-mix(in srgb, var(--bs-body-bg, #ffffff) 68%, transparent); + color: var(--bs-secondary-color, #555); + font-size: 0.9rem; + pointer-events: none; +} +.pyvis-network-output.is-loading::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + z-index: 6; + width: 2.25rem; + height: 2.25rem; + margin: -2.4rem 0 0 -1.125rem; /* center horizontally, sit above the label */ + border: 3px solid color-mix(in srgb, var(--bs-secondary-color, #555) 25%, transparent); + border-top-color: var(--bs-secondary-color, #555); + border-radius: 50%; + animation: sespy-net-spin 0.8s linear infinite; + pointer-events: none; +} +@keyframes sespy-net-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .pyvis-network-output.is-loading::after { animation: none; } +} +``` + +- [ ] **Step 2: Add the identical CSS to the MosaicSES skin** — open `../MosaicSES/www/sespy-skin.css`, find its `.pyvis-network-output {` rule, add `position: relative;` to it the same way, and append the **exact same** `/* (4) Network loading spinner … */` block from Step 1. + +- [ ] **Step 3: Verify visually in both apps** — boot each app and confirm the spinner shows on a network load and clears; confirm no unstyled text blob: + +```bash +# SESPy +micromamba run -n shiny shiny run --port 8000 app.py & # open http://127.0.0.1:8000, watch CLD load +# MosaicSES +( cd "../MosaicSES" && micromamba run -n shiny shiny run --port 8001 app.py & ) # open http://127.0.0.1:8001, watch Topology +``` + +Expected: a centered spinner + "Rendering network…" veil during load in BOTH apps, clearing when the graph draws. + +- [ ] **Step 4: Commit (two repos)** + +```bash +git add www/sespy-skin.css +git commit -m "style(shell): pyvis network loading spinner pseudo-elements (SESPy skin)" +( cd "../MosaicSES" && git add www/sespy-skin.css && git commit -m "style: mirror pyvis network loading spinner CSS from SESPy skin" ) +``` + +Expected: one commit in each repo. + +--- + +## Task 3: Cross-app manual smoke checklist + +**Files:** +- Create: `docs/2026-07-02-network-spinner-smoke-checklist.md` + +**Interfaces:** +- Consumes: the `is-loading` / pseudo-element contract from Tasks 1-2. + +- [ ] **Step 1: Write the checklist** + +```markdown +# Network spinner — manual smoke (2026-07-02) + +Run each app: `micromamba run -n shiny shiny run --launch-browser app.py`. + +## SESPy (app.py) +- [ ] CLD Visualization: spinner shows over the graph on first load and clears + when it draws (physics-off view — proves the _ready hide, not _stabilized). +- [ ] Leverage Points (physics-on) and back: spinner re-shows on each network + (re)render, then clears. +- [ ] Node/edge modal opens ABOVE where the spinner was (z-index ok); toolbar + buttons clickable after load. + +## MosaicSES (../MosaicSES/app.py) — separate skin copy; MUST be checked here +- [ ] Topology: spinner shows over the 6-node graph, clears when drawn. Confirms + MosaicSES/www/sespy-skin.css got the CSS (else you'll see an UNSTYLED veil + or nothing). +- [ ] Drill into a compartment (larger CLD): spinner visibly useful, then clears. + +## Themes / a11y +- [ ] Switch to a dark theme: veil + text still legible over the canvas. +- [ ] OS "reduce motion" on: veil + text + static ring, no rotation. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/2026-07-02-network-spinner-smoke-checklist.md +git commit -m "docs: cross-app manual smoke checklist for the network spinner" +``` + +--- + +## Notes for the implementer + +- **Do not reintroduce an injected overlay `
`.** The fork's `el.innerHTML=''` + (`bindings.js:90`) destroys child nodes on every render; pseudo-elements + + the `is-loading` class survive it. This is the whole reason for the approach. +- **First-load SHOW** is covered because the handlers bind at parse time (jQuery + event delegation, no `Shiny` object needed), before the first output renders. + The e2e's sticky-marker assertion depends on this; if it ever flakes on + `assert shown`, check that the script is bound before first render. +- `_ready` suffix is 6 chars (`_ready`); `slice(-6)`/`slice(0,-6)` are correct + and do not collide with `_stabilized`, `_stabilizationProgress`, etc. +- `_ready` fires ~1 frame before vis-network commits its first paint, so on a + *re-render* there can be a sub-perceptible (~16 ms) blank flash between hiding + the veil and the redraw. Cosmetic and acceptable; only revisit (e.g. hide on + vis `afterDrawing`) if smoke shows a visible flicker. +- The e2e asserts `data-sespy-net-hidden === 'ready'`, not just that `is-loading` + cleared — because the 8 s fallback would otherwise mask a broken `_ready` hide + (the 12 s poll outlasts the 8 s fallback). Keep that assertion. +- If `#cld-network` is not the CLD output id in the running app, discover it via + `Object.keys(window.pyvisNetworks)` in the console and update the test. As of + this plan it is `cld-network`. +- In CSS the ellipsis is the escape `\2026` (a single backslash), NOT the JS + `…`. Confirm it renders in the veil during smoke. +- MosaicSES is a SEPARATE git repo; its skin edit is committed in that repo, and + verifying it requires running/smoking the MosaicSES app (SESPy CI won't). diff --git a/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md b/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md new file mode 100644 index 0000000..328ad46 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md @@ -0,0 +1,152 @@ +# Network loading spinner — design + +**Date:** 2026-07-02 (revised 2026-07-03 after multi-agent plan review) +**Status:** approved (brainstorming), revised post-review +**Scope:** SESPy shared shell + both apps' skin copies — affects every pyvis network view in SESPy and MosaicSES. + +## Problem + +The pyvis network views (Topology, CLD Visualization, Leverage, Network Metrics, +Intervention, Loop Analysis, Simplify) render with a visible delay and show no +feedback during it, so the panel looks blank/broken until the graph appears. + +The genuinely *blank* window is: the server recomputes the `render_pyvis_network` +output, then the client rebuilds the DOM and instantiates vis-network. Physics +stabilization (nodes animating into place) happens *after* the graph is already +drawn and visible — it is not a blank wait, and the common CLD view runs with +physics **disabled** anyway (`sespy/modules/cld_visualization.py:187`, +hierarchical layout). So the indicator must cover the blank build/instantiate +window and clear the moment the graph is first drawn — uniformly, regardless of +whether physics runs. + +## Goal + +Show a centered spinner over each pyvis output while it builds, auto-hidden the +moment the graph is drawn. One implementation in the shared shell (plus the CSS +mirrored into both apps' skin copies). No changes to the pyvis fork. + +Non-goals (YAGNI): percentage progress bar, per-view config, persistence, +covering non-pyvis outputs, de-duplicating the two skin copies (pre-existing +divergence, tracked separately). + +## Approach + +A pure-CSS spinner rendered as **`::before`/`::after` pseudo-elements on the +`.pyvis-network-output` element itself**, toggled by an `is-loading` class that a +short shell script adds/removes based on documented Shiny client events. + +### Why pseudo-elements (the load-bearing decision) + +The pyvis fork's binding runs `el.innerHTML = ''` at the top of every +`renderValue` (`pyvis/shiny/bindings.js:90`, where `el` is the +`.pyvis-network-output` element). Any child node injected into `el` (an overlay +`
`) is therefore **destroyed on every render** — precisely when the graph is +building. Pseudo-elements are generated content on the element, not child nodes, +so `innerHTML = ''` cannot remove them; and the `is-loading` **class** is an +attribute, which also survives `innerHTML = ''`. This eliminates the injected +node, a MutationObserver, and the entire wipe/re-inject problem. (An earlier +draft used an injected overlay div + observer; multi-agent review found the fork +wipes it — hence this approach.) + +### Show / hide triggers + +| Phase | Signal | Action | +|---|---|---| +| Server build + client instantiate | `shiny:recalculating` on a `.pyvis-network-output` element | add `is-loading`; set a sticky `data-sespy-net-shown="1"` marker; arm an 8 s fallback | +| Graph drawn | `shiny:inputchanged` with `name` ending `_ready` (the fork sends `_ready` **"always … sent"**, `bindings.js:836-841`, unconditional, every render, physics-independent) → map to `#` | remove `is-loading`; clear the timer | +| Safety net | `_ready` missed for any reason | 8 s per-output timeout removes `is-loading` | + +`_ready` (not `_stabilized`) is the hide signal: `_stabilized` only fires when +physics runs, so the physics-off CLD view would never receive it. `_ready` fires +for all renders when the network is created/about to draw — the correct, uniform +"graph is up" moment. + +### Components + +1. **CSS** — appended to **both** `SESPy/www/sespy-skin.css` and + `MosaicSES/www/sespy-skin.css` (the apps serve separate copies). Adds + `position: relative` to `.pyvis-network-output`, then: + - `.pyvis-network-output.is-loading::before` — a full veil (`inset:0`, + translucent bg) carrying the centered label "Rendering network…". + - `.pyvis-network-output.is-loading::after` — a centered rotating spinner ring + (border + `@keyframes`), positioned just above the label. + - `z-index` 5/6 — below the fork's `.pyvis-modal-overlay` (z-index 1000, + `pyvis/shiny/styles.css:168-179`), so node/edge modals stay on top. + - `@media (prefers-reduced-motion: reduce)` — no rotation. + - Pseudo-elements are `pointer-events: none` so they never block the canvas + (only visible while `.is-loading`; the graph is interactive as soon as the + class is removed). + +2. **JS** (`network_spinner_js` in `sespy/dashboard.py`, injected in + `head_content` next to `theme_js`; ships to both apps via the editable `sespy` + install). Registers the two jQuery delegated handlers at parse time (they need + only jQuery, not the `Shiny` object, so binding before the first render is + guaranteed — no first-load race): + - `$(document).on('shiny:recalculating', …)` → if `e.target` has class + `pyvis-network-output`, add `is-loading`, set `data-sespy-net-shown="1"` + (sticky), clear any prior `data-sespy-net-hidden`, and arm/re-arm an 8 s + `setTimeout` (keyed by `e.target.id`) that removes `is-loading` and sets + `data-sespy-net-hidden="fallback"`. + - `$(document).on('shiny:inputchanged', …)` → if `e.name` ends with `_ready`, + strip the suffix, and on the matching `#.pyvis-network-output` remove + `is-loading`, set `data-sespy-net-hidden="ready"`, and clear its timer. + No MutationObserver, no DOM injection. The `data-sespy-net-hidden` provenance + (`ready` vs `fallback`) lets the e2e prove the real hide path works. + +### Why not alternatives + +- **Injected overlay `
` + MutationObserver**: broken by the fork's + `innerHTML = ''` (see above). Rejected. +- **Shiny `busy_indicators`**: covers only the server-recalculating phase, hides + before the client build/instantiate finishes. Rejected. +- **Modify the pyvis fork's `bindings.js`**: separate repo installed from a conda + channel; can't be committed/shipped from the app trees. Rejected. + +## Error handling / robustness + +- `_ready` missed → 8 s per-output timeout clears `is-loading`. +- Pseudo-elements are `pointer-events: none` and only visible while + `.is-loading`, so nothing blocks interaction after the graph draws. +- z-index keeps the spinner below the fork's modals/toolbar; the veil sits over + the whole output (incl. the fork's toolbar/status bar) but only during load. +- `shiny:inputchanged` fires for every app-wide input; the handler does only a + cheap suffix test + a class removal that no-ops unless the id resolves to a + `.pyvis-network-output`. Negligible cost, no misfire. +- Shared-shell blast radius (all network views, both apps) is intended; the + change only toggles a class and adds pseudo-element styling — it touches no + nav/collapse/burger behavior. + +## Testing + +- **e2e** (SESPy's own harness, gated by SESPy CI): extend `tests/test_cld_e2e.py` + for output id `cld-network`. After the network is ready + (`window.pyvisNetworks['cld-network'].nodes`/edges readable), assert: + 1. `#cld-network` has `data-sespy-net-shown === "1"` — proves the **show** path + ran (spinner entered loading state); non-racy because the marker is sticky. + 2. poll up to ~12 s until `#cld-network` no longer has class `is-loading` — + proves the **hide** path ran and nothing is stuck. + 3. `#cld-network` has `data-sespy-net-hidden === "ready"` — proves it hid via + the fork's `_ready` signal, NOT the 8 s fallback. Without this the fallback + masks a broken `_ready` hide (the 12 s poll outlasts the 8 s fallback), so + the physics-off CLD's core hide path would be unverified. + End-state only; never races to catch the transient visible spinner (per the + condition-based-waiting lesson from the leverage/simulation de-flake). The two + sticky markers (`shown`, `hidden`) close both the "spinner never showed" and + the "fallback masked a broken _ready hide" vacuous-pass gaps. +- **Manual smoke** (both apps): SESPy CLD/Leverage + MosaicSES Topology and a + large compartment CLD — spinner shows during load, clears when drawn; toolbar + and node/edge modals still work; check a dark theme; check reduced-motion. + +## Files touched + +- `sespy/dashboard.py` — add `network_spinner_js`, inject in `head_content`. +- `www/sespy-skin.css` (SESPy) — add `.pyvis-network-output` spinner styles. +- `../MosaicSES/www/sespy-skin.css` — add the identical spinner styles. +- `tests/test_cld_e2e.py` (SESPy) — sticky-marker + not-stuck end-state assertions. + +## Known follow-ups (out of scope) + +- The two `sespy-skin.css` copies are already ~54 lines divergent; de-duplicating + them into one shared asset is separate tech debt. +- Verifying MosaicSES gets the change requires MosaicSES CI/smoke — SESPy CI does + not load the MosaicSES app. diff --git a/sespy/dashboard.py b/sespy/dashboard.py index fecd4fe..1ab99e5 100644 --- a/sespy/dashboard.py +++ b/sespy/dashboard.py @@ -280,6 +280,49 @@ def dashboard_page( }); """) + # Network loading spinner: while a pyvis output builds, add `is-loading` (the + # skin renders a spinner via ::before/::after pseudo-elements, which — unlike + # an injected child — survive the fork's `el.innerHTML=''` on every render). + # Hide on the fork's always-sent `_ready` input (NOT `_stabilized`, which + # never fires for the physics-off hierarchical CLD), with an 8s fallback. + # `data-sespy-net-*` markers are sticky proof for the e2e (show + hide path). + network_spinner_js = ui.tags.script(""" + (function () { + var FALLBACK_MS = 8000; + var timers = {}; + + // Bound at parse time (jQuery only; no Shiny object needed), so the + // handlers exist before the first output renders — no first-load race. + $(document).on('shiny:recalculating', function (e) { + var el = e.target; + if (!el || !el.classList || !el.classList.contains('pyvis-network-output')) return; + el.classList.add('is-loading'); + el.setAttribute('data-sespy-net-shown', '1'); // sticky proof the spinner showed + el.removeAttribute('data-sespy-net-hidden'); // reset provenance for this cycle + if (el.id) { + clearTimeout(timers[el.id]); + timers[el.id] = setTimeout(function () { + el.classList.remove('is-loading'); + el.setAttribute('data-sespy-net-hidden', 'fallback'); // hid via safety net + }, FALLBACK_MS); + } + }); + + // The fork sends _ready when the network is created/drawn — every + // render, physics or not. Hide on that. + $(document).on('shiny:inputchanged', function (e) { + if (!e.name || e.name.slice(-6) !== '_ready') return; + var id = e.name.slice(0, -6); + var el = document.getElementById(id); + if (el && el.classList.contains('pyvis-network-output')) { + el.classList.remove('is-loading'); + el.setAttribute('data-sespy-net-hidden', 'ready'); // hid via the real _ready path + clearTimeout(timers[id]); + } + }); + })(); + """) + return ui.tags.div( # Inject the shell stylesheet at the page level. The skin contains the # design tokens, layout, AND the critical guards (display:block on @@ -299,6 +342,7 @@ def dashboard_page( burger_js, bookmark_js, theme_js, + network_spinner_js, ), ui.page_sidebar( ui.sidebar(*sidebar_children, width=280, class_="sespy-sidebar sespy-nav-shell"), diff --git a/tests/test_cld_e2e.py b/tests/test_cld_e2e.py index 23f7e07..34ab0c0 100644 --- a/tests/test_cld_e2e.py +++ b/tests/test_cld_e2e.py @@ -34,6 +34,38 @@ async def main(): assert any(flags), "no dashed (delayed) edge in the CLD" assert not all(flags), "expected at least one solid (immediate) edge too" + # --- network loading spinner (shared shell) --- + # End-state only (do NOT race the transient visible spinner): + # (1) the sticky marker proves the show path ran (is-loading was added); + # (2) is-loading must clear once the graph is up (via _ready or the + # 8s fallback). + shown = await page.evaluate( + "() => document.getElementById('cld-network')" + " && document.getElementById('cld-network').getAttribute('data-sespy-net-shown') === '1'" + ) + assert shown, "#cld-network never entered loading state (spinner show path dead)" + not_loading = False + for _ in range(24): # up to ~12s: covers _ready and the 8s fallback + not_loading = await page.evaluate( + "() => { const el = document.getElementById('cld-network');" + " return !!el && !el.classList.contains('is-loading'); }" + ) + if not_loading: + break + await page.wait_for_timeout(500) + assert not_loading, "#cld-network stuck in .is-loading after the graph rendered" + # Provenance: the physics-off CLD MUST hide via the fork's _ready signal, + # not the 8s fallback. Without this, a broken _ready hide is masked by the + # fallback (poll window > fallback) and the test would pass while users see + # an 8s frozen spinner. + hidden_by = await page.evaluate( + "() => { const el = document.getElementById('cld-network');" + " return el && el.getAttribute('data-sespy-net-hidden'); }" + ) + assert hidden_by == "ready", \ + f"CLD hid via {hidden_by!r}, not '_ready' — fallback masked a broken hide path" + print("network spinner show->hide (via _ready): OK") + await page.screenshot(path="tests/screenshots/cld.png") print("\ncld e2e assertions pass") await browser.close() diff --git a/www/sespy-skin.css b/www/sespy-skin.css index 2ac5c17..53ae771 100644 --- a/www/sespy-skin.css +++ b/www/sespy-skin.css @@ -121,6 +121,7 @@ label, .form-label, .control-label { /* (1) html-fill-container/contents collapse — see cld.css note */ .pyvis-network-output { display: block !important; + position: relative; /* positioning context for the loading pseudo-elements */ } /* (2) Transform on any vis-network ancestor breaks vis.js tooltip @@ -144,6 +145,46 @@ label, .form-label, .control-label { border: none !important; } +/* (4) Network loading spinner (shared shell). Rendered as pseudo-elements so it + survives the pyvis fork's `el.innerHTML=''` on every render (bindings.js:90); + toggled by the `is-loading` class from network_spinner_js. ::before is the + veil + label, ::after is the ring. z-index stays below the fork's + .pyvis-modal-overlay (1000) so node/edge modals sit on top. */ +.pyvis-network-output.is-loading::before { + content: "Rendering network\2026"; /* \2026 = U+2026 ellipsis */ + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding-top: 3.2rem; /* leave room for the ring above the text */ + background: rgba(255, 255, 255, 0.68); /* fallback for engines without color-mix */ + background: color-mix(in srgb, var(--bs-body-bg, #ffffff) 68%, transparent); + color: var(--bs-secondary-color, #555); + font-size: 0.9rem; + pointer-events: none; +} +.pyvis-network-output.is-loading::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + z-index: 6; + width: 2.25rem; + height: 2.25rem; + margin: -2.4rem 0 0 -1.125rem; /* center horizontally, sit above the label */ + border: 3px solid color-mix(in srgb, var(--bs-secondary-color, #555) 25%, transparent); + border-top-color: var(--bs-secondary-color, #555); + border-radius: 50%; + animation: sespy-net-spin 0.8s linear infinite; + pointer-events: none; +} +@keyframes sespy-net-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .pyvis-network-output.is-loading::after { animation: none; } +} + /* ================================================================= TITLE BAR — header_actions (the Feedback/About/Options/Help cluster) now lives INSIDE the full-width navbar that carries the app title.