diff --git a/tests/test_api.py b/tests/test_api.py index 612f2d4..0313e60 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -159,13 +159,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 bf87aa0..4b00ceb 100644 --- a/tests/test_web_checks.py +++ b/tests/test_web_checks.py @@ -87,7 +87,9 @@ 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 + # /db failure: only the catalog-clouds panel blanks — the comparison bar and the search live in the + # same Compare workspace and must survive a /db outage. + assert ".catch(() => { const el = $('rp-clouds'); if (el) el.innerHTML" in replay assert ".catch(() => { const el = $('rp-sur'); if (el) el.innerHTML" in replay diff --git a/web/agent.js b/web/agent.js index 952a908..dcf2af3 100644 --- a/web/agent.js +++ b/web/agent.js @@ -486,7 +486,7 @@ const j = await r.json().catch(() => null); if (!r.ok) { status.textContent = (j && j.detail) || `accept failed (${r.status})`; status.className = 'small agent-line-no'; btn.disabled = false; return; } host.innerHTML = ''; - status.textContent = `Accepted — saved to data/protocols/${j.protocol_id || pid}.json. An accepted protocol fills the sandbox form step by step, each step still confirmed.`; + status.textContent = `Accepted — saved to data/protocols/${j.protocol_id || pid}.json. An accepted protocol fills the Explore form step by step, each step still confirmed.`; status.className = 'small agent-line-ok'; } catch (e) { status.textContent = 'the accept request failed: ' + (e && e.message || e); 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/figures.js b/web/figures.js index 3c96394..6d1d504 100644 --- a/web/figures.js +++ b/web/figures.js @@ -68,7 +68,7 @@ } // ---------------------------------------------------------------- n · T · tau_E - const PRESET = { // ITER: this app's sandbox baseline (physics.simulate). MAST: shot #30166 at t = 0.27 s (n̄e, Te0, W/P_loss). + const PRESET = { // ITER: this app's Explore baseline (physics.simulate). MAST: shot #30166 at t = 0.27 s (n̄e, Te0, W/P_loss). iter: { n: 1.015, T: 7.6, tau: 3.81, label: 'ITER: this app’s model, volume-average T' }, mast: { n: 0.386, T: 1.22, tau: 0.049, label: 'MAST #30166 at 0.27 s: measured, core electron T' }, }; diff --git a/web/glossary.json b/web/glossary.json index f41b168..ff551f8 100644 --- a/web/glossary.json +++ b/web/glossary.json @@ -1,5 +1,5 @@ { - "_about": "One sentence per term, shared by the guided study (hover on a dotted word) and the lab (hover on a panel label, help line under a slider). 'on' lists data-guide names that get this definition as their tooltip; 'help' is the line under a sandbox slider. Physics statements follow docs/PHYSICS.md.", + "_about": "One sentence per term, shared by the guided study (hover on a dotted word) and the lab (hover on a panel label, help line under a slider). 'on' lists data-guide names that get this definition as their tooltip; 'help' is the line under an Explore slider. Physics statements follow docs/PHYSICS.md.", "terms": { "plasma": { "name": "Plasma", "def": "A gas hot enough that electrons have left their atoms, so it conducts electricity and can be pushed and held by magnetic fields." }, "kev": { "name": "keV", "def": "The temperature unit used in fusion: 1 keV is about 11.6 million °C. A burning D-T plasma needs roughly 10 keV." }, diff --git a/web/guide.js b/web/guide.js index a6c8c60..9a88d75 100644 --- a/web/guide.js +++ b/web/guide.js @@ -72,7 +72,7 @@ ['mouseover', 'focusin'].forEach((ev) => document.addEventListener(ev, (e) => { const t = e.target.closest && e.target.closest('.term'); if (t) showTip(t); })); ['mouseout', 'focusout'].forEach((ev) => document.addEventListener(ev, (e) => { if (e.target.closest && e.target.closest('.term')) tip.hidden = true; })); - // Lab-mode help, from the same glossary: tooltips on panel labels, one line under each sandbox slider, "?" back to a lesson. + // Lab-mode help, from the same glossary: tooltips on panel labels, one line under each Explore slider, "?" back to a lesson. function labHelp() { Object.values(G).forEach((g) => (g.on || []).forEach((name) => { const el = target(name); if (el && !el.title) el.title = `${g.name}: ${g.def}`; })); const KNOB = { Ip: 'ip', B: 'bt', n: 'density', P_aux: 'heating', H: 'h-factor', Zeff: 'zeff' }; @@ -107,7 +107,7 @@ const keep = new Set(); // the lit elements and every ancestor up to the tab inTab.forEach((el) => { for (let n = el; n && n !== section.parentNode; n = n.parentNode) keep.add(n); }); const dock = parseFloat(getComputedStyle(document.body).getPropertyValue('--dock')) || 0, r = inTab[0].getBoundingClientRect(); - if (r.top < 0 || r.top > innerHeight - dock - 80) inTab[0].scrollIntoView({ block: 'start', behavior: 'smooth' }); // its top is off screen (pages that scroll) + if (r.top < 0 || r.top > innerHeight - dock - 80) inTab[0].scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); // its top is off screen (pages that scroll) keep.forEach((node) => { if (inTab.includes(node)) return; // a lit element keeps all of its children [...node.children].forEach((c) => { @@ -246,6 +246,18 @@ modeSeg.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.v); }); welcome.addEventListener('click', (e) => { const b = e.target.closest('[data-mode]'); if (b) setMode(b.dataset.mode); }); + // ---------------------------------------------------------------- the command palette's hook + // The palette (web/palette.js) starts or resumes the study through this — the same jump the per-panel + // "?" buttons take (find the lesson, setMode). Additive: existing FusionLab keys untouched. + window.FusionLab = Object.assign(window.FusionLab || {}, { guided: { + start(lessonId) { + if (!L) return false; // lessons not loaded (boot failed): the palette reports it, nothing breaks + if (lessonId != null) { const i = L.steps.findIndex((s) => s.id === lessonId); if (i >= 0) idx = i; } + setMode('guided'); + return true; + }, + } }); + async function boot() { const [lessons, glossary] = await Promise.all(['lessons', 'glossary'].map((f) => fetch(`/static/${f}.json`).then((r) => r.json()))); L = lessons; G = glossary.terms; diff --git a/web/index.html b/web/index.html index 9bfbaa0..ddabebc 100644 --- a/web/index.html +++ b/web/index.html @@ -8,10 +8,13 @@ + + +
@@ -26,17 +29,18 @@

FUSIONLAB · open-data tokamak twin

-
- - + + -
+