// 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 ( ); } const stop = stops[cur]; const ops = window.TutorialParser.buildOps(stop, platform, release); const playKey = cur + "-" + replay + "-" + platform + "-" + (t.simulateFail ? "f" : "") + "-" + (t.typewriter ? "a" : "i"); const totalPass = Object.values(perStop).reduce((a, b) => a + (b.pass || 0), 0); const totalFail = Object.values(perStop).reduce((a, b) => a + (b.fail || 0), 0); const totalSkip = Object.values(perStop).reduce((a, b) => a + (b.skip || 0), 0); return (
tutorial_runner {raw.filename}
✓ {totalPass} {totalFail > 0 && ✕ {totalFail}} {totalSkip > 0 && ⊘ {totalSkip}}
{ setPlaying(false); goTo(i); }} stats={stats} perStop={perStop} />
reader what the tutorial shows
execution {workdir} {t.simulateFail && fail-sim}
setPlaying((p) => !p)} speed={speed} onSpeed={setSpeed} platform={platform} onPlatform={(p) => { setPlatform(p); }} onRestart={restart} release={release} /> {showFull && setShowFull(false)} />}
); } function TweaksUI({ t, setTweak }) { return ( setTweak("accent", v)} /> setTweak("mono", v)} /> setTweak("sans", v)} /> setTweak("fontScale", v)} /> setTweak("typewriter", v)} /> setTweak("simulateFail", v)} /> ); } ReactDOM.createRoot(document.getElementById("root")).render();