// app.jsx — root of the Executable Tutorial Player.
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#7ab8ff",
"mono": "'JetBrains Mono'",
"sans": "'Public Sans'",
"typewriter": true,
"fontScale": 100,
"simulateFail": false
}/*EDITMODE-END*/;
function randWorkdir() {
const s = Math.random().toString(36).slice(2, 8);
return "/tmp/tutorial-test-" + s;
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [raw, setRaw] = React.useState(null); // {yaml, filename}
const [spec, setSpec] = React.useState(null);
const [stops, setStops] = React.useState([]);
const [outline, setOutline] = React.useState([]);
const [stats, setStats] = React.useState(null);
const [error, setError] = React.useState(null);
const [cur, setCur] = React.useState(0);
const [replay, setReplay] = React.useState(0);
const [playing, setPlaying] = React.useState(false);
const [speed, setSpeed] = React.useState(1);
const [platform, setPlatform] = React.useState("linux");
const [perStop, setPerStop] = React.useState({});
const [showFull, setShowFull] = React.useState(false);
const [workdir, setWorkdir] = React.useState(randWorkdir());
const advanceRef = React.useRef(null);
const release = (spec && spec.release) || "";
// accent + fonts → CSS vars
React.useEffect(() => {
const r = document.documentElement;
r.style.setProperty("--accent", t.accent);
r.style.setProperty("--mono", t.mono + ", ui-monospace, monospace");
r.style.setProperty("--sans", t.sans + ", system-ui, sans-serif");
r.style.setProperty("--font-scale", (t.fontScale || 100) / 100);
}, [t.accent, t.mono, t.sans, t.fontScale]);
function loadYaml(yaml, filename) {
try {
const parsed = window.TutorialParser.parse(yaml);
const st = window.TutorialParser.buildStops(parsed);
const ol = window.TutorialParser.buildOutline(parsed, st);
setSpec(parsed);
setStops(st);
setOutline(ol);
setStats(window.TutorialParser.specStats(parsed));
setRaw({ yaml, filename });
setError(null);
setCur(0);
setReplay((r) => r + 1);
setPerStop({});
setPlaying(false);
setWorkdir(randWorkdir());
} catch (e) {
setError(e.message || String(e));
}
}
function reset() {
if (advanceRef.current) clearTimeout(advanceRef.current);
setRaw(null); setSpec(null); setStops([]); setOutline([]);
setStats(null); setCur(0); setPlaying(false); setPerStop({});
}
const goTo = React.useCallback((i) => {
if (advanceRef.current) clearTimeout(advanceRef.current);
setCur((prev) => {
const next = Math.max(0, Math.min(stops.length - 1, i));
if (next === prev) return prev;
return next;
});
}, [stops.length]);
const next = React.useCallback(() => goTo(cur + 1), [cur, goTo]);
const prev = React.useCallback(() => { setPlaying(false); goTo(cur - 1); }, [cur, goTo]);
function restart() {
if (advanceRef.current) clearTimeout(advanceRef.current);
setPerStop({});
setPlaying(false);
if (cur === 0) setReplay((r) => r + 1);
else setCur(0);
}
function onTermComplete(tally) {
setPerStop((p) => ({ ...p, [cur]: tally }));
if (playing) {
if (cur < stops.length - 1) {
advanceRef.current = setTimeout(() => setCur((c) => Math.min(stops.length - 1, c + 1)), 850 / speed);
} else {
setPlaying(false);
}
}
}
// keyboard
React.useEffect(() => {
function onKey(e) {
if (!spec || showFull) return;
if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return;
if (e.key === "ArrowRight") { e.preventDefault(); next(); }
else if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
else if (e.key === " ") { e.preventDefault(); setPlaying((p) => !p); }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [spec, showFull, next, prev]);
React.useEffect(() => () => { if (advanceRef.current) clearTimeout(advanceRef.current); }, []);
if (!spec) {
return (