From 078999e021aa543d13c666648291596d964e96c6 Mon Sep 17 00:00:00 2001 From: razinkele Date: Thu, 2 Jul 2026 21:44:44 +0300 Subject: [PATCH 1/7] docs(spec): network loading spinner (shared shell, all pyvis views) Design for a centered spinner overlay over every pyvis network output, shown during server build AND client vis-network stabilization, auto-hidden on the fork's already-emitted _stabilized signal (8s fallback). Lives entirely in the SESPy shell (dashboard.py + sespy-skin.css); no pyvis fork changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-02-network-loading-spinner-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md 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..e9f7fa3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md @@ -0,0 +1,121 @@ +# Network loading spinner — design + +**Date:** 2026-07-02 +**Status:** approved (brainstorming) +**Scope:** SESPy shared shell — affects every pyvis network view in both SESPy and MosaicSES. + +## Problem + +The pyvis network views (Topology, CLD Visualization, Leverage, Network Metrics, +Intervention, Loop Analysis, Simplify) render with a visible delay. There is no +feedback during that delay, so the panel looks blank/broken until the graph +appears. Users perceive the app as slow or stuck. + +The delay has two phases: +1. **Server build** — the reactive `render_pyvis_network` output recomputes and + ships the network data/HTML. +2. **Client layout** — vis-network instantiates and runs physics stabilization + (nodes animate into place). For larger graphs (compartment CLDs ~20+ nodes) + this is the dominant, visible wait, and it happens *after* Shiny already + considers the output "recalculated". + +A good indicator must cover **both** phases. + +## Goal + +Show a centered spinner overlay ("Rendering network…") over each pyvis output +while it is building and laying out, and auto-hide it the moment the graph is +ready. One implementation in the shared shell, covering all network views in +both apps. No changes to the pyvis fork. + +Non-goals (YAGNI): percentage progress bar, per-view configuration, persistence, +covering non-pyvis outputs. + +## Approach + +Add a small CSS overlay + a short client script to the SESPy shell +(`sespy/dashboard.py`), driven entirely by **documented Shiny client events** +plus a signal the pyvis fork **already emits** — so it stays decoupled from the +fork's internals. + +Key facts this relies on (verified in the installed `pyvis.shiny` fork, 4.2): +- The output element carries the Shiny output binding class + `.pyvis-network-output`, and its DOM `id` equals the Shiny output id. +- The fork already calls `Shiny.setInputValue(outputId + '_stabilized', …)` when + vis-network fires `stabilizationIterationsDone` (event binding defaults to all + events, which SESPy does not override). This surfaces to arbitrary JS as a + `shiny:inputchanged` event with `name === outputId + '_stabilized'`. + +### Show / hide triggers + +| Phase | Signal | Action | +|---|---|---| +| Server build | `shiny:recalculating` on a `.pyvis-network-output` element | show overlay for that element | +| Client layout done | `shiny:inputchanged` with `name` ending `_stabilized` → map to `#` | hide that element's overlay | +| Safety net | physics disabled / instant / event missed | hide after an **8 s** timeout, per overlay | + +Showing on `recalculating` and hiding only on `_stabilized` (not on +`recalculated`) is what makes the overlay span the client-layout phase. + +### Components + +1. **CSS** (`www/sespy-skin.css`, next to the existing `.pyvis-network-output` + guards): `.sespy-net-overlay` — absolutely positioned, covers the output, + centered spinner + label; `.is-loading` toggles visibility; `pointer-events` + only active while loading; z-index **below** the fork's `.pyvis-modal-overlay` + so node/edge modals stay on top. Respects `prefers-reduced-motion` (no spin). + +2. **JS** (`network_spinner_js` in `dashboard.py`, injected in `head_content` + alongside `bookmark_js`/`theme_js`, registered on `shiny:connected`): + - A `MutationObserver` (plus an initial sweep) ensures every + `.pyvis-network-output` has a child overlay node injected exactly once — + works for initial load, tab switches, and re-renders. + - `$(document).on('shiny:recalculating', …)` → if the target is a + `.pyvis-network-output`, add `.is-loading` and arm the 8 s fallback timer. + - `$(document).on('shiny:inputchanged', …)` → if `name` ends with + `_stabilized`, strip the suffix to get the output id, find + `#.pyvis-network-output`, remove `.is-loading`, clear its timer. + - Idempotent: re-showing reuses the same overlay node. + +### Why not alternatives + +- **Shiny `busy_indicators`**: trivial to enable but only covers the server + recalculating phase — it hides once the HTML is delivered, *before* physics + stabilization, i.e. it misses the dominant wait. Rejected. +- **Modify the pyvis fork's `bindings.js`**: the fork owns the network and could + show its own overlay on `stabilizationIterationsDone` precisely, but the fork + is a separate repo installed from a conda channel (not the SESPy tree), so it + can't be committed/shipped from here. Rejected in favor of the shell. + +## Error handling / robustness + +- Missing/never-firing `_stabilized` → 8 s per-overlay timeout hides it anyway. +- Overlay must never block interaction after hide: `pointer-events: none` when + not `.is-loading`. +- Must not disturb the fork's toolbar, status bar, or modal overlays (z-index + and scoping keep them independent). +- Shared-shell blast radius is intended (all network views, both apps); the + change only *adds* an overlay layer and touches no nav/collapse behavior. + +## Testing + +- **e2e** (SESPy's own harness, so it is gated by SESPy CI): drive a SESPy + network view that already has an e2e (CLD visualization or Leverage), and for + its output id `` assert: + 1. an overlay element exists inside `#.pyvis-network-output`, and + 2. once `window.pyvisNetworks[''].nodes.length > 0`, the output no longer + carries `.is-loading` (overlay hidden). + Deliberately assert the **end state**, not a race to catch the transient + visible overlay — matching the condition-based-waiting lesson from the + leverage/simulation e2e de-flake. Prefer extending an existing + `tests/test_cld_e2e.py` / `tests/test_leverage_e2e.py` over a new script. +- **Manual smoke**: load MosaicSES Topology and a larger compartment CLD; the + spinner shows during load and disappears when the graph settles; the pyvis + toolbar and node/edge modals still work. + +## Files touched + +- `sespy/dashboard.py` — add `network_spinner_js`, inject in `head_content`. +- `www/sespy-skin.css` — add `.sespy-net-overlay` styles. +- `tests/test_cld_e2e.py` or `tests/test_leverage_e2e.py` (SESPy) — one + end-state assertion (extend, don't add a new script). From 8be67e7bf07debf2ca895116bc41d643f2b96e28 Mon Sep 17 00:00:00 2001 From: razinkele Date: Thu, 2 Jul 2026 23:08:27 +0300 Subject: [PATCH 2/7] docs(plan): TDD implementation plan for network loading spinner Task 1: shell CSS + injected JS (show on shiny:recalculating, hide on _stabilized + 8s fallback) with a CLD end-state e2e. Task 2: cross-app manual smoke checklist. No pyvis fork changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-02-network-loading-spinner.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-network-loading-spinner.md 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..3480507 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md @@ -0,0 +1,305 @@ +# 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 overlay ("Rendering network…") over every pyvis network output while it builds and lays out, auto-hidden the moment the graph is ready. + +**Architecture:** One CSS overlay + one short client script, both injected by the shared SESPy shell (`sespy/dashboard.py` + `www/sespy-skin.css`). The script shows the overlay on Shiny's `shiny:recalculating` event for `.pyvis-network-output` elements (server build) and hides it on the `shiny:inputchanged` event for `_stabilized` (the pyvis fork already emits this when vis-network finishes physics), with an 8 s per-overlay fallback. No changes to the pyvis fork; every network view in SESPy and MosaicSES inherits it. + +**Tech Stack:** Python Shiny, the razinka `pyvis.shiny` 4.2 fork (vis-network 10.0.2), vanilla JS + jQuery (`$` is present — Shiny ships it), 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`. +- The spinner overlay z-index MUST be below `1000` (the fork's `.pyvis-modal-overlay` z-index) so node/edge modals stay on top. +- Overlay MUST be `pointer-events: none` unless the output is `.is-loading`, so it never blocks interaction after hiding. +- e2e assertions MUST check the **end state** (overlay hidden once the graph is ready), never race to catch the transient visible overlay — per the condition-based-waiting lesson from the leverage/simulation de-flake. +- ruff lint is blocking in CI; `tests/**` already ignores E402/E501. Keep `ruff check` clean. +- Overlay label text is exactly `Rendering network…` (with a real ellipsis `…`, U+2026). +- Fallback timeout is exactly `8000` ms. + +--- + +## File Structure + +- `www/sespy-skin.css` — add `position: relative` to the existing `.pyvis-network-output` rule and append the `.sespy-net-overlay` styles. Static styling only. +- `sespy/dashboard.py` — add a `network_spinner_js = ui.tags.script(...)` block next to `theme_js`, and include it in the existing `ui.head_content(...)` call. Owns show/hide behavior. +- `tests/test_cld_e2e.py` — extend the existing CLD network e2e with the overlay end-state assertion (do NOT add a new script). This is the CI-gated test. +- `docs/2026-07-02-network-spinner-smoke-checklist.md` — manual cross-app smoke steps (MosaicSES topology + a large CLD). + +--- + +## Task 1: Network spinner overlay (shell CSS + JS) with CLD e2e + +**Files:** +- Modify: `www/sespy-skin.css:122-145` (the pyvis guards section) +- 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, already present): + - DOM: the pyvis output element has class `pyvis-network-output` and its `id` equals the Shiny output id (e.g. `cld-network`). + - Shiny client events: `shiny:recalculating` (fires on the output element), `shiny:inputchanged` (has `.name`, fires for `_stabilized`). +- Produces (relied on by the e2e and manual smoke): + - CSS class contract: while loading, the `.pyvis-network-output` element carries `is-loading`; it always contains exactly one child `div.sespy-net-overlay`. + +- [ ] **Step 1: Write the failing test** — insert the overlay end-state assertion into `tests/test_cld_e2e.py` after the existing delay-edge assertions (after the `assert not all(flags), …` line, currently line 35) and before the `await page.screenshot(...)` line (currently line 37): + +```python + # --- network loading overlay (shared shell) --- + # The network is ready here (edges readable). The overlay must exist and, + # after stabilization/fallback, must no longer mark the output as loading. + # End-state only: do NOT race to catch the transient visible overlay. + overlay_present = await page.evaluate( + "() => !!document.querySelector('#cld-network .sespy-net-overlay')" + ) + assert overlay_present, "no .sespy-net-overlay injected into #cld-network" + not_loading = False + for _ in range(24): # up to ~12s: covers the _stabilized event 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 network ready" + print("network overlay end-state: OK") +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Boot the app, then run the script (the runner boots the server for the full suite, but for a single script boot it manually): + +```bash +cd "/c/Users/arturas.baziukas/OneDrive - ku.lt/HORIZON_EUROPE/Marine-SABRES/SESPy" +micromamba run -n shiny shiny run --port 8000 app.py & # leave running +# wait until http://127.0.0.1:8000 serves, then: +PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py +``` + +Expected: FAIL at `assert overlay_present` — `AssertionError: no .sespy-net-overlay injected into #cld-network` (the overlay does not exist yet). + +- [ ] **Step 3: Add the overlay CSS** — in `www/sespy-skin.css`, change the existing rule at lines 122-124 from: + +```css +.pyvis-network-output { + display: block !important; +} +``` + +to: + +```css +.pyvis-network-output { + display: block !important; + position: relative; /* anchor for the loading overlay */ +} +``` + +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 overlay (shared shell). Shown while the pyvis + output builds server-side AND while vis-network runs physics; hidden on + the fork's _stabilized signal (8s JS fallback). Sits below the fork's + .pyvis-modal-overlay (z-index 1000) so node/edge modals stay on top. */ +.sespy-net-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + z-index: 5; + background: color-mix(in srgb, var(--bs-body-bg, #ffffff) 68%, transparent); + color: var(--bs-secondary-color, #555); + font-size: 0.9rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} +.pyvis-network-output.is-loading .sespy-net-overlay { + opacity: 1; + pointer-events: auto; +} +.sespy-net-overlay__spinner { + width: 2.25rem; + height: 2.25rem; + border: 3px solid color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: sespy-net-spin 0.8s linear infinite; +} +@keyframes sespy-net-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .sespy-net-overlay__spinner { animation: none; } +} +``` + +- [ ] **Step 4: Add the injected script** — in `sespy/dashboard.py`, immediately after the `theme_js = ui.tags.script(""" … """)` block, add: + +```python + network_spinner_js = ui.tags.script(""" + $(document).on('shiny:connected', function () { + var LABEL = 'Rendering network\\u2026'; + var FALLBACK_MS = 8000; + var timers = {}; + + function ensureOverlay(el) { + if (el.querySelector(':scope > .sespy-net-overlay')) return; + var ov = document.createElement('div'); + ov.className = 'sespy-net-overlay'; + ov.setAttribute('aria-hidden', 'true'); + ov.innerHTML = '
' + LABEL + '
'; + el.appendChild(ov); + } + function sweep(root) { + (root || document).querySelectorAll('.pyvis-network-output').forEach(ensureOverlay); + } + function show(el) { + ensureOverlay(el); + el.classList.add('is-loading'); + if (el.id) { + clearTimeout(timers[el.id]); + timers[el.id] = setTimeout(function () { el.classList.remove('is-loading'); }, FALLBACK_MS); + } + } + function hide(id) { + var el = document.getElementById(id); + if (el && el.classList.contains('pyvis-network-output')) { + el.classList.remove('is-loading'); + clearTimeout(timers[id]); + } + } + + sweep(document); + new MutationObserver(function (muts) { + muts.forEach(function (m) { + m.addedNodes.forEach(function (n) { + if (n.nodeType !== 1) return; + if (n.classList && n.classList.contains('pyvis-network-output')) ensureOverlay(n); + if (n.querySelectorAll) sweep(n); + }); + }); + }).observe(document.body, { childList: true, subtree: true }); + + $(document).on('shiny:recalculating', function (e) { + var el = e.target; + if (el && el.classList && el.classList.contains('pyvis-network-output')) show(el); + }); + $(document).on('shiny:inputchanged', function (e) { + if (e.name && e.name.slice(-11) === '_stabilized') hide(e.name.slice(0, -11)); + }); + }); + """) +``` + +- [ ] **Step 5: Include the script in the head** — in the same file, in the `ui.head_content( … )` call, add `network_spinner_js` to the list immediately after `theme_js,`: + +```python + burger_js, + bookmark_js, + theme_js, + network_spinner_js, + ), +``` + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +# app still running from Step 2 — reload happens automatically on file save, +# but to be safe restart: kill the shiny run, relaunch, wait for :8000, then: +PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py +``` + +Expected: PASS — prints `network overlay end-state: OK` and `cld e2e assertions pass`. + +- [ ] **Step 7: Guard against flakiness** — run the CLD e2e 3 times; all must exit 0: + +```bash +for i in 1 2 3; do PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py >/dev/null 2>&1; echo "run $i EXIT=$?"; done +``` + +Expected: `run 1 EXIT=0`, `run 2 EXIT=0`, `run 3 EXIT=0`. + +- [ ] **Step 8: Lint** + +```bash +micromamba run -n shiny ruff check sespy tests app.py +``` + +Expected: `All checks passed!` + +- [ ] **Step 9: Commit** + +```bash +git add www/sespy-skin.css sespy/dashboard.py tests/test_cld_e2e.py +git commit -m "feat(shell): loading spinner overlay for pyvis networks + +Centered spinner shown while a pyvis output builds and while vis-network runs +physics, auto-hidden on the fork's _stabilized signal (8s fallback). +Injected once in the SESPy shell, so every network view in SESPy + MosaicSES +gets it. CLD e2e asserts the overlay exists and clears once the graph is ready." +``` + +--- + +## Task 2: Cross-app manual smoke checklist + +**Files:** +- Create: `docs/2026-07-02-network-spinner-smoke-checklist.md` + +**Interfaces:** +- Consumes: the `is-loading` / `.sespy-net-overlay` contract from Task 1. +- Produces: a checklist doc (no code consumers). + +- [ ] **Step 1: Write the checklist** — create `docs/2026-07-02-network-spinner-smoke-checklist.md`: + +```markdown +# Network spinner — manual smoke (2026-07-02) + +Run each app with `micromamba run -n shiny shiny run --launch-browser app.py`. + +## SESPy (app.py) +- [ ] CLD Visualization: on first load the spinner shows over the graph and + disappears once nodes settle. +- [ ] Switch to Leverage Points and back to CLD: spinner re-shows on each + network (re)render, then clears. +- [ ] Node/edge modal still opens and sits ABOVE where the overlay was + (z-index correct); toolbar buttons still clickable after load. + +## MosaicSES (../MosaicSES/app.py) +- [ ] Topology: spinner shows over the 6-node compartment graph, clears when + laid out. +- [ ] Drill into a compartment (larger CLD, ~20+ nodes): spinner is visibly + useful during the longer stabilization, then clears. + +## Reduced motion +- [ ] With OS "reduce motion" on, the overlay still shows but the spinner does + not rotate (label + static ring only). +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/2026-07-02-network-spinner-smoke-checklist.md +git commit -m "docs: manual smoke checklist for the network loading spinner" +``` + +--- + +## Notes for the implementer + +- The overlay's SHOW behavior on the very first render can race the script's + event registration; that is why the e2e asserts only the **end state** + (overlay present + not stuck loading). The visible-during-load behavior is + covered by the manual smoke, not the e2e. Do not add an assertion that races + to catch the overlay while visible. +- If `#cld-network` is not the CLD output id in the running app, discover it in + the browser console with `Object.keys(window.pyvisNetworks)` and use that id + in the e2e (update both the selector and the `pyvisNetworks[...]` key). As of + this plan it is `cld-network`. +- `':scope > .sespy-net-overlay'` and `color-mix()` are supported by the + vis-network target (Chromium via Playwright, and modern desktop browsers). From 535d97f8605e74432c47aa280d8b89b1ddfd25ac Mon Sep 17 00:00:00 2001 From: razinkele Date: Fri, 3 Jul 2026 09:32:54 +0300 Subject: [PATCH 3/7] docs(spec+plan): revise network spinner after multi-agent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review (4 orthogonal agents) found: (1) BLOCKER — fork's el.innerHTML='' wipes any injected overlay; (2) MAJOR — default CLD is physics-off so _stabilized never fires; (3) MAJOR — e2e was near-vacuous; (4) MAJOR — MosaicSES serves a separate skin copy, so shared CSS wouldn't reach it. Revisions: pseudo-element spinner (survives innerHTML=''); hide on the always-sent _ready (not _stabilized); sticky data-sespy-net-shown marker for a non-racy e2e; mirror the CSS into both apps' skins; bind handlers at parse time (no first-load race). Dropped the MutationObserver entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-02-network-loading-spinner.md | 377 +++++++++--------- ...26-07-02-network-loading-spinner-design.md | 194 +++++---- 2 files changed, 306 insertions(+), 265 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-network-loading-spinner.md b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md index 3480507..9b4bb00 100644 --- a/docs/superpowers/plans/2026-07-02-network-loading-spinner.md +++ b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md @@ -2,60 +2,62 @@ > **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 overlay ("Rendering network…") over every pyvis network output while it builds and lays out, auto-hidden the moment the graph is ready. +**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:** One CSS overlay + one short client script, both injected by the shared SESPy shell (`sespy/dashboard.py` + `www/sespy-skin.css`). The script shows the overlay on Shiny's `shiny:recalculating` event for `.pyvis-network-output` elements (server build) and hides it on the `shiny:inputchanged` event for `_stabilized` (the pyvis fork already emits this when vis-network finishes physics), with an 8 s per-overlay fallback. No changes to the pyvis fork; every network view in SESPy and MosaicSES inherits it. +**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 (`$` is present — Shiny ships it), CSS. Tests: standalone Playwright e2e scripts run via `tests/run_e2e.py`. +**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`. -- The spinner overlay z-index MUST be below `1000` (the fork's `.pyvis-modal-overlay` z-index) so node/edge modals stay on top. -- Overlay MUST be `pointer-events: none` unless the output is `.is-loading`, so it never blocks interaction after hiding. -- e2e assertions MUST check the **end state** (overlay hidden once the graph is ready), never race to catch the transient visible overlay — per the condition-based-waiting lesson from the leverage/simulation de-flake. -- ruff lint is blocking in CI; `tests/**` already ignores E402/E501. Keep `ruff check` clean. -- Overlay label text is exactly `Rendering network…` (with a real ellipsis `…`, U+2026). -- Fallback timeout is exactly `8000` ms. +- 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 -- `www/sespy-skin.css` — add `position: relative` to the existing `.pyvis-network-output` rule and append the `.sespy-net-overlay` styles. Static styling only. -- `sespy/dashboard.py` — add a `network_spinner_js = ui.tags.script(...)` block next to `theme_js`, and include it in the existing `ui.head_content(...)` call. Owns show/hide behavior. -- `tests/test_cld_e2e.py` — extend the existing CLD network e2e with the overlay end-state assertion (do NOT add a new script). This is the CI-gated test. -- `docs/2026-07-02-network-spinner-smoke-checklist.md` — manual cross-app smoke steps (MosaicSES topology + a large CLD). +- `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: Network spinner overlay (shell CSS + JS) with CLD e2e +## 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: `www/sespy-skin.css:122-145` (the pyvis guards section) - 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, already present): - - DOM: the pyvis output element has class `pyvis-network-output` and its `id` equals the Shiny output id (e.g. `cld-network`). - - Shiny client events: `shiny:recalculating` (fires on the output element), `shiny:inputchanged` (has `.name`, fires for `_stabilized`). -- Produces (relied on by the e2e and manual smoke): - - CSS class contract: while loading, the `.pyvis-network-output` element carries `is-loading`; it always contains exactly one child `div.sespy-net-overlay`. +- 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** — insert the overlay end-state assertion into `tests/test_cld_e2e.py` after the existing delay-edge assertions (after the `assert not all(flags), …` line, currently line 35) and before the `await page.screenshot(...)` line (currently line 37): +- [ ] **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 overlay (shared shell) --- - # The network is ready here (edges readable). The overlay must exist and, - # after stabilization/fallback, must no longer mark the output as loading. - # End-state only: do NOT race to catch the transient visible overlay. - overlay_present = await page.evaluate( - "() => !!document.querySelector('#cld-network .sespy-net-overlay')" + # --- 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 overlay_present, "no .sespy-net-overlay injected into #cld-network" + assert shown, "#cld-network never entered loading state (spinner show path dead)" not_loading = False - for _ in range(24): # up to ~12s: covers the _stabilized event and the 8s fallback + 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'); }" @@ -63,140 +65,58 @@ if not_loading: break await page.wait_for_timeout(500) - assert not_loading, "#cld-network stuck in .is-loading after network ready" - print("network overlay end-state: OK") + assert not_loading, "#cld-network stuck in .is-loading after the graph rendered" + print("network spinner show->hide: OK") ``` - [ ] **Step 2: Run the test to verify it fails** -Boot the app, then run the script (the runner boots the server for the full suite, but for a single script boot it manually): - ```bash cd "/c/Users/arturas.baziukas/OneDrive - ku.lt/HORIZON_EUROPE/Marine-SABRES/SESPy" -micromamba run -n shiny shiny run --port 8000 app.py & # leave running -# wait until http://127.0.0.1:8000 serves, then: +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 overlay_present` — `AssertionError: no .sespy-net-overlay injected into #cld-network` (the overlay does not exist yet). - -- [ ] **Step 3: Add the overlay CSS** — in `www/sespy-skin.css`, change the existing rule at lines 122-124 from: - -```css -.pyvis-network-output { - display: block !important; -} -``` - -to: - -```css -.pyvis-network-output { - display: block !important; - position: relative; /* anchor for the loading overlay */ -} -``` - -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 overlay (shared shell). Shown while the pyvis - output builds server-side AND while vis-network runs physics; hidden on - the fork's _stabilized signal (8s JS fallback). Sits below the fork's - .pyvis-modal-overlay (z-index 1000) so node/edge modals stay on top. */ -.sespy-net-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.6rem; - z-index: 5; - background: color-mix(in srgb, var(--bs-body-bg, #ffffff) 68%, transparent); - color: var(--bs-secondary-color, #555); - font-size: 0.9rem; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; -} -.pyvis-network-output.is-loading .sespy-net-overlay { - opacity: 1; - pointer-events: auto; -} -.sespy-net-overlay__spinner { - width: 2.25rem; - height: 2.25rem; - border: 3px solid color-mix(in srgb, currentColor 25%, transparent); - border-top-color: currentColor; - border-radius: 50%; - animation: sespy-net-spin 0.8s linear infinite; -} -@keyframes sespy-net-spin { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { - .sespy-net-overlay__spinner { animation: none; } -} -``` +Expected: FAIL at `assert shown` — nothing sets `data-sespy-net-shown` yet. -- [ ] **Step 4: Add the injected script** — in `sespy/dashboard.py`, immediately after the `theme_js = ui.tags.script(""" … """)` block, add: +- [ ] **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(""" - $(document).on('shiny:connected', function () { - var LABEL = 'Rendering network\\u2026'; + (function () { var FALLBACK_MS = 8000; var timers = {}; - function ensureOverlay(el) { - if (el.querySelector(':scope > .sespy-net-overlay')) return; - var ov = document.createElement('div'); - ov.className = 'sespy-net-overlay'; - ov.setAttribute('aria-hidden', 'true'); - ov.innerHTML = '
' + LABEL + '
'; - el.appendChild(ov); - } - function sweep(root) { - (root || document).querySelectorAll('.pyvis-network-output').forEach(ensureOverlay); - } - function show(el) { - ensureOverlay(el); + // 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 if (el.id) { clearTimeout(timers[el.id]); timers[el.id] = setTimeout(function () { el.classList.remove('is-loading'); }, FALLBACK_MS); } - } - function hide(id) { + }); + + // 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'); clearTimeout(timers[id]); } - } - - sweep(document); - new MutationObserver(function (muts) { - muts.forEach(function (m) { - m.addedNodes.forEach(function (n) { - if (n.nodeType !== 1) return; - if (n.classList && n.classList.contains('pyvis-network-output')) ensureOverlay(n); - if (n.querySelectorAll) sweep(n); - }); - }); - }).observe(document.body, { childList: true, subtree: true }); - - $(document).on('shiny:recalculating', function (e) { - var el = e.target; - if (el && el.classList && el.classList.contains('pyvis-network-output')) show(el); - }); - $(document).on('shiny:inputchanged', function (e) { - if (e.name && e.name.slice(-11) === '_stabilized') hide(e.name.slice(0, -11)); }); - }); + })(); """) ``` -- [ ] **Step 5: Include the script in the head** — in the same file, in the `ui.head_content( … )` call, add `network_spinner_js` to the list immediately after `theme_js,`: +- [ ] **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, @@ -206,25 +126,24 @@ Then, immediately after the `.pyvis-network-output .pyvis-network-canvas { borde ), ``` -- [ ] **Step 6: Run the test to verify it passes** +- [ ] **Step 5: Run the test to verify it passes** ```bash -# app still running from Step 2 — reload happens automatically on file save, -# but to be safe restart: kill the shiny run, relaunch, wait for :8000, then: +# 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 overlay end-state: OK` and `cld e2e assertions pass`. +Expected: PASS — prints `network spinner show->hide: OK` and `cld e2e assertions pass`. -- [ ] **Step 7: Guard against flakiness** — run the CLD e2e 3 times; all must exit 0: +- [ ] **Step 6: Flake guard — run the CLD e2e 5 times** ```bash -for i in 1 2 3; do PYTHONPATH="$PWD" micromamba run -n shiny python tests/test_cld_e2e.py >/dev/null 2>&1; echo "run $i EXIT=$?"; done +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: `run 1 EXIT=0`, `run 2 EXIT=0`, `run 3 EXIT=0`. +Expected: all `EXIT=0`. -- [ ] **Step 8: Lint** +- [ ] **Step 7: Lint** ```bash micromamba run -n shiny ruff check sespy tests app.py @@ -232,74 +151,172 @@ micromamba run -n shiny ruff check sespy tests app.py Expected: `All checks passed!` -- [ ] **Step 9: Commit** +- [ ] **Step 8: Commit** ```bash -git add www/sespy-skin.css sespy/dashboard.py tests/test_cld_e2e.py -git commit -m "feat(shell): loading spinner overlay for pyvis networks +git add sespy/dashboard.py tests/test_cld_e2e.py +git commit -m "feat(shell): network spinner is-loading toggle + CLD e2e -Centered spinner shown while a pyvis output builds and while vis-network runs -physics, auto-hidden on the fork's _stabilized signal (8s fallback). -Injected once in the SESPy shell, so every network view in SESPy + MosaicSES -gets it. CLD e2e asserts the overlay exists and clears once the graph is ready." +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: Cross-app manual smoke checklist +## 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: 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` / `.sespy-net-overlay` contract from Task 1. -- Produces: a checklist doc (no code consumers). +- Consumes: the `is-loading` / pseudo-element contract from Tasks 1-2. -- [ ] **Step 1: Write the checklist** — create `docs/2026-07-02-network-spinner-smoke-checklist.md`: +- [ ] **Step 1: Write the checklist** ```markdown # Network spinner — manual smoke (2026-07-02) -Run each app with `micromamba run -n shiny shiny run --launch-browser app.py`. +Run each app: `micromamba run -n shiny shiny run --launch-browser app.py`. ## SESPy (app.py) -- [ ] CLD Visualization: on first load the spinner shows over the graph and - disappears once nodes settle. -- [ ] Switch to Leverage Points and back to CLD: spinner re-shows on each - network (re)render, then clears. -- [ ] Node/edge modal still opens and sits ABOVE where the overlay was - (z-index correct); toolbar buttons still clickable after load. - -## MosaicSES (../MosaicSES/app.py) -- [ ] Topology: spinner shows over the 6-node compartment graph, clears when - laid out. -- [ ] Drill into a compartment (larger CLD, ~20+ nodes): spinner is visibly - useful during the longer stabilization, then clears. - -## Reduced motion -- [ ] With OS "reduce motion" on, the overlay still shows but the spinner does - not rotate (label + static ring only). +- [ ] 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: manual smoke checklist for the network loading spinner" +git commit -m "docs: cross-app manual smoke checklist for the network spinner" ``` --- ## Notes for the implementer -- The overlay's SHOW behavior on the very first render can race the script's - event registration; that is why the e2e asserts only the **end state** - (overlay present + not stuck loading). The visible-during-load behavior is - covered by the manual smoke, not the e2e. Do not add an assertion that races - to catch the overlay while visible. -- If `#cld-network` is not the CLD output id in the running app, discover it in - the browser console with `Object.keys(window.pyvisNetworks)` and use that id - in the e2e (update both the selector and the `pyvisNetworks[...]` key). As of +- **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. +- 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`. -- `':scope > .sespy-net-overlay'` and `color-mix()` are supported by the - vis-network target (Chromium via Playwright, and modern desktop browsers). +- 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 index e9f7fa3..6a2b430 100644 --- a/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md +++ b/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md @@ -1,121 +1,145 @@ # Network loading spinner — design -**Date:** 2026-07-02 -**Status:** approved (brainstorming) -**Scope:** SESPy shared shell — affects every pyvis network view in both SESPy and MosaicSES. +**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. There is no -feedback during that delay, so the panel looks blank/broken until the graph -appears. Users perceive the app as slow or stuck. - -The delay has two phases: -1. **Server build** — the reactive `render_pyvis_network` output recomputes and - ships the network data/HTML. -2. **Client layout** — vis-network instantiates and runs physics stabilization - (nodes animate into place). For larger graphs (compartment CLDs ~20+ nodes) - this is the dominant, visible wait, and it happens *after* Shiny already - considers the output "recalculated". - -A good indicator must cover **both** phases. +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 overlay ("Rendering network…") over each pyvis output -while it is building and laying out, and auto-hide it the moment the graph is -ready. One implementation in the shared shell, covering all network views in -both apps. No changes to the pyvis fork. +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 configuration, persistence, -covering non-pyvis outputs. +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 -Add a small CSS overlay + a short client script to the SESPy shell -(`sespy/dashboard.py`), driven entirely by **documented Shiny client events** -plus a signal the pyvis fork **already emits** — so it stays decoupled from the -fork's internals. +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) -Key facts this relies on (verified in the installed `pyvis.shiny` fork, 4.2): -- The output element carries the Shiny output binding class - `.pyvis-network-output`, and its DOM `id` equals the Shiny output id. -- The fork already calls `Shiny.setInputValue(outputId + '_stabilized', …)` when - vis-network fires `stabilizationIterationsDone` (event binding defaults to all - events, which SESPy does not override). This surfaces to arbitrary JS as a - `shiny:inputchanged` event with `name === outputId + '_stabilized'`. +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 | `shiny:recalculating` on a `.pyvis-network-output` element | show overlay for that element | -| Client layout done | `shiny:inputchanged` with `name` ending `_stabilized` → map to `#` | hide that element's overlay | -| Safety net | physics disabled / instant / event missed | hide after an **8 s** timeout, per overlay | +| 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` | -Showing on `recalculating` and hiding only on `_stabilized` (not on -`recalculated`) is what makes the overlay span the client-layout phase. +`_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** (`www/sespy-skin.css`, next to the existing `.pyvis-network-output` - guards): `.sespy-net-overlay` — absolutely positioned, covers the output, - centered spinner + label; `.is-loading` toggles visibility; `pointer-events` - only active while loading; z-index **below** the fork's `.pyvis-modal-overlay` - so node/edge modals stay on top. Respects `prefers-reduced-motion` (no spin). - -2. **JS** (`network_spinner_js` in `dashboard.py`, injected in `head_content` - alongside `bookmark_js`/`theme_js`, registered on `shiny:connected`): - - A `MutationObserver` (plus an initial sweep) ensures every - `.pyvis-network-output` has a child overlay node injected exactly once — - works for initial load, tab switches, and re-renders. - - `$(document).on('shiny:recalculating', …)` → if the target is a - `.pyvis-network-output`, add `.is-loading` and arm the 8 s fallback timer. - - `$(document).on('shiny:inputchanged', …)` → if `name` ends with - `_stabilized`, strip the suffix to get the output id, find - `#.pyvis-network-output`, remove `.is-loading`, clear its timer. - - Idempotent: re-showing reuses the same overlay node. +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 `dataset.sespyNetShown = '1'`, + and arm/re-arm an 8 s `setTimeout` (keyed by `e.target.id`) that removes + `is-loading`. + - `$(document).on('shiny:inputchanged', …)` → if `e.name` ends with `_ready`, + strip the suffix, and on the matching `#.pyvis-network-output` remove + `is-loading` and clear its timer. + No MutationObserver, no DOM injection. ### Why not alternatives -- **Shiny `busy_indicators`**: trivial to enable but only covers the server - recalculating phase — it hides once the HTML is delivered, *before* physics - stabilization, i.e. it misses the dominant wait. Rejected. -- **Modify the pyvis fork's `bindings.js`**: the fork owns the network and could - show its own overlay on `stabilizationIterationsDone` precisely, but the fork - is a separate repo installed from a conda channel (not the SESPy tree), so it - can't be committed/shipped from here. Rejected in favor of the shell. +- **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 -- Missing/never-firing `_stabilized` → 8 s per-overlay timeout hides it anyway. -- Overlay must never block interaction after hide: `pointer-events: none` when - not `.is-loading`. -- Must not disturb the fork's toolbar, status bar, or modal overlays (z-index - and scoping keep them independent). -- Shared-shell blast radius is intended (all network views, both apps); the - change only *adds* an overlay layer and touches no nav/collapse behavior. +- `_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, so it is gated by SESPy CI): drive a SESPy - network view that already has an e2e (CLD visualization or Leverage), and for - its output id `` assert: - 1. an overlay element exists inside `#.pyvis-network-output`, and - 2. once `window.pyvisNetworks[''].nodes.length > 0`, the output no longer - carries `.is-loading` (overlay hidden). - Deliberately assert the **end state**, not a race to catch the transient - visible overlay — matching the condition-based-waiting lesson from the - leverage/simulation e2e de-flake. Prefer extending an existing - `tests/test_cld_e2e.py` / `tests/test_leverage_e2e.py` over a new script. -- **Manual smoke**: load MosaicSES Topology and a larger compartment CLD; the - spinner shows during load and disappears when the graph settles; the pyvis - toolbar and node/edge modals still work. +- **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. + End-state only; never races to catch the transient visible spinner (per the + condition-based-waiting lesson from the leverage/simulation de-flake). The + sticky marker closes the "spinner never showed" vacuous-pass gap. +- **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` — add `.sespy-net-overlay` styles. -- `tests/test_cld_e2e.py` or `tests/test_leverage_e2e.py` (SESPy) — one - end-state assertion (extend, don't add a new script). +- `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. From 462e4c79be3606149b362eabe930ce2400719033 Mon Sep 17 00:00:00 2001 From: razinkele Date: Sat, 4 Jul 2026 09:53:16 +0300 Subject: [PATCH 4/7] docs(spec+plan): fold in round-2 verification findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review (3 agents) confirmed the pseudo-element mechanism paints above the canvas, _ready fires every render, and $ is available at parse (cross-app safe). Two fixes folded in: (1) HIGH — add data-sespy-net-hidden provenance ('ready' vs 'fallback') and assert 'ready' in the e2e, so the 8s fallback can't mask a broken _ready hide; (2) nit — rgba() background fallback before color-mix for older engines. Confirmed SESPy + MosaicSES are the only two sespy.dashboard consumers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-02-network-loading-spinner.md | 27 +++++++++++++++++-- ...26-07-02-network-loading-spinner-design.md | 21 ++++++++++----- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-network-loading-spinner.md b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md index 9b4bb00..9bd7623 100644 --- a/docs/superpowers/plans/2026-07-02-network-loading-spinner.md +++ b/docs/superpowers/plans/2026-07-02-network-loading-spinner.md @@ -66,7 +66,17 @@ The e2e checks the class/attribute contract only (not pixels), so it depends on break await page.wait_for_timeout(500) assert not_loading, "#cld-network stuck in .is-loading after the graph rendered" - print("network spinner show->hide: OK") + # 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** @@ -94,9 +104,13 @@ Expected: FAIL at `assert shown` — nothing sets `data-sespy-net-shown` yet. 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'); }, FALLBACK_MS); + timers[el.id] = setTimeout(function () { + el.classList.remove('is-loading'); + el.setAttribute('data-sespy-net-hidden', 'fallback'); // hid via safety net + }, FALLBACK_MS); } }); @@ -109,6 +123,7 @@ Expected: FAIL at `assert shown` — nothing sets `data-sespy-net-shown` yet. 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]); } }); @@ -208,6 +223,7 @@ Then, immediately after the `.pyvis-network-output .pyvis-network-canvas { borde 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; @@ -313,6 +329,13 @@ git commit -m "docs: cross-app manual smoke checklist for the network spinner" `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`. 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 index 6a2b430..328ad46 100644 --- a/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md +++ b/docs/superpowers/specs/2026-07-02-network-loading-spinner-design.md @@ -83,13 +83,15 @@ for all renders when the network is created/about to draw — the correct, unifo 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 `dataset.sespyNetShown = '1'`, - and arm/re-arm an 8 s `setTimeout` (keyed by `e.target.id`) that removes - `is-loading`. + `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` and clear its timer. - No MutationObserver, no DOM injection. + `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 @@ -123,9 +125,14 @@ for all renders when the network is created/about to draw — the correct, unifo 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 - sticky marker closes the "spinner never showed" vacuous-pass gap. + 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. From e077853a5aa0503f262073054069dccc616642ac Mon Sep 17 00:00:00 2001 From: razinkele Date: Sun, 5 Jul 2026 15:47:46 +0300 Subject: [PATCH 5/7] 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 and data-sespy-net-hidden provenance ('ready' vs 'fallback'). CLD e2e asserts the show->hide cycle happened via _ready (not the fallback), non-racy. 5/5 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- sespy/dashboard.py | 44 +++++++++++++++++++++++++++++++++++++++++++ tests/test_cld_e2e.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) 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() From 115c074572e09a533fb2dddd7cfee23189491291 Mon Sep 17 00:00:00 2001 From: razinkele Date: Sun, 5 Jul 2026 19:09:32 +0300 Subject: [PATCH 6/7] style(shell): pyvis network loading spinner pseudo-elements (SESPy skin) --- www/sespy-skin.css | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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. From 660962455048f57b4cdb2920e90deb3ba4d6b3c3 Mon Sep 17 00:00:00 2001 From: razinkele Date: Sun, 5 Jul 2026 20:30:05 +0300 Subject: [PATCH 7/7] docs: cross-app manual smoke checklist for the network spinner --- ...6-07-02-network-spinner-smoke-checklist.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/2026-07-02-network-spinner-smoke-checklist.md 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.