0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Everyone learns that functions call each other, and almost nobody gets to see the pile of paused work that builds up while they do. Step through three tiny programs one line at a time and the call stack draws itself: a frame appears holding its own private copies of the arguments, sits frozen on the line it stopped at while something else runs on top of it, and vanishes the moment it hands a value back — source highlight, frame contents, depth strip and a plain-English narration line all moving together. It opens already running the receipt example, where checkout stalls on line 2 waiting for add and then again on line 3 waiting for toCents; press 2 and four copies of factorial stack up, each with its own n, until a single 1 comes back and the whole pile collapses upward. Example 3 is the strange one — two functions that each refuse to answer and keep handing the question to the other — and arrow keys step either direction (the unwind, backwards, is the part that usually only exists in your head), space plays, and the depth strip is a scrubber you can drag to any moment in the trace.
Source — the code you see is the code that just ran 968 lines 42.9 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Call Stack Stepper</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* --- demo-local supplements (derived; same three theme states) --- */
:root {
--canvas: #FBFBF8; --track: #ECEBE5; --bar: #DBDAD2; --shadow: 0 1px 2px rgba(20,22,26,.05);
--syn-num: #1F6FB2; --syn-str: #8A5A00; --ghost: rgba(26,29,33,.05);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--canvas: #171A20; --track: #23272F; --bar: #313742; --shadow: 0 1px 2px rgba(0,0,0,.32);
--syn-num: #79C0FF; --syn-str: #E0B341; --ghost: rgba(255,255,255,.075);
}
}
:root[data-theme="dark"] {
--canvas: #171A20; --track: #23272F; --bar: #313742; --shadow: 0 1px 2px rgba(0,0,0,.32);
--syn-num: #79C0FF; --syn-str: #E0B341; --ghost: rgba(255,255,255,.075);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { -webkit-text-size-adjust: 100%; line-height: 1.45; overflow-x: hidden; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 22px 16px 30px; }
header h1 { font-size: 21px; margin: 0 0 4px; letter-spacing: -.01em; }
header p.lede { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 66ch; }
.chip {
display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .06em;
text-transform: uppercase; color: var(--accent); background: var(--accent-soft);
border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; margin-bottom: 10px;
}
.panel {
margin-top: 14px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
}
.phead {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 12px; border-bottom: 1px solid var(--line);
}
.phead h2 {
margin: 0; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); font-weight: 500;
}
.phead .spacer { flex: 1 1 auto; }
.pill {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .05em; padding: 2px 8px;
border-radius: 999px; border: 1px solid var(--line); color: var(--muted); background: var(--bg);
white-space: nowrap;
}
.pill.ok { color: var(--run); background: var(--run-soft); border-color: var(--run); }
.pill.busy { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); }
/* ---- buttons / controls ---- */
button {
font-family: var(--font-sans); font-size: 13.5px; color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px; min-height: 36px;
cursor: pointer; white-space: nowrap;
}
button:hover:not(:disabled) { border-color: var(--muted); }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
select {
font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; padding: 7px 8px; min-height: 36px;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
.picker { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.picker button { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; flex: 1 1 200px; text-align: left; padding: 8px 11px; }
.picker .pk-name { font-size: 13.5px; font-weight: 600; }
.picker .pk-sig { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.picker button[aria-pressed="true"] .pk-sig { color: var(--accent); }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 11px 12px; }
.controls .spacer { flex: 1 1 auto; }
.tbtn { font-family: var(--font-mono); font-size: 12.5px; padding: 6px 10px; min-width: 40px; }
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; letter-spacing: .04em; white-space: nowrap; }
.readout b { color: var(--ink); font-weight: 600; }
/* ---- narration ---- */
.narr {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 13px; border-top: 1px solid var(--line); background: var(--canvas);
/* tall enough for the longest note at this width, so stepping never shifts
the panels underneath. Raised again at the narrower breakpoints. */
min-height: 76px;
}
.narr .marker {
flex: 0 0 auto; font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); border: 1px solid var(--line);
border-radius: 999px; padding: 2px 7px; margin-top: 2px; background: var(--surface);
}
.narr .marker.call { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.narr .marker.ret, .narr .marker.done { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.narr .txt { font-size: 14.5px; line-height: 1.5; min-width: 0; }
.narr code.mn { font-family: var(--font-mono); font-size: 12.8px; background: var(--bg); border: 1px solid var(--line); border-radius: 5px; padding: 0 4px; white-space: nowrap; }
/* ---- scrubber + depth sparkline ---- */
.scrub { padding: 8px 12px 12px; border-top: 1px solid var(--line); }
.scrub .slab { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); display: flex; gap: 8px; align-items: baseline; }
.spark { display: block; width: 100%; height: 42px; margin-top: 4px; cursor: pointer; touch-action: none; }
.spark rect.b { fill: var(--bar); }
.spark rect.past { fill: var(--muted); opacity: .55; }
.spark rect.cur { fill: var(--accent); }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 20px; margin: 2px 0 0;
background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; background: var(--track); border-radius: 3px; }
input[type=range]::-moz-range-track { height: 4px; background: var(--track); border-radius: 3px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 15px; height: 15px; margin-top: -5.5px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); }
input[type=range]::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); }
/* ---- stats ---- */
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border-top: 1px solid var(--line); }
.stat { background: var(--surface); padding: 8px 12px; min-width: 0; }
.stat .lab { font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.stat .val { font-family: var(--font-mono); font-size: 16px; font-variant-numeric: tabular-nums; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; }
.stat .val.good { color: var(--run); }
.stat .val.pending { color: var(--muted); }
/* ---- layout ---- */
.grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,330px); gap: 14px; align-items: start; }
.grid > .panel { margin-top: 0; }
/* ---- source ---- */
.srcwrap { position: relative; max-height: 430px; overflow: auto; padding: 8px 0 10px; background: var(--canvas); }
.ln { display: grid; grid-template-columns: 30px 26px minmax(0,1fr); align-items: baseline; padding: 1px 10px 1px 0; }
.ln .num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); text-align: right; padding-right: 8px; font-variant-numeric: tabular-nums; }
.ln .mark { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); white-space: nowrap; letter-spacing: -.02em; }
.ln .mark .arrow { color: var(--accent); font-size: 12px; margin-right: 2px; }
.cnt { display: inline-block; min-width: 15px; text-align: center; font-size: 9.5px;
line-height: 14px; border-radius: 4px; background: var(--track); color: var(--muted); padding: 0 3px; }
.ln .code { font-family: var(--font-mono); font-size: 13px; line-height: 1.65; white-space: pre; overflow-x: auto; scrollbar-width: none; }
.ln .code::-webkit-scrollbar { display: none; }
.ln.paused { background: var(--ghost); }
.ln.cur { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
.ln.cur .num { color: var(--accent); opacity: 1; }
.t-kw { color: var(--accent); }
.t-num { color: var(--syn-num); }
.t-str { color: var(--syn-str); }
.t-fn { color: var(--ink); font-weight: 600; }
.t-com { color: var(--muted); font-style: italic; }
.t-pun { color: var(--muted); }
/* ---- stack ---- */
.stackwrap { position: relative; padding: 10px; background: var(--canvas); display: flex; flex-direction: column; min-height: 300px; overflow: auto; }
/* margin-top:auto pins the pile to the floor and, unlike justify-content:flex-end,
keeps the topmost frame reachable if the content ever outgrows the box. */
.stack { display: flex; flex-direction: column; gap: 6px; margin: auto 0 0; padding: 0; list-style: none; }
.frame {
border: 1px solid var(--line); border-radius: 8px; background: var(--surface);
padding: 7px 9px 8px; overflow: hidden;
}
.frame.top { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.frame.returning { border-color: var(--run); box-shadow: 0 0 0 2px var(--run-soft); }
.frame.script { background: var(--bg); border-style: dashed; }
.fhead { display: flex; align-items: center; gap: 7px; }
.fname { font-family: var(--font-mono); font-size: 13px; font-weight: 600; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.fhead .spacer { flex: 1 1 auto; }
.fbadge { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.fbadge.run { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.fbadge.ret { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.fdepth { font-family: var(--font-mono); font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
.fvars { margin-top: 5px; display: grid; gap: 2px; }
.vrow { display: grid; grid-template-columns: 38px minmax(0,1fr); gap: 6px; align-items: baseline; }
.vtag { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.vbody { font-family: var(--font-mono); font-size: 12.5px; min-width: 0; overflow-wrap: anywhere; }
.vk { color: var(--muted); }
.vv { color: var(--ink); font-weight: 600; border-radius: 4px; padding: 0 2px; }
.fret { margin-top: 5px; font-family: var(--font-mono); font-size: 12.5px; color: var(--run); background: var(--run-soft); border-radius: 5px; padding: 2px 6px; display: inline-block; }
.fnote { margin-top: 5px; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
.frame.ghost { opacity: .5; }
@keyframes frameIn { from { opacity: 0; transform: translateY(-12px) scale(.97); } to { opacity: 1; transform: none; } }
.frame.enter { animation: frameIn .26s cubic-bezier(.2,.7,.3,1); }
@keyframes valFlash { from { background: var(--accent-soft); } to { background: transparent; } }
.vv.flash { animation: valFlash .7s ease-out; }
.frame.ghost { transition: height .2s ease, opacity .2s ease, margin .2s ease, padding .2s ease; }
.hint { margin-top: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .03em; }
.hint kbd { font-family: var(--font-mono); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; background: var(--surface); color: var(--ink); font-size: 10.5px; }
.legend { font-family: var(--font-mono); font-size: 10px; color: var(--muted); letter-spacing: .03em; }
.legend .cnt { margin: 0 1px; }
footer { margin-top: 16px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 820px) {
.grid { grid-template-columns: minmax(0,1fr); }
.stackwrap { min-height: 210px; }
.srcwrap { max-height: 330px; }
.narr { min-height: 92px; }
}
@media (max-width: 430px) {
.wrap { padding: 16px 12px 24px; }
header h1 { font-size: 19px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.ln { grid-template-columns: 24px 22px minmax(0,1fr); }
.ln .code { font-size: 12px; }
.picker button { flex: 1 1 100%; }
/* narration wraps to 3–4 lines on a phone; reserve the room so the panels
below it don't jump every step during playback. */
.narr { min-height: 104px; }
.legend { display: none; }
.controls { gap: 6px; }
.tbtn { padding: 6px 8px; }
}
@media (prefers-reduced-motion: reduce) {
.frame.enter { animation: none; }
.vv.flash { animation: none; }
.frame.ghost { transition: none; }
* { scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="chip">call stack · step through</span>
<h1>Call Stack Stepper</h1>
<p class="lede">Three tiny programs, paused mid-run. Step through them line by line and watch the
call stack build up and peel back off — every frame carrying its own copy of the values.</p>
</header>
<div class="picker" id="picker" role="group" aria-label="Choose a program"></div>
<section class="panel" aria-label="Playback">
<div class="controls">
<button class="tbtn" id="restart" title="Back to the start (Home)" aria-label="Restart from the first step">⏮</button>
<button class="tbtn" id="back" title="One step back (Left arrow)" aria-label="One step back">◀</button>
<button class="primary" id="play" title="Play or pause (Space)" aria-label="Play">▶ Play</button>
<button class="tbtn" id="fwd" title="One step forward (Right arrow)" aria-label="One step forward">▶</button>
<button class="tbtn" id="end" title="Jump to the end (End)" aria-label="Jump to the last step">⏭</button>
<span class="spacer"></span>
<label class="sr" for="speed">Playback speed</label>
<select id="speed">
<option value="1150">0.5× slow</option>
<option value="620" selected>1× steady</option>
<option value="290">2× brisk</option>
</select>
<span class="readout" id="readout">step <b>0</b> / 0</span>
</div>
<div class="narr">
<span class="marker" id="marker">step</span>
<div class="txt" id="narration" aria-live="polite">Loading…</div>
</div>
<div class="scrub">
<div class="slab"><span>stack depth over time</span><span class="spacer" style="flex:1"></span><span id="peak"></span></div>
<svg class="spark" id="spark" preserveAspectRatio="none" aria-hidden="true"></svg>
<input type="range" id="scrubber" min="0" max="0" step="1" value="0" aria-label="Scrub through the steps">
</div>
<div class="stats">
<div class="stat"><div class="lab">step</div><div class="val" id="s-step">0 / 0</div></div>
<div class="stat"><div class="lab">frames on stack</div><div class="val" id="s-depth">1</div></div>
<div class="stat"><div class="lab">calls made</div><div class="val" id="s-calls">0</div></div>
<div class="stat"><div class="lab">result</div><div class="val pending" id="s-result">running…</div></div>
</div>
</section>
<div class="grid">
<section class="panel" aria-label="Source code">
<div class="phead">
<h2>source</h2>
<span class="legend">▸ current line · <span class="cnt">n</span> frames paused here</span>
<span class="spacer"></span>
<span class="pill" id="linepill">line 1</span>
</div>
<div class="srcwrap" id="srcwrap"><div id="src"></div></div>
</section>
<section class="panel" aria-label="Call stack">
<div class="phead">
<h2>call stack</h2>
<span class="spacer"></span>
<span class="pill" id="stackpill">1 frame</span>
</div>
<div class="stackwrap" id="stackwrap">
<ul class="stack" id="stack"></ul>
</div>
</section>
</div>
<p class="hint"><kbd>space</kbd> play/pause · <kbd>←</kbd> <kbd>→</kbd> step ·
<kbd>home</kbd> <kbd>end</kbd> jump · <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> pick a program</p>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ---------- theme bridge (platform postMessage + standalone media query) ---------- */
window.addEventListener("message", function (e) {
var d = e && e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
var $ = function (id) { return document.getElementById(id); };
var SVG_NS = "http://www.w3.org/2000/svg";
var mqReduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduce = function () { return !!(mqReduce && mqReduce.matches); };
function esc(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function fmt(v) {
if (v === undefined) return "—";
if (typeof v === "string") return '"' + v + '"';
return String(v);
}
/* narration: backticks become mono chips */
function noteHTML(s) {
var parts = String(s).split("`"), out = "", i;
for (i = 0; i < parts.length; i++) {
out += (i % 2) ? '<code class="mn">' + esc(parts[i]) + "</code>" : esc(parts[i]);
}
return out;
}
function notePlain(s) { return String(s).replace(/`/g, ""); }
/* ================= tracer ================= */
function Tracer() {
this.steps = [];
this.stack = [];
this.uid = 0;
this.opened = 0;
}
Tracer.prototype.top = function () { return this.stack[this.stack.length - 1]; };
Tracer.prototype.pushFrame = function (fn, args, line, isScript) {
var f = {
id: ++this.uid, fn: fn, args: args.slice(), locals: [],
line: line, ret: null, script: !!isScript
};
this.stack.push(f);
if (!isScript) this.opened++;
return f;
};
Tracer.prototype.snap = function (line, note, kind) {
var frames = this.stack.map(function (f) {
return {
id: f.id, fn: f.fn, script: f.script, line: f.line,
args: f.args.map(function (a) { return [a[0], a[1]]; }),
locals: f.locals.map(function (a) { return [a[0], a[1]]; }),
ret: f.ret ? { v: f.ret.v } : null
};
});
this.steps.push({
line: line, note: note, kind: kind || "line",
frames: frames, depth: frames.length, opened: this.opened
});
};
Tracer.prototype.step = function (line, note, kind) {
if (this.stack.length) this.top().line = line;
this.snap(line, note, kind || "line");
};
Tracer.prototype.setLocal = function (name, val) {
var f = this.top(), i;
for (i = 0; i < f.locals.length; i++) {
if (f.locals[i][0] === name) { f.locals[i][1] = val; return; }
}
f.locals.push([name, val]);
};
Tracer.prototype.pop = function (val, line, note) {
var f = this.top();
f.line = line;
f.ret = { v: val };
this.snap(line, note, "return");
this.stack.pop();
return val;
};
Tracer.prototype.finish = function (val, line, note) {
var f = this.top();
f.line = line;
f.ret = { v: val };
this.snap(line, note, "done");
return val;
};
/* ================= the three programs ================= */
function factorialTrace(t, n, first) {
t.pushFrame("factorial", [["n", n]], 1);
t.step(1, first
? "Enter `factorial` with `n = 4`. One frame, holding one `n`."
: "Enter `factorial` again, now with `n = " + n + "`. This frame gets its own `n` — the one below is untouched.",
"call");
if (n <= 1) {
t.step(2, "Line 2 asks: is `n` 1 or less? Yes, `n` is `" + n + "`.");
return t.pop(1, 3, "The base case returns `1` without calling anything else. From here the stack only shrinks.");
}
t.step(2, "Line 2 asks: is `n` 1 or less? No, `n` is `" + n + "`.");
t.step(5, "Line 5 needs the answer to `factorial(" + (n - 1) + ")` first, so this frame freezes right here and a deeper one opens.");
var s = factorialTrace(t, n - 1, false);
t.setLocal("smaller", s);
t.step(5, "Back in `factorial(" + n + ")`, exactly where it froze. The `" + s + "` that came back is stored in `smaller`.", "resume");
t.step(6, "Line 6 multiplies: `" + n + " * " + s + "` is `" + (n * s) + "`.");
return t.pop(n * s, 6, "`factorial(" + n + ")` returns `" + (n * s) + "` down to the frame underneath it.");
}
function mutualTrace(t, name, n, first) {
var isEven = name === "isEven";
var other = isEven ? "isOdd" : "isEven";
var defLine = isEven ? 1 : 6;
var testLine = defLine + 1;
var callLine = defLine + 2;
var baseVal = isEven;
t.pushFrame(name, [["n", n]], defLine);
t.step(defLine, first
? "Enter `isEven` with `n = 4`. First frame on the stack."
: "Enter `" + name + "` with `n = " + n + "`. A frame for the other function this time — they take turns.",
"call");
if (n === 0) {
t.step(testLine, "Line " + testLine + " asks: is `n` 0? Yes — and that settles the whole thing.");
return t.pop(baseVal, testLine, (isEven
? "Zero is even, so `isEven(0)` returns `true`."
: "Zero is not odd, so `isOdd(0)` returns `false`.") + " The real answer is known now; the rest is just relaying it.");
}
t.step(testLine, "Line " + testLine + " asks: is `n` 0? No, `n` is `" + n + "`.");
t.step(callLine, "`" + name + "(" + n + ")` cannot answer on its own, so it asks `" + other + "(" + (n - 1) + ")` and waits.");
var v = mutualTrace(t, other, n - 1, false);
t.step(callLine, "Back in `" + name + "(" + n + ")` with `" + v + "` in hand.", "resume");
return t.pop(v, callLine, "`" + name + "(" + n + ")` passes `" + v + "` straight up, unchanged.");
}
var PROGRAMS = [
{
id: "nested",
name: "Nested calls",
sig: "checkout(12, 3)",
mainLine: 17,
src: [
"function checkout(price, tip) {",
" const total = add(price, tip);",
" const cents = toCents(total);",
" return cents;",
"}",
"",
"function add(a, b) {",
" const sum = a + b;",
" return sum;",
"}",
"",
"function toCents(amount) {",
" return amount * 100;",
"}",
"",
"// the program starts here",
"checkout(12, 3);"
],
build: function (t) {
t.step(17, "The program really starts at the bottom: line 17 calls `checkout(12, 3)`. Everything above it is just definitions sitting there.", "start");
t.pushFrame("checkout", [["price", 12], ["tip", 3]], 1);
t.step(1, "Enter `checkout`. A fresh frame goes on top of the stack, holding its own copies of `price` and `tip`.", "call");
t.step(2, "Line 2 cannot finish until `add(12, 3)` answers, so `checkout` pauses exactly here.");
t.pushFrame("add", [["a", 12], ["b", 3]], 7);
t.step(7, "Enter `add`. Its `a` and `b` are separate boxes from the ones in `checkout` — same numbers, different frame.", "call");
t.step(8, "Line 8 is about to add them. So far this frame holds nothing but its two arguments.");
t.setLocal("sum", 15);
t.step(9, "`a + b` came out as `15`, so a new local called `sum` appears in the frame to hold it.");
t.pop(15, 9, "`add` returns `15`. Its frame is thrown away, and `a`, `b` and `sum` disappear with it.");
t.setLocal("total", 15);
t.step(2, "Back in `checkout`, right where it paused. The returned `15` lands in `total`.", "resume");
t.step(3, "Line 3 needs `toCents(15)`. Pause again, call again.");
t.pushFrame("toCents", [["amount", 15]], 12);
t.step(12, "Enter `toCents`. Two frames deep, plus the script itself.", "call");
t.pop(1500, 13, "Line 13 works out `15 * 100` and returns `1500` in one move. That frame pops.");
t.setLocal("cents", 1500);
t.step(3, "Back in `checkout` for the second time. `cents` is now `1500`.", "resume");
t.step(4, "Line 4 hands `cents` back to whoever called `checkout`.");
t.pop(1500, 4, "`checkout` returns `1500` and pops. Only the script frame is left.");
t.finish(1500, 17, "Finished. `checkout(12, 3)` is `1500` — fifteen dollars, written in cents.");
}
},
{
id: "factorial",
name: "Recursion",
sig: "factorial(4)",
mainLine: 10,
src: [
"function factorial(n) {",
" if (n <= 1) {",
" return 1;",
" }",
" const smaller = factorial(n - 1);",
" return n * smaller;",
"}",
"",
"// 4 * 3 * 2 * 1",
"factorial(4);"
],
build: function (t) {
t.step(10, "Line 10 calls `factorial(4)`. A function that calls itself: watch the stack grow all the way down, then unwind all the way back up.", "start");
factorialTrace(t, 4, true);
t.finish(24, 10, "Finished. `factorial(4)` is `24`. Four frames opened, four frames closed, strictly last-in first-out.");
}
},
{
id: "mutual",
name: "Mutual recursion",
sig: "isEven(4)",
mainLine: 11,
src: [
"function isEven(n) {",
" if (n === 0) return true;",
" return isOdd(n - 1);",
"}",
"",
"function isOdd(n) {",
" if (n === 0) return false;",
" return isEven(n - 1);",
"}",
"",
"isEven(4);"
],
build: function (t) {
t.step(11, "Line 11 asks `isEven(4)`. Neither function calls itself — they hand the question back and forth, one step smaller each time.", "start");
mutualTrace(t, "isEven", 4, true);
t.finish(true, 11, "Finished. `isEven(4)` is `true`. The answer was decided at the very bottom and relayed up through five frames.");
}
}
];
/* build every trace once, up front */
PROGRAMS.forEach(function (p) {
var t = new Tracer();
t.pushFrame("script", [], p.mainLine, true);
p.build(t);
p.steps = t.steps;
p.maxDepth = t.steps.reduce(function (m, s) { return Math.max(m, s.depth); }, 1);
p.result = t.steps[t.steps.length - 1].frames[0].ret;
});
/* ================= syntax highlighting ================= */
var KW = (function () {
var o = Object.create(null);
"function const let var return if else true false null".split(" ").forEach(function (k) { o[k] = 1; });
return o;
})();
function highlight(line) {
var re = /(\/\/[^\n]*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(\d+(?:\.\d+)?)|([A-Za-z_$][A-Za-z0-9_$]*)|(\s+)|([^\s])/g;
var out = "", m;
while ((m = re.exec(line)) !== null) {
if (m[1]) out += '<span class="t-com">' + esc(m[1]) + "</span>";
else if (m[2]) out += '<span class="t-str">' + esc(m[2]) + "</span>";
else if (m[3]) out += '<span class="t-num">' + esc(m[3]) + "</span>";
else if (m[4]) {
if (KW[m[4]]) out += '<span class="t-kw">' + esc(m[4]) + "</span>";
else if (/^\s*\(/.test(line.slice(re.lastIndex))) out += '<span class="t-fn">' + esc(m[4]) + "</span>";
else out += esc(m[4]);
}
else if (m[5]) out += m[5];
else out += '<span class="t-pun">' + esc(m[6]) + "</span>";
}
return out || " ";
}
function sigOf(f) {
if (f.script) return "script";
return f.fn + "(" + f.args.map(function (a) { return fmt(a[1]); }).join(", ") + ")";
}
/* ================= dom refs / state ================= */
var pickerEl = $("picker"), srcEl = $("src"), srcWrap = $("srcwrap");
var stackEl = $("stack"), stackWrap = $("stackwrap");
var narrEl = $("narration"), markerEl = $("marker");
var sparkEl = $("spark"), scrubEl = $("scrubber");
var readoutEl = $("readout"), peakEl = $("peak");
var linePill = $("linepill"), stackPill = $("stackpill");
var sStep = $("s-step"), sDepth = $("s-depth"), sCalls = $("s-calls"), sResult = $("s-result");
var btnPlay = $("play"), btnBack = $("back"), btnFwd = $("fwd"), btnRestart = $("restart"), btnEnd = $("end");
var speedEl = $("speed");
var prog = null, idx = 0, playing = false, timer = null;
var lineEls = [], rects = [], ghosts = [], prevIds = null, prevVals = null;
var suppressAnim = false;
/* ================= program picker ================= */
PROGRAMS.forEach(function (p, i) {
var b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-pressed", "false");
b.innerHTML = '<span class="pk-name">' + esc((i + 1) + ". " + p.name) + '</span>' +
'<span class="pk-sig">' + esc(p.sig) + " · " + p.steps.length + " steps</span>";
b.addEventListener("click", function () { load(i, true); });
p.btn = b;
pickerEl.appendChild(b);
});
/* ================= renderers ================= */
function buildSource(p) {
var html = "", i;
for (i = 0; i < p.src.length; i++) {
html += '<div class="ln" data-line="' + (i + 1) + '">' +
'<span class="num">' + (i + 1) + "</span>" +
'<span class="mark"></span>' +
'<span class="code">' + highlight(p.src[i]) + "</span>" +
"</div>";
}
srcEl.innerHTML = html;
lineEls = Array.prototype.slice.call(srcEl.children);
}
function buildSpark(p) {
var n = p.steps.length, H = p.maxDepth, i, r;
while (sparkEl.firstChild) sparkEl.removeChild(sparkEl.firstChild);
sparkEl.setAttribute("viewBox", "0 0 " + n + " " + H);
rects = [];
for (i = 0; i < n; i++) {
r = document.createElementNS(SVG_NS, "rect");
r.setAttribute("x", i + 0.08);
r.setAttribute("width", 0.84);
r.setAttribute("y", H - p.steps[i].depth);
r.setAttribute("height", p.steps[i].depth);
r.setAttribute("class", "b");
sparkEl.appendChild(r);
rects.push(r);
}
peakEl.textContent = "peak " + H + " frames";
}
function keepInView(cont, el) {
if (!el) return;
var ct = cont.scrollTop, ch = cont.clientHeight, et = el.offsetTop, eh = el.offsetHeight;
if (et < ct) cont.scrollTop = Math.max(0, et - 6);
else if (et + eh > ct + ch) cont.scrollTop = et + eh - ch + 6;
}
function renderSource(st) {
var counts = {}, i, f, top = st.frames.length - 1;
for (i = 0; i < top; i++) {
f = st.frames[i];
counts[f.line] = (counts[f.line] || 0) + 1;
}
for (i = 0; i < lineEls.length; i++) {
var el = lineEls[i], ln = i + 1, mark = "";
var cur = (ln === st.line), c = counts[ln] || 0;
el.className = "ln" + (cur ? " cur" : "") + (c > 0 && !cur ? " paused" : "");
if (cur) mark += '<span class="arrow">▸</span>';
if (c > 0) mark += '<span class="cnt">' + c + "</span>";
el.children[1].innerHTML = mark;
el.title = c > 0
? c + (c === 1 ? " frame is" : " frames are") + " paused on this line"
: (cur ? "the line the top frame is on" : "");
}
linePill.textContent = "line " + st.line;
keepInView(srcWrap, lineEls[st.line - 1]);
}
function frameHTML(f, isTop, depthNo, above, newVals) {
var cls = "frame" + (f.script ? " script" : "") +
(f.ret ? " returning" : (isTop ? " top" : ""));
var badge = f.ret
? '<span class="fbadge ret">' + (f.script ? "result" : "returned") + "</span>"
: (isTop ? '<span class="fbadge run">running</span>' : '<span class="fbadge">paused</span>');
var h = '<li class="' + cls + '" data-id="' + f.id + '">' +
'<div class="fhead"><span class="fname">' + esc(f.fn) + "</span>" +
'<span class="spacer"></span>' + badge +
'<span class="fdepth">#' + depthNo + "</span></div>";
var rows = "", i, kind, list;
for (kind = 0; kind < 2; kind++) {
list = kind === 0 ? f.args : f.locals;
for (i = 0; i < list.length; i++) {
var key = f.id + ":" + kind + ":" + list[i][0];
var txt = fmt(list[i][1]);
newVals[key] = txt;
var flash = prevVals && (key in prevVals) && prevVals[key] !== txt && !reduce();
rows += '<div class="vrow"><span class="vtag">' + (kind === 0 ? "arg" : "local") + "</span>" +
'<span class="vbody"><span class="vk">' + esc(list[i][0]) + ' = </span>' +
'<span class="vv' + (flash ? " flash" : "") + '">' + esc(txt) + "</span></span></div>";
}
}
if (rows) h += '<div class="fvars">' + rows + "</div>";
if (f.ret) {
h += '<div class="fret">' + (f.script ? "result " : "returns ") + esc(fmt(f.ret.v)) + "</div>";
} else if (isTop) {
h += '<div class="fnote">' + (f.script ? "the file itself · line " : "at line ") + f.line + "</div>";
} else {
h += '<div class="fnote">line ' + f.line + " · waiting on " + esc(sigOf(above)) + "</div>";
}
return h + "</li>";
}
function renderStack(st) {
ghosts.forEach(function (g) { if (g.parentNode) g.parentNode.removeChild(g); });
ghosts = [];
var old = {};
Array.prototype.forEach.call(stackEl.children, function (el) {
old[el.getAttribute("data-id")] = { h: el.offsetHeight, html: el.innerHTML, cls: el.className };
});
var frames = st.frames, newVals = Object.create(null), ids = {}, html = "", i;
for (i = frames.length - 1; i >= 0; i--) {
ids[frames[i].id] = true;
html += frameHTML(frames[i], i === frames.length - 1, i, frames[i + 1] || null, newVals);
}
stackEl.innerHTML = html;
prevVals = newVals;
if (!reduce() && !suppressAnim) {
/* frames that just appeared: slide in */
Array.prototype.forEach.call(stackEl.children, function (el) {
var id = el.getAttribute("data-id");
if (prevIds && !prevIds[id]) {
el.classList.add("enter");
setTimeout(function () { el.classList.remove("enter"); }, 320);
}
});
/* frames that just popped: collapse a ghost of them */
Object.keys(old).forEach(function (id) {
if (ids[id]) return;
var g = document.createElement("li");
g.className = old[id].cls + " ghost";
g.innerHTML = old[id].html;
g.style.height = old[id].h + "px";
g.style.overflow = "hidden";
stackEl.insertBefore(g, stackEl.firstChild);
ghosts.push(g);
requestAnimationFrame(function () {
g.style.height = "0px";
g.style.opacity = "0";
g.style.paddingTop = "0px";
g.style.paddingBottom = "0px";
});
setTimeout(function () {
var k = ghosts.indexOf(g);
if (k >= 0) ghosts.splice(k, 1);
if (g.parentNode) g.parentNode.removeChild(g);
}, 230);
});
}
prevIds = ids;
stackPill.textContent = frames.length + (frames.length === 1 ? " frame" : " frames");
stackWrap.scrollTop = 0;
}
var MARKERS = {
start: ["start", ""], call: ["call", "call"], line: ["step", ""],
resume: ["back", ""], "return": ["return", "ret"], done: ["done", "done"]
};
function render() {
var st = prog.steps[idx], last = prog.steps.length - 1;
var mk = MARKERS[st.kind] || MARKERS.line;
markerEl.textContent = mk[0];
markerEl.className = "marker" + (mk[1] ? " " + mk[1] : "");
narrEl.innerHTML = noteHTML(st.note);
renderSource(st);
renderStack(st);
readoutEl.innerHTML = "step <b>" + (idx + 1) + "</b> / " + prog.steps.length;
sStep.textContent = (idx + 1) + " / " + prog.steps.length;
sDepth.textContent = String(st.depth);
sCalls.textContent = String(st.opened);
if (st.kind === "done") {
sResult.textContent = fmt(prog.result.v);
sResult.className = "val good";
} else {
sResult.textContent = "running…";
sResult.className = "val pending";
}
for (var i = 0; i < rects.length; i++) {
rects[i].setAttribute("class", i === idx ? "b cur" : (i < idx ? "b past" : "b"));
}
scrubEl.value = String(idx);
scrubEl.setAttribute("aria-valuetext", "step " + (idx + 1) + " of " + prog.steps.length + ": " + notePlain(st.note));
btnBack.disabled = idx === 0;
btnRestart.disabled = idx === 0;
btnFwd.disabled = idx === last;
btnEnd.disabled = idx === last;
}
function goTo(i) {
var last = prog.steps.length - 1;
idx = i < 0 ? 0 : (i > last ? last : i);
render();
}
/* ================= playback ================= */
function setPlayLabel() {
btnPlay.innerHTML = playing ? "⏸ Pause" : "▶ Play";
btnPlay.setAttribute("aria-label", playing ? "Pause" : "Play");
}
function stop() {
if (timer) { clearTimeout(timer); timer = null; }
if (playing) { playing = false; setPlayLabel(); }
}
function tick() {
timer = null;
if (!playing) return;
if (idx >= prog.steps.length - 1) { stop(); return; }
goTo(idx + 1);
if (idx >= prog.steps.length - 1) { stop(); return; }
timer = setTimeout(tick, +speedEl.value);
}
function play() {
if (playing) return;
if (idx >= prog.steps.length - 1) goTo(0);
playing = true;
setPlayLabel();
timer = setTimeout(tick, Math.min(420, +speedEl.value));
}
function toggle() { if (playing) stop(); else play(); }
btnPlay.addEventListener("click", toggle);
btnFwd.addEventListener("click", function () { stop(); goTo(idx + 1); });
btnBack.addEventListener("click", function () { stop(); goTo(idx - 1); });
btnRestart.addEventListener("click", function () { stop(); goTo(0); });
btnEnd.addEventListener("click", function () { stop(); goTo(prog.steps.length - 1); });
speedEl.addEventListener("change", function () {
if (playing && timer) { clearTimeout(timer); timer = setTimeout(tick, +speedEl.value); }
});
scrubEl.addEventListener("input", function () { stop(); goTo(+scrubEl.value); });
/* sparkline doubles as a scrubber */
var dragging = false;
function pickFromEvent(e) {
var r = sparkEl.getBoundingClientRect();
if (!r.width) return;
var f = (e.clientX - r.left) / r.width;
goTo(Math.floor(f * prog.steps.length));
}
sparkEl.addEventListener("pointerdown", function (e) {
dragging = true; stop();
if (sparkEl.setPointerCapture) { try { sparkEl.setPointerCapture(e.pointerId); } catch (err) {} }
pickFromEvent(e); e.preventDefault();
});
sparkEl.addEventListener("pointermove", function (e) { if (dragging) pickFromEvent(e); });
/* released on window too: if pointer capture is unavailable and the finger
lifts outside the strip, the drag must not stay stuck on. */
function endDrag() { dragging = false; }
sparkEl.addEventListener("pointerup", endDrag);
sparkEl.addEventListener("pointercancel", endDrag);
window.addEventListener("pointerup", endDrag);
window.addEventListener("pointercancel", endDrag);
/* ================= keyboard ================= */
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target || {}, tag = (t.tagName || "").toUpperCase();
var typing = tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
var k = e.key;
if (k === " " || k === "Spacebar") {
if (typing || tag === "BUTTON") return; /* let the focused control handle it */
e.preventDefault(); toggle(); return;
}
if (k === "ArrowRight") { if (typing) return; e.preventDefault(); stop(); goTo(idx + 1); return; }
if (k === "ArrowLeft") { if (typing) return; e.preventDefault(); stop(); goTo(idx - 1); return; }
if (k === "Home") { if (typing) return; e.preventDefault(); stop(); goTo(0); return; }
if (k === "End") { if (typing) return; e.preventDefault(); stop(); goTo(prog.steps.length - 1); return; }
if (k === "1" || k === "2" || k === "3") {
if (typing) return;
e.preventDefault(); load(+k - 1, true);
}
});
/* The stack box is sized once to the tallest the pile ever gets in this program,
so the floor stays put while frames pile on and peel off instead of the whole
page growing and shrinking under the reader. Measured, not guessed: frame
heights depend on how many locals they hold and how the text wraps. */
function reserveStackHeight() {
var last = prog.steps.length - 1;
var keep = idx > last ? last : (idx < 0 ? 0 : idx), tallest = 0, i;
suppressAnim = true;
stackWrap.style.minHeight = "0px";
for (i = 0; i < prog.steps.length; i++) {
renderStack(prog.steps[i]);
if (stackEl.scrollHeight > tallest) tallest = stackEl.scrollHeight;
}
stackWrap.style.minHeight = (tallest + 20) + "px"; /* + .stackwrap padding */
prevIds = null; prevVals = null;
idx = keep;
renderStack(prog.steps[idx]);
suppressAnim = false;
}
var reflow = null;
window.addEventListener("resize", function () {
if (!prog) return;
if (reflow) clearTimeout(reflow);
reflow = setTimeout(function () { reflow = null; reserveStackHeight(); }, 160);
});
/* ================= load a program ================= */
function load(i, autoplay) {
stop();
prog = PROGRAMS[i];
PROGRAMS.forEach(function (p) { p.btn.setAttribute("aria-pressed", p === prog ? "true" : "false"); });
prevIds = null; prevVals = null;
ghosts.forEach(function (g) { if (g.parentNode) g.parentNode.removeChild(g); });
ghosts = [];
stackEl.innerHTML = "";
buildSource(prog);
buildSpark(prog);
scrubEl.max = String(prog.steps.length - 1);
reserveStackHeight();
prevIds = null; prevVals = null;
if (reduce()) {
/* no auto-motion: land on the most interesting frame instead */
var deep = 0;
for (var k = 0; k < prog.steps.length; k++) {
if (prog.steps[k].depth > prog.steps[deep].depth) deep = k;
}
goTo(deep);
} else {
goTo(0);
if (autoplay) play();
}
}
setPlayLabel();
load(0, true);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.