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 @@
+
+
+
Real shots of the MAST tokamak, beside the physics models, with learned corrections and their errors stated. Where would you like to start?
-
-
+
+
+
— the what-if sliders on their own: pick a machine, turn the knobs, and watch the operating limits respond.
You can switch at any time with Guided | Lab in the header.
diff --git a/web/lessons.json b/web/lessons.json
index 98f1e28..20560af 100644
--- a/web/lessons.json
+++ b/web/lessons.json
@@ -22,12 +22,12 @@
{ "id": "0.2", "ch": 0, "title": "What fusion is", "stage": "reaction", "focus": [],
"body": "Squeeze two light nuclei together and they fuse into a heavier one, releasing energy. The easiest pair is deuterium and tritium ([[D-T|dt]]). The catch: nuclei are positively charged and repel each other. Only when the fuel is extremely hot do enough of them collide hard enough to fuse. Temperature here is measured in [[keV|kev]]: 1 keV is about 11.6 million °C.",
"task": { "text": "Drag the temperature up until the fuel reaches 10 keV.", "check": { "event": "fusionlab:figure", "all": [{ "path": "name", "op": "==", "value": "reaction" }, { "path": "T_keV", "op": ">=", "value": 10 }] } },
- "deeper": "D + T → ⁴He (3.5 MeV) + n (14.1 MeV). The curve is the D-T [[reactivity]] ⟨σv⟩ from the Bosch–Hale fit, the same function the sandbox uses (physics.sigmav_dt). It peaks near 65 keV, but a reactor runs around 10–20 keV, where fusion power for a given plasma pressure is highest." },
+ "deeper": "D + T → ⁴He (3.5 MeV) + n (14.1 MeV). The curve is the D-T [[reactivity]] ⟨σv⟩ from the Bosch–Hale fit, the same function behind the Explore readouts (physics.sigmav_dt). It peaks near 65 keV, but a reactor runs around 10–20 keV, where fusion power for a given plasma pressure is highest." },
{ "id": "0.3", "ch": 0, "title": "Three things at once", "stage": "triple", "focus": [],
"body": "Hot is not enough. The fuel must be hot (temperature T), dense enough for collisions to be frequent (density n), and it must hold its heat long enough (the [[energy confinement time|tau-e]] τE). Multiply the three and you get the [[triple product]], the single number that says how close a plasma is to sustaining itself.",
"task": { "text": "Load the ITER preset, then the MAST one, and compare.", "check": { "event": "fusionlab:figure", "all": [{ "path": "name", "op": "==", "value": "triple" }, { "path": "preset", "op": "==", "value": "mast" }] } },
- "deeper": "Ignition needs roughly n·T·τE ≳ 3 × 10²¹ m⁻³·keV·s (Wesson, Tokamaks). The ITER preset is this app's sandbox baseline; the MAST preset is the peak of shot #30166. The gap of about a thousand is mostly confinement time, and confinement time is mostly size." },
+ "deeper": "Ignition needs roughly n·T·τE ≳ 3 × 10²¹ m⁻³·keV·s (Wesson, Tokamaks). The ITER preset is this app's Explore baseline; the MAST preset is the peak of shot #30166. The gap of about a thousand is mostly confinement time, and confinement time is mostly size." },
{ "id": "0.4", "ch": 0, "title": "The score, and an honest note", "stage": "triple", "focus": [],
"body": "The score of a fusion machine is its [[gain Q|q-gain]]: fusion power out divided by heating power in. Q = 1 is breakeven. JET reached about two thirds in 1997. ITER is built for Q = 10. MAST never tried. It ran on deuterium only and made no fusion power to speak of. Its job was to study the bottle, not the fire: how well a compact, [[spherical tokamak|spherical-tokamak]] holds heat and where it goes unstable. That is what its data can teach you." },
@@ -161,7 +161,7 @@
{ "id": "4.8", "ch": 4, "title": "Distance to the three walls",
"tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["gauge-greenwald", "gauge-troyon", "gauge-kink", "strip-lim"],
- "body": "The same three limits as in the sandbox, now [[computed|provenance]] from measured quantities: density against the [[Greenwald|greenwald]] ceiling, EFIT's βN against the [[Troyon|troyon]] limit, and q95 against the [[kink]] limit. The bottom strip shows all three through time. On this shot the Troyon gauge climbs into amber and peaks at 0.92: MAST routinely ran close to the pressure limit, because high pressure for a given field is the point of a spherical tokamak." },
+ "body": "The same three limits as in Explore, now [[computed|provenance]] from measured quantities: density against the [[Greenwald|greenwald]] ceiling, EFIT's βN against the [[Troyon|troyon]] limit, and q95 against the [[kink]] limit. The bottom strip shows all three through time. On this shot the Troyon gauge climbs into amber and peaks at 0.92: MAST routinely ran close to the pressure limit, because high pressure for a given field is the point of a spherical tokamak." },
{ "id": "5.1", "ch": 5, "title": "Ramp-up",
"tab": "replay", "shot": 30166, "slice": { "t": 0.06 }, "view": "cutaway", "highlight": null, "focus": ["xs", "strip-ip", "params", "bar"],
@@ -170,7 +170,7 @@
{ "id": "5.2", "ch": 5, "title": "Beams on",
"tab": "replay", "shot": 30166, "slice": { "t": 0.1 }, "focus": ["strip-p", "strip-w", "bar"],
- "body": "So far the only heating is ohmic, under 1 MW, and the stored energy is about 10 kJ. Find the moment the [[neutral beam|nbi]] switches on.",
+ "body": "So far the only heating is ohmic, under 1 MW, and the stored energy is about 0.01 MJ. Find the moment the [[neutral beam|nbi]] switches on.",
"task": { "text": "Scrub forward to the first slice with the beam on.", "check": { "event": "fusionlab:slice", "all": [{ "path": "nbi_on", "op": "==", "value": true }, { "path": "t_s", "op": "<=", "value": 0.135 }] } },
"deeper": "From 0.12 s about 2 MW of beam power arrives and the stored energy triples within 30 ms. Part of that early rise is the beam's own fast ions, which EFIT counts as stored energy." },
@@ -181,11 +181,11 @@
{ "id": "5.4", "ch": 5, "title": "The peak",
"tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["params", "gauge-troyon", "strip-w"],
- "body": "At 0.27 s the stored energy peaks at 127 kJ and the Troyon gauge reaches 0.92. H98 reads 1.3, which would be excellent confinement. Be careful. The measured energy includes fast beam ions that the scaling law knows nothing about, so H98 reads high whenever beams are on. A number is only as good as your knowledge of what went into it, and the panel says so." },
+ "body": "At 0.27 s the stored energy peaks at 0.13 MJ and the Troyon gauge reaches 0.92. H98 reads 1.3, which would be excellent confinement. Be careful. The measured energy includes fast beam ions that the scaling law knows nothing about, so H98 reads high whenever beams are on. A number is only as good as your knowledge of what went into it, and the panel says so." },
{ "id": "5.5", "ch": 5, "title": "What the comparison shows",
"tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["insight", "logbook"],
- "body": "Two readings of the same shot, side by side. On the left, what the twin works out from the data: confinement relative to each law, the closest approach to a limit and when, and how the learned correction did on this shot. On the right, what the physicist in the control room wrote. When they agree, you can trust both a little more. When they differ, you have found something worth a closer look." },
+ "body": "Two readings of the same shot, side by side. On the left, what the twin works out from the data: confinement relative to each law, which limit the shot came closest to, and how the learned correction did on this shot. On the right, what the physicist in the control room wrote. When they agree, you can trust both a little more. When they differ, you have found something worth a closer look." },
{ "id": "6.1", "ch": 6, "title": "Same recipe, more power",
"tab": "replay", "shot": 30192, "slice": 0, "view": "cutaway", "highlight": null, "focus": ["bar", "strip-p", "strip-w", "strip-lim"],
@@ -204,7 +204,7 @@
{ "id": "7.1", "ch": 7, "title": "A law is a fit",
"tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["strip-w"],
- "body": "The warm curves come from [[scaling laws|scaling-law]]: power-law fits of confinement time across thousands of shots on many machines. They are how ITER was sized. But the machines in that database were conventional, doughnut-shaped tokamaks. MAST is a cored apple, and published studies found it scales differently, with a weaker dependence on current and a much stronger one on field. So on MAST the law is a reference line, not the truth.",
+ "body": "The warm curves come from [[scaling laws|scaling-law]]: power-law fits of confinement time across thousands of shots on many machines. They are how ITER was sized. But the machines in those fits were conventional, doughnut-shaped tokamaks. MAST is a cored apple, and published studies found it scales differently, with a weaker dependence on current and a much stronger one on field. So on MAST the law is a reference line, not the truth.",
"deeper": "Valovič et al., Nucl. Fusion 49 (2009) 075016 fitted MAST H-modes as W ∝ Ip0.59 BT1.4, against [[IPB98(y,2)|ipb98]]'s Ip0.93 BT0.15. The same paper notes the magnitudes broadly agree." },
{ "id": "7.2", "ch": 7, "title": "One shot among thousands",
@@ -222,6 +222,6 @@
{ "id": "8.1", "ch": 8, "title": "Into the lab",
"tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "overlay": true, "highlight": null, "view": "cutaway", "focus": [], "last": true,
- "body": "Look at the screen again. Flux surfaces from magnetics. A Thomson profile. Stored energy beside two laws and a learned correction. Three limit gauges. A logbook. Field lines with q counted along them. None of it is jargon now. Three things to try: replay the ohmic shot #30420 and find which limit it came closest to; press What-if on #30192 and give its beams 30 % more power; pick any shot and check the twin's reading against the logbook. The ? on any panel brings you back to its lesson." }
+ "body": "Look at the screen again. Flux surfaces from magnetics. A Thomson profile. Stored energy beside two laws and a learned correction. Three limit gauges. A logbook. Field lines with q counted along them. None of it is jargon now. Three things to try: replay the ohmic shot #30420 and find which limit it came closest to; press Edit this shot on #30192 and give its beams 30 % more power; pick any shot and check the twin's reading against the logbook. The ? on any panel brings you back to its lesson." }
]
}
diff --git a/web/palette.js b/web/palette.js
new file mode 100644
index 0000000..9dd57f3
--- /dev/null
+++ b/web/palette.js
@@ -0,0 +1,243 @@
+// FusionLab command palette: a native