From e32694d17eb77596e3a9a691f7c1d2c35623ad75 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 17:02:28 +0000 Subject: [PATCH 01/10] chore: release branch for FusionLab task-mode IA redesign (Direction C) From cdb157d69459699fe8d6dbdcd4e8299dacc6870b Mon Sep 17 00:00:00 2001 From: "obvious-autobuild[bot]" <262744130+obvious-autobuild[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:47 +0000 Subject: [PATCH 02/10] feat(router): three workspaces behind one hash grammar (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(router): three workspaces behind one hash grammar One route() owns which workspace is visible and the URL grammar — #/replay/{shot}?t= · #/compare/{ids}?ref= · #/explore — with hashchange and popstate handling, a persisted landing (last visited, else the welcome-modal pick, else Replay), legacy '#sandbox' and ?shot=/?compare= deep links kept working, and data-guide anchors plus the window.FusionLab / fusionlab:* contracts untouched (showTab is now a hash-writing alias). The compare chip machine's public hook (FusionLab.replay.compare) lets the router mount #/compare/{ids}; catalog shot clicks route through the grammar; the legacy srchSetUrl/cmpUrl URL writers preserve the hash they used to strip; and a welcome-modal pick no longer stomps a deep-linked route (it only navigates off the bare default landing). Mirrors the section-id renames in the UI-contract tests and serves the new module from the static-assets test. Co-authored-by: Tapan Ganatma Nakkina * style(header): task-mode tabs with ARIA, focus ring, mobile legend collapse Header tabs become Replay / Compare / Explore with role=tablist/tab, aria-selected, aria-controls and arrow-key roving focus; the sections become tab-replay / tab-compare / tab-explore. A --focus-ring token adds :focus-visible styling app-wide, the disabled ⌘K button stakes the palette's header slot, and the provenance legend (its tags repeat on every panel) collapses below 760px so the title stops clipping on mobile — the audit defect captured in fl_AmO2E2fS. The welcome modal gains an "Explore the model" pick that persists its landing. Co-authored-by: Tapan Ganatma Nakkina * fix(welcome): keep the welcome pick working across reloads of a bare landing Reloading a bare landing stranded the welcome-modal pick on Replay: the boot's own canonicalizing replaceState wrote '#/replay' into the URL, so on the next boot route() read that app-written hash as an explicit route and defaultLanding stayed false — the pick stored its landing but never navigated. Root cause, not symptom: a hash the app itself wrote must not look like one the user asked for. Leave the bare landing bare; the hash carries state only once the user navigates (deep links, #sandbox and the legacy ?shot=/?compare= entry points are unchanged). Verified in-browser: bare boot -> pick 'Explore the model' -> '#/explore' applied; reload -> '#/explore' restored, modal suppressed; reload BEFORE picking -> pick still navigates. pytest 352 passed + 1 skipped, ruff and mypy clean. Co-authored-by: Tapan Ganatma Nakkina --------- Co-authored-by: Obvious Co-authored-by: Tapan Ganatma Nakkina --- tests/test_api.py | 4 +- tests/test_web_checks.py | 2 +- web/app.js | 21 +++--- web/index.html | 21 +++--- web/replay.js | 24 +++--- web/style.css | 15 ++++ web/workspaces.js | 155 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 web/workspaces.js diff --git a/tests/test_api.py b/tests/test_api.py index 2eb1838..0119f61 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -154,13 +154,13 @@ def test_map_matches_simulate_at_a_grid_point(): def test_static_assets_served(): - for path in ("/static/app.js", "/static/style.css", "/static/replay.js", "/static/vessel3d.js", + for path in ("/static/app.js", "/static/workspaces.js", "/static/style.css", "/static/replay.js", "/static/vessel3d.js", "/static/vendor/three.module.min.js", "/static/vendor/OrbitControls.js"): assert client.get(path).status_code == 200, path assert "fusionlab:tab" in client.get("/static/app.js").text assert client.get("/static/vendor/plotly.min.js").status_code == 200 html = client.get("/").text - assert 'id="tab-replay"' in html and 'id="tab-db"' in html and 'id="tab-sandbox"' in html + assert 'id="tab-replay"' in html and 'id="tab-compare"' in html and 'id="tab-explore"' in html # the demo never needs the network: no CDN in the page or in any script of ours (vendored libraries aside) ours = [html] + [p.read_text() for p in sorted(WEB.glob("*.js")) + sorted(WEB.glob("*.json"))] for text in ours: diff --git a/tests/test_web_checks.py b/tests/test_web_checks.py index a018c51..97d2cfe 100644 --- a/tests/test_web_checks.py +++ b/tests/test_web_checks.py @@ -76,7 +76,7 @@ def test_failed_shot_switch_reverts_the_select(): def test_fire_and_forget_panels_render_their_errors(): """JS-07: the /db and /surrogate fetches carry the figures.js one-line .catch.""" replay = _served("replay.js") - assert ".catch(() => { $('tab-db').innerHTML" in replay + assert ".catch(() => { $('tab-compare').innerHTML" in replay assert ".catch(() => { const el = $('rp-sur'); if (el) el.innerHTML" in replay diff --git a/web/app.js b/web/app.js index 8c52690..83d09ce 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,6 @@ -// FusionLab shell (tabs) + Sandbox tab: what-if sliders on the reduced 0D model. -// Static file, no bundler. The Replay tab lives in replay.js and listens for 'fusionlab:tab'. +// FusionLab shell + Explore workspace (the reduced 0D model): what-if sliders on device presets. +// Static file, no bundler. Navigation lives in workspaces.js (the router, loaded first); the Replay +// workspace lives in replay.js. Both listen for 'fusionlab:tab'. (() => { 'use strict'; const $ = (id) => document.getElementById(id); @@ -10,15 +11,15 @@ const GAUGE_SPAN = 1.25; // gauge full scale (the 1.0 tick sits at 80%, see style.css) let devices = {}, simSeq = 0, mapSeq = 0, mapReady = false; - // ---------------------------------------------------------------- tabs + // ---------------------------------------------------------------- workspaces + // The router (workspaces.js) owns which section is visible and the URL grammar. showTab keeps its + // name and event contract for guide.js / agent.js (they call it and listen for 'fusionlab:tab') — + // it is now a thin alias that writes the hash, the one navigation act. function showTab(name) { - document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === name)); - document.querySelectorAll('section[id^="tab-"]').forEach((s) => { s.hidden = s.id !== 'tab-' + name; }); - document.dispatchEvent(new CustomEvent('fusionlab:tab', { detail: name })); + window.FusionLabRouter.navigate(name); } - document.querySelectorAll('.tabs button').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab))); document.addEventListener('fusionlab:tab', (e) => { - if (e.detail === 'sandbox' && mapReady && window.Plotly) Plotly.Plots.resize($('map')); + if (e.detail === 'explore' && mapReady && window.Plotly) Plotly.Plots.resize($('map')); }); // ---------------------------------------------------------------- helpers @@ -167,6 +168,6 @@ }, }); - // Replay (real shots) is the landing tab; /#sandbox opens the what-if sliders directly. - if (location.hash !== '#sandbox') showTab('replay'); + // Boot navigation is the router's job (workspaces.js): it applies the deep link or the + // landing workspace (last visited, else the welcome-modal pick, else Replay). })(); diff --git a/web/index.html b/web/index.html index 9bfbaa0..813a4e4 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,7 @@ + @@ -26,17 +27,18 @@

FUSIONLAB · open-data tokamak twin

-