eve-hub 0.16.35 → 0.16.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.35",
3
+ "version": "0.16.37",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build-onboarding.mjs from deep-dive/. Do not edit.
2
- export default {"description":"How to build a deep-dive explainer page for a code path (a call stack, a dataflow or a protocol) from a recorded run of the real code. Load when someone asks for a tech explainer, a walkthrough of how a flow works, a stepped repro of a bug, or a page that shows what a PR changes in a flow.","content":"# Deep-dive explainer pages\n\nA deep-dive page explains one flow in real code by stepping through a recorded run of it. Every step is a breakpoint pause: its call stack, its variables, the rows its stores hold and the requests in flight. A note per step says what happens there in plain words. The page is one HTML file with no JavaScript. It works in the Mac doc pane and under 3 MiB.\n\nThe kit is the files next to this one:\n\n- `record.mjs` runs the topic's scenario under the V8 inspector and writes the recording.\n- `render.mjs` turns the topic, the recording and the notes into the page.\n- `check.mjs` fails when a note or the topic's authored text says something the recording does not show.\n- `carry-notes.mjs` carries notes from one recording to another of the same topic, for a two-recording page.\n- `shot.mjs` screenshots a page at a step, a level and a width, so you can look at it.\n- `pin.mjs` makes a recording repeat its ids and times, for a topic with `pin` (step 3).\n- `lib.mjs` holds what the others share.\n\nInstall the render dependencies once, in this skill's directory. Its `package.json` declares them at exact versions:\n\n```\nnpm i --prefix \"$SKILL_DIR\"\nnpx --prefix \"$SKILL_DIR\" playwright install --with-deps chromium\n```\n\n`$SKILL_DIR` is the directory this file is in (`$HOME/.agents/skills/deep-dive`). `record.mjs` and `check.mjs` need only Node 24. `shiki` and `geist` are for `render.mjs`, and `playwright` is for `shot.mjs` only. `render.mjs` also downloads Vercel's Geist foundation stylesheet, `https://vercel.com/geist/vercel-brand.css`, and inlines it, so it needs network access. The page's own styles use only that stylesheet's tokens and type sizes, so the page follows Geist in light and dark.\n\n## Who the page is for\n\nA page is done when a cold critic, a fresh session that has never seen the code, passes all five story checks on it:\n\n| # | Reader | Check |\n|---|---|---|\n| 1 | New engineer | After 5 minutes, page closed, draws the actors and crossings and names the stores written. 4 of 5 answers right. |\n| 2 | Debugger | Reaches the write site and the deciding rule from page open in under 60 seconds. |\n| 3 | PR reviewer | Given base and head recordings, the page marks every step whose calls, rows or values differ. |\n| 4 | The human | Each invariant is stated in one sentence, with `file:line`, linked to the step that enforces it, before any data. |\n| 5 | Authoring bot | A new topic renders with no renderer edits, and every note's `file:line` and value matches the recording. |\n\nA second cold reader lists every question the page raises. Fewer than 40 is the bar.\n\n## The method\n\nDo these in order. Each one is cheap to redo, and each later step depends on the earlier ones being right.\n\n### 1. Read the code and pick actors and rules first\n\nBefore writing anything, read the flow end to end and write down two lists.\n\n- **Actors.** The processes, modules, stores and fakes that take part. Each is one lifeline on the page. Name them as the code does.\n- **Invariants.** The rules this flow keeps, each one sentence with the `file:line` that enforces it. A bug is stated as the rule the code breaks, with the line that breaks it. Never label a rule \"Broken:\".\n\nThese two lists are the page's skeleton. If you can't name the rules, you don't understand the flow yet, and no amount of recording will fix that.\n\n### 2. Write the scenario\n\nThe scenario is a module that drives one run of the flow through the real code:\n\n```js\nexport async function scenario(mark, { root }) {\n const { thing } = await import(`${root}/src/thing.ts`);\n mark('ready', { file: dbPath }); // passed to the stores reader\n await thing(input);\n mark('done', { result });\n}\n```\n\n- Import the code under test from `root`, so the same scenario records against another checkout (`--root`).\n- Fake only at the network edge: a fake token service, an in-memory Cosmos, a recording broker. The real code runs everywhere else. Put fakes in the topic folder, and say on the page (in `story` and the fake actor's `about`) which parts are fakes.\n- Make it deterministic. Stub `crypto.randomUUID` and clocks so two recordings of the same code match.\n- When a package can't be installed in the sandbox, map its import to a stub with a loader hook (`module.registerHooks`) and pass the hook with `import` in the topic or `--import`. A stub that is asked for a case it doesn't model throws \"not modelled\"; it never silently returns.\n\n### 3. Write `topic.json`\n\nPaths are relative to the topic file's directory.\n\n```json\n{\n \"title\": \"How a post reaches a bot\",\n \"root\": \"../../hosted-hub\",\n \"scenario\": \"scenario.ts\",\n \"stores\": \"stores.mjs\",\n \"import\": \"stubs/register.mjs\",\n \"points\": [{ \"id\": \"p1\", \"file\": \"src/api/workspace.ts\", \"line\": 348, \"watch\": [\"req.url\"] }],\n \"actors\": [{ \"name\": \"hub\", \"kind\": \"process\", \"role\": \"server\", \"frames\": \"^src/\", \"urls\": \"/api/\", \"about\": \"...\" }],\n \"invariants\": [{ \"rule\": \"...\", \"file\": \"src/store/messages.ts\", \"line\": 84, \"point\": \"p5\", \"hit\": 0, \"values\": [] }],\n \"chapters\": [{ \"title\": \"...\", \"from\": \"p1\", \"values\": [] }],\n \"story\": \"...\", \"storyValues\": [],\n \"outcome\": \"...\", \"outcomeValues\": [],\n \"glossary\": [{ \"term\": \"clientId\", \"def\": \"...\" }]\n}\n```\n\n- **`points`.** One breakpoint per moment worth a step. A point in a file outside `root` starts with `./` (the scenario's own lines). `watch` expressions are evaluated at the pause and shown in the inspector. A list of SQL statements (`{sql}` items) in a watch is drawn as a batch sent to the first store.\n- **`actors`.** `frames` is a regex over the frame's path relative to `root`, and `urls` matches the requests it serves. `kind` is `process`, `module` or `store`. `role` is the short label under its name, and `about` is one sentence for the cast table. An actor with `key` (a JS expression, such as `sessionId`) gets one lifeline per value, for two sessions running at once.\n- **`stores`.** A module exporting `rows(ready)`. It gets the `ready` mark's data and returns `{ table: rows[] }`, read-only. The page diffs rows between steps and shows each write. A topic with no database leaves it out.\n- **`invariants`.** `point` and `hit` (0-based) choose the step that enforces the rule. The cited line must sit inside that step's source window (the paused line plus or minus 4), or `check.mjs` fails.\n- **`chapters`.** Each starts at a point (`from`, with an optional `hit`). Titles are one sentence about what happens, not a function name.\n- **`story`** is the start view's first paragraph: what the flow is for, who starts it, what the reader will watch happen, and which parts are fakes. **`outcome`** closes the chapter list with what the run ended with.\n- **`values`** on any authored text (`storyValues`, `outcomeValues`, a chapter's or an invariant's `values`) list the run values it states, as `{ name, value }`. `check.mjs` fails when the recording doesn't have them, and the head page of a two-recording pair tags text written for the base run.\n- **`pin`** and **`generated`**, for a topic that gets a base and a head recording. With `\"pin\": true` the recorder seeds random bytes, UUIDs and `Math.random` and starts the clock at a fixed instant that moves 1 ms per read, so two runs of the same code write the same ids and times. A diff compares values exactly. Anything that still differs per run, such as a temp directory the code makes with `mkdtemp`, goes in `generated` as a regex, and its matches compare equal: `\"generated\": [\"/tmp/app-[A-Za-z0-9]{6}\"]`. Under `pin`, a duration counts clock reads, so a note never presents it as time.\n- **`glossary`.** Every backticked name in a chapter title or an invariant is a glossary term or a function in the recording. Terms open in place where they appear.\n\n### 4. Record\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording.json\n```\n\nIt prints the points that were never hit and the breakpoints V8 moved to another line (a breakpoint inside a multi-line statement pauses at the statement's end). Move points until every one is hit where you meant. The first step's paused function is the page's entry: frames outside it are left out of the diagram, so make step 1 a pause in the outermost function you want drawn.\n\nThe recorder also counts each function's entries and returns around its points, and every pause stores them as `call`. The inspector shows \"Since step N, the last pause in this call\" only when no new call of the function started between the two pauses and only one call of it was running. Otherwise the step shows no Before column. A second call from the same line, or two async calls running at once, never share one.\n\nThe recording stores `provenance` (the commit, the files changed against it, the scenario, the date and the pause count) and `sources`, the text of every file a step pauses in or a rule cites. `check.mjs` and `render.mjs` read source from there, so a recording checks and renders without the checkout it came from.\n\n### 5. Write the notes\n\n`notes.json` has one note per step: `{ point, hit, text, refs: [{ file, line }], values: [{ name, value }] }`.\n\n- Say what happens at this step and why, in plain words, with the values that decide it. \"`prepareMessage` tests `existing` at `src/store/messages.ts:84`. It is undefined, so the post is written as new.\"\n- Every `file:line` in the text is in `refs`, and every ref is a frame of this step or a breakpoint that was hit.\n- Every value the text states is in `values`, named by its scope path (`posted.to`), watch expression (`queue.length`) or row cell (`messages.kind`). `check.mjs` compares each one with the recording.\n\n### 6. Check, render and look\n\n```\nnode \"$SKILL_DIR/check.mjs\" topic.json recording.json notes.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording.json notes.json page.html\nnode \"$SKILL_DIR/shot.mjs\" page.html start.png 1040 light\nnode \"$SKILL_DIR/shot.mjs\" page.html seq.png 1040 light '#step-9' seq\n```\n\nFix every check failure at its source: a wrong note, a point on the wrong line, a rule cited outside its step. Never loosen a note until it passes. Then open the screenshots yourself: the start view, the sequence level at a busy step, the steps level, and dark mode. The page fits 1040 px with no horizontal scroll, and nothing overlaps.\n\n### 7. Two recordings, for a PR\n\nTo show what a change does to the flow, set `pin` in the topic (see step 3), record the base, record the same topic against the head checkout and carry the notes. Before the head, record the base a second time and render it with `--base recording.json`: the banner must say no step differs. A step that differs between two runs of the same code names a value to add to `generated`.\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording-head.json --root /path/to/head\nnode \"$SKILL_DIR/carry-notes.mjs\" recording.json notes.json recording-head.json > notes-head.json\nnode \"$SKILL_DIR/check.mjs\" topic.json recording-head.json notes-head.json --base recording.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording-head.json notes-head.json pr.html --base recording.json\n```\n\n`carry-notes.mjs` gives each note's values the head run's values and replaces each changed value where the note shows it in backticks. Reread every carried note at a step the page marks as differing, because a sentence that explains why a value was set can stop being true. The page marks each step whose calls, rows or values differ, and tags authored text whose `values` held only in the base run.\n\n### 8. Hand it to cold readers\n\nSend the page to two fresh sessions that have never seen the code. Give the critic the five story checks above and ask for pass or fail, the time taken and the answers given. Ask the reader to list every question the page raises. Fix what they find in the topic and the notes, re-render and send it again until all five pass and the reader lists fewer than 40 questions.\n\n## Register and layout checklist\n\n- Every sentence on the page is a whole sentence in plain words. No arrows, no `≠`, no em dashes, no \"Broken:\" label.\n- Counts agree with their nouns (\"1 file\", \"2 files\").\n- Name code by its exact name in backticks. Define every name the reader can't know in the glossary.\n- A note says what the step shows. It never says \"as we saw\" or points at another step for its meaning.\n- Chapter titles say what happens (\"A bot author's request waits until its delivery is tried\"), never a function name alone.\n- The page has 0 `<script>` tags, is under 3 MiB and fits 1040 px.\n\n## Examples\n\n`examples/` in the hub repo (`packages/agent-kit/deep-dive/examples/`) has three complete topics: `hub` (a post reaching a bot, with SQLite rows and a two-recording steer change), `eve` (the GitHub credential lease, two sessions on one sandbox, with fakes) and `api` (a deployment from create to READY, with loader-hook stubs for packages the sandbox can't install). Read the one closest to your flow before writing a topic.\n","files":{"record.mjs":"// Records a real run of topic.scenario: breakpoints set from a worker over the V8 inspector at topic.points; each pause captures the stack,\n// async stack, scopes, watch values and, when topic.stores is set, the rows its rows(ready) returns.\n// Usage: node record.mjs topic.json recording.json [--root /other/checkout] [--import hooks.mjs]\n// --root records the same topic against another copy of topic.root (a patched head for a two-recording page).\n// --import loads a module before the scenario, for loader hooks (registerHooks) that map imports the sandbox can't install.\n// An actor with `key` (a JS expression) gets it evaluated on every frame of that actor, so the page can draw one lifeline per key value.\n// `pauseKey` is evaluated on the paused frame of every pause and stored as pauses[].keys, for pauses with no frame of the actor.\n// Each pause stores `call` (the paused function's entry count and live calls; see track) and, with `pin` in the topic, ids and times repeat.\nimport { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';\nimport { pathToFileURL } from 'node:url';\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { execFileSync } from 'node:child_process';\nimport { loadTopic, pointPath, relTo, urlOf } from './lib.mjs';\nconst SELF = import.meta.url;\nif (isMainThread) {\n const args = process.argv.slice(2);\n const flag = (n) => { const k = args.indexOf(n); if (k < 0) return null; const v = args[k + 1]; args.splice(k, 2); return v; };\n const rootOverride = flag('--root'), hooks = flag('--import');\n const [topicFile, out] = args;\n if (!topicFile || !out) { console.error('usage: node record.mjs topic.json recording.json [--root dir] [--import hooks.mjs]'); process.exit(2); }\n const base = loadTopic(topicFile), topic = loadTopic(topicFile, rootOverride);\n const root = topic.root;\n const scenarioPath = rootOverride && base.scenario.startsWith(base.root + '/') ? root + base.scenario.slice(base.root.length) : topic.scenario;\n const hookPath = hooks ? new URL(hooks, pathToFileURL(process.cwd() + '/')).href : topic.import ? urlOf(topic.import) : null;\n if (hookPath) await import(hookPath);\n const date = new Date().toISOString().slice(0, 10);\n if (topic.pin) await import('./pin.mjs');\n const points = topic.points.map((p) => ({ ...p, url: urlOf(pointPath(topic, root, p)) }));\n const keys = topic.actors.filter((a) => a.key || a.pauseKey).map((a) => ({ name: a.name, frames: a.frames, key: a.key, pauseKey: a.pauseKey }));\n const w = new Worker(new URL(SELF), { workerData: { points, root, dir: topic.dir, keys, stores: topic.stores ?? null } });\n w.on('error', (e) => { console.error('recorder worker', e); process.exit(1); });\n const keep = setInterval(() => {}, 50);\n await new Promise((r) => w.once('message', r));\n const { scenario } = await import(pathToFileURL(scenarioPath).href);\n const marks = [];\n const mark = (phase, info) => { marks.push({ phase, info }); if (phase === 'ready') w.postMessage({ arm: info ?? {} }); if (phase === 'done') w.postMessage({ disarm: true }); };\n await scenario(mark, { root });\n const got = new Promise((r) => w.once('message', r));\n w.postMessage({ dump: true });\n const { pauses, unresolved } = await got;\n clearInterval(keep);\n const commit = execFileSync('git', ['-C', root, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();\n const dirty = execFileSync('git', ['-C', root, 'diff', '--name-only', '--relative', 'HEAD'], { encoding: 'utf8' }).split('\\n').filter(Boolean);\n const provenance = { commit, changed: dirty, scenario: relTo(root, topic.dir)(urlOf(scenarioPath)), date, pauses: pauses.length };\n // Every file a step's source panel shows or an invariant cites, as it was when recorded.\n const srcUrls = new Set([...pauses.map((p) => p.frames[0].url), ...(topic.invariants ?? []).map((v) => urlOf(`${root}/${v.file}`))]);\n const sources = Object.fromEntries([...srcUrls].map((u) => [u, readFileSync(u.replace(/^file:\\/\\//, ''), 'utf8')]));\n writeFileSync(out, JSON.stringify({ root, scenario: scenarioPath, provenance, marks, pauses, sources }, (k, v) => typeof v === 'bigint' ? String(v) : v, 1));\n console.log(`pauses ${pauses.length}`);\n const hitIds = new Set(pauses.map((p) => p.point));\n const missed = topic.points.filter((p) => !hitIds.has(p.id)).map((p) => `${p.id} ${p.file}:${p.line}`);\n if (unresolved.length) console.log('breakpoints that resolved to another line:', unresolved.join(', '));\n if (missed.length) console.log('points never hit:', missed.join(', '));\n process.exit(0);\n} else {\n setInterval(() => {}, 1000);\n const { Session } = await import('node:inspector');\n const { points, root, dir, keys, stores } = workerData;\n const rel = relTo(root, dir), KEYS = keys.map((k) => ({ ...k, re: new RegExp(k.frames) }));\n const rowsOf = stores ? (await import(pathToFileURL(stores).href)).rows : null;\n const s = new Session(); s.connectToMainThread();\n const call = (m, p = {}) => new Promise((res, rej) => s.post(m, p, (e, r) => e ? rej(e) : res(r)));\n let armed = null; const pauses = []; const scripts = new Map(); const bpPoint = new Map(); const unresolved = [];\n const resolved = new Map(), counter = new Map(), placed = new Map(), fnOfPoint = new Map(), calls = new Map();\n s.on('Debugger.scriptParsed', ({ params }) => scripts.set(params.scriptId, params.url));\n const WATCH = new Map(points.filter((b) => b.watch).map((b) => [b.id, b.watch]));\n const preview = async (obj, depth) => {\n if (!obj) return null;\n if (obj.type !== 'object' || obj.subtype === 'null') return { t: obj.subtype ?? obj.type, v: obj.value ?? obj.unserializableValue ?? obj.description ?? null };\n if ((obj.subtype === 'map' || obj.subtype === 'set') && depth > 0) {\n const { internalProperties = [] } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const entries = internalProperties.find((p) => p.name === '[[Entries]]');\n const out = [];\n if (entries) { const { result } = await call('Runtime.getProperties', { objectId: entries.value.objectId, ownProperties: true });\n for (const p of result.filter((p) => /^\\d+$/.test(p.name)).slice(0, 40)) out.push(await preview(p.value, depth - 1)); }\n return { t: obj.subtype, v: Object.fromEntries(out.map((e, i) => [i, e])), n: out.length, d: obj.description };\n }\n if (depth <= 0 || obj.subtype === 'promise' || /^(Function|Db|Client)/.test(obj.className ?? '')) return { t: obj.className ?? obj.subtype, v: obj.description };\n const { result } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const props = {};\n for (const p of result.filter((p) => p.enumerable !== false && p.value).slice(0, 40)) props[p.name] = await preview(p.value, depth - 1);\n return { t: obj.subtype === 'array' ? 'array' : (obj.className ?? 'Object'), v: props, n: obj.subtype === 'array' ? result.filter(p => /^\\d+$/.test(p.name)).length : undefined };\n };\n const frameOf = (url, fn, line, col) => ({ fn: fn || '(anonymous)', url, line: line + 1, col: col + 1 });\n s.on('Debugger.paused', async ({ params }) => {\n try {\n if (params.reason === 'instrumentation') return await track(params.data.scriptId);\n const hit = params.hitBreakpoints ?? [];\n const counts = hit.flatMap((b) => (counter.get(b) ?? []).map((k) => ({ ...k, b })));\n for (const { fn, b } of counts.filter((k) => k.kind === 'entry')) {\n const c = calls.get(fn);\n // The first candidate a call reaches is the one every call reaches first; the other candidates go.\n if (!c.entry) {\n c.entry = b;\n for (const o of c.candidates.filter((o) => o !== b)) {\n const rest = counter.get(o).filter((k) => !(k.kind === 'entry' && k.fn === fn));\n if (rest.length) counter.set(o, rest); else { counter.delete(o); for (const [at, id] of placed) if (id === o) placed.delete(at); await call('Debugger.removeBreakpoint', { breakpointId: o }); }\n }\n }\n c.entries++;\n }\n const exits = counts.filter((k) => k.kind === 'exit');\n const point = hit.map((b) => bpPoint.get(b)).find(Boolean) ?? null;\n try {\n if (!point || !armed) return;\n await record(params, point, hit.map((b) => fnOfPoint.get(b)?.get(params.callFrames[0].location.scriptId)).find(Boolean));\n } finally { for (const c of exits) calls.get(c.fn).exits++; }\n } finally { await call('Debugger.resume'); }\n });\n const record = async (params, point, fn) => {\n const top = params.callFrames[0];\n const frames = params.callFrames.map((f) => frameOf(scripts.get(f.location.scriptId) ?? f.url, f.functionName, f.location.lineNumber, f.location.columnNumber));\n const asyncFrames = [];\n for (let a = params.asyncStackTrace; a; a = a.parent) asyncFrames.push({ description: a.description, frames: a.callFrames.map((f) => frameOf(scripts.get(f.scriptId) ?? f.url, f.functionName, f.lineNumber, f.columnNumber)).filter((f) => f.url !== SELF) });\n const scopes = [];\n for (const sc of top.scopeChain.filter((x) => x.type === 'local' || x.type === 'block' || x.type === 'closure').slice(0, 3)) {\n const { result } = await call('Runtime.getProperties', { objectId: sc.object.objectId, ownProperties: true });\n const vars = {};\n for (const p of result) if (p.value) vars[p.name] = await preview(p.value, 3);\n scopes.push({ type: sc.type, name: sc.name ?? null, vars });\n }\n for (const [k, f] of params.callFrames.entries()) {\n const url = scripts.get(f.location.scriptId) ?? f.url, ks = KEYS.filter((a) => a.re.test(rel(url)) || a.re.test(url));\n for (const a of ks.filter((x) => x.key)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: f.callFrameId, expression: a.key, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) { (frames[k].keys ??= {})[a.name] = r.result.value; } }\n }\n // pauseKey: evaluated on the paused frame whatever actor it belongs to (an AsyncLocalStorage lookup, say), for pauses whose stack has no\n // frame of the actor to read `key` from, such as a fake resumed inside one session's await.\n const pkeys = {};\n for (const a of KEYS.filter((x) => x.pauseKey)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression: a.pauseKey, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) pkeys[a.name] = r.result.value; }\n const watch = [];\n for (const expression of WATCH.get(point) ?? []) {\n const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression, returnByValue: true });\n watch.push({ expression, value: r.exceptionDetails ? { error: r.exceptionDetails.exception?.description ?? r.exceptionDetails.text } : r.result.value });\n }\n let rows = {};\n if (rowsOf) try { rows = await rowsOf(armed); } catch (e) { rows = { error: String(e?.message ?? e) }; }\n pauses.push({ at: performance.now(), wall: Date.now(), point, call: fn ? { fn, entries: calls.get(fn).entries, live: calls.get(fn).entries - calls.get(fn).exits } : null, hit: params.hitBreakpoints, frames: frames.filter((f) => f.url !== SELF), asyncFrames, ...(Object.keys(pkeys).length ? { keys: pkeys } : {}), scopes, watch, rows });\n };\n // Which call a pause belongs to. Before a script with a point runs, find the function around each point: scanning breakable locations\n // from the top of the script, the first one whose function-restricted range reaches the point starts that range. Breakpoints on the\n // function's first location and on its returns count entries and exits; a pause stores both, so check and render can treat two pauses\n // as one call only when no entry came between them and exactly one call was live.\n const texts = new Map();\n const source = async (scriptId) => texts.get(scriptId) ?? texts.set(scriptId, (await call('Debugger.getScriptSource', { scriptId })).scriptSource).get(scriptId);\n // Entry candidates are the function's locations outside the parameter list (a default value runs only for some calls), in source order,\n // up to the first that isn't a `call` location: V8 breaks at a call after its arguments, so any of them may run first. A parameter location is inside parentheses\n // that close into `{` or `=>` and don't follow `if`, `for`, `while`, `switch`, `catch` or `with`.\n const inParams = (text, l) => {\n let i = 0;\n for (let n = 0; n < l.lineNumber; n++) i = text.indexOf('\\n', i) + 1;\n i += l.columnNumber;\n for (let depth = 0; i < text.length; i++) {\n const c = text[i];\n if (c === '\"' || c === \"'\" || c === '`') { for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\\\') i++; }\n else if (c === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) { i = text.indexOf(text[i + 1] === '/' ? '\\n' : '*/', i + 2); if (i < 0) return false; }\n else if ('([{'.includes(c)) depth++;\n else if (')]}'.includes(c) && depth) depth--;\n else if (c !== ')' && ')]}'.includes(c)) return false;\n else if (c === ')') {\n if (!/^\\s*(\\{|=>)/.test(text.slice(i + 1, i + 200))) return false;\n let open = i - 1;\n for (let d = 0; open >= 0; open--) { if (text[open] === ')') d++; else if (text[open] === '(') { if (!d) break; d--; } }\n return !/\\b(if|for|while|switch|catch|with)\\s*$/.test(text.slice(Math.max(0, open - 20), open));\n }\n }\n return false;\n };\n const track = async (scriptId) => {\n const loc = (l) => `${l.lineNumber}:${l.columnNumber}`;\n for (const [bp, locs] of resolved) for (const at of locs.filter((l) => l.scriptId === scriptId)) {\n const end = { ...at, columnNumber: at.columnNumber + 1 };\n const { locations: before } = await call('Debugger.getPossibleBreakpoints', { start: { scriptId, lineNumber: 0, columnNumber: 0 }, end });\n let first = null;\n for (const q of before) {\n const { locations } = await call('Debugger.getPossibleBreakpoints', { start: q, end, restrictToFunction: true });\n if (locations.some((l) => loc(l) === loc(at))) { first = locations[0]; break; }\n }\n if (!first) continue;\n const fn = `${scriptId}:${loc(first)}`;\n if (!calls.has(fn)) {\n const { locations: body } = await call('Debugger.getPossibleBreakpoints', { start: first, restrictToFunction: true });\n const text = await source(scriptId), own = body.filter((l) => !inParams(text, l)), last = own.findIndex((l) => l.type !== 'call');\n if (last < 0) continue;\n const c = { entries: 0, exits: 0, candidates: [], entry: null };\n calls.set(fn, c);\n for (const [kind, l] of [...own.slice(0, last + 1).map((l) => ['entry', l]), ...body.filter((l) => l.type === 'return').map((l) => ['exit', l])]) {\n const at = `${scriptId}:${loc(l)}`, id = placed.get(at) ?? (await call('Debugger.setBreakpoint', { location: l })).breakpointId;\n placed.set(at, id);\n counter.set(id, [...(counter.get(id) ?? []), { kind, fn }]);\n if (kind === 'entry') c.candidates.push(id);\n }\n }\n if (!fnOfPoint.has(bp)) fnOfPoint.set(bp, new Map());\n fnOfPoint.get(bp).set(scriptId, fn);\n }\n };\n await call('Debugger.enable');\n await call('Debugger.setInstrumentationBreakpoint', { instrumentation: 'beforeScriptExecution' });\n await call('Debugger.setAsyncCallStackDepth', { maxDepth: 32 });\n for (const b of points) {\n const r = await call('Debugger.setBreakpointByUrl', { url: b.url, lineNumber: b.line - 1, columnNumber: b.col ? b.col - 1 : 0 });\n bpPoint.set(r.breakpointId, b.id);\n }\n s.on('Debugger.breakpointResolved', ({ params }) => { resolved.set(params.breakpointId, [...(resolved.get(params.breakpointId) ?? []), params.location]); const id = bpPoint.get(params.breakpointId); const p = points.find((x) => x.id === id); if (p && params.location.lineNumber + 1 !== p.line) unresolved.push(`${id} ${p.file}:${p.line} -> :${params.location.lineNumber + 1}`); });\n parentPort.on('message', (m) => { if (m.arm) armed = m.arm; if (m.disarm) armed = null; if (m.dump) parentPort.postMessage({ pauses, unresolved }); });\n parentPort.postMessage('ready');\n}\n","render.mjs":"// The page: a top bar with a stepper, a navigator (chapters, sequence diagram or steps) and an inspector for the current step, all without JS.\n// Usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]\n// VIEWER_DEFAULTS='level=seq;show=note,stack;inspector=off' changes which inputs start checked (level: ch, seq, steps; show: note stack vars values wire source inv; inspector: on, off).\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { basename, dirname } from 'node:path';\nimport { codeToTokens } from 'shiki';\nimport { readJson, loadTopic, rootOf, relTo, stackOf, hitsOf, plain, invStep, authoredOf, chaptersOf, plural, sourceOf, diffOf, previousInCall } from './lib.mjs';\nconst require = createRequire(import.meta.url);\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile, out] = argv;\nif (!out) { console.error('usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, N = P.length;\nconst ROOT = rootOf(topic, rec);\nconst rel = relTo(ROOT, topic.dir);\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst code = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');\nconst base = (url) => rel(url).split('/').pop();\nconst same = (a, b) => JSON.stringify(a) === JSON.stringify(b);\nconst href = (i) => `#step-${i + 1}`;\nconst A = (i, cls, body, title) => `<a class=\"${cls}\" href=\"${href(i)}\"${title ? ` title=\"${esc(title)}\"` : ''}>${body}</a>`;\n\n// Actors. A frame belongs to the first actor whose `frames` regex matches its path relative to topic.root (its full url when outside it).\nconst ACT = topic.actors.map((a) => ({ ...a, fre: a.frames ? new RegExp(a.frames) : null, ure: a.urls ? new RegExp(a.urls) : null }));\nconst frameActor = new Map();\nconst actorOfUrl = (url) => { if (!frameActor.has(url)) { const r = rel(url); frameActor.set(url, ACT.find((a) => a.fre && (a.fre.test(r) || a.fre.test(url)))?.name ?? null); } return frameActor.get(url); };\nconst STORES = ACT.filter((a) => a.kind === 'store').map((a) => a.name);\n// Lanes: an actor with `key` gets one lifeline per value record.mjs evaluated on its frames. At each step the actor's lane is the value on\n// its innermost frame that has one, else its lane at the step before.\nconst keyed = new Set(ACT.filter((a) => a.key || a.pauseKey).map((a) => a.name));\n// A pause's lane key: from a frame of the actor (key), else from the pause itself (pauseKey), else the last one seen.\nconst keyAt = (p, n) => p.frames.find((x) => x.keys?.[n] !== undefined)?.keys[n] ?? p.keys?.[n];\nconst laneVal = (() => { const last = {}; return P.map((p) => { for (const n of keyed) { const v = keyAt(p, n); if (v !== undefined) last[n] = String(v); } return { ...last }; }); })();\nconst lane = (name, i) => name && keyed.has(name) && laneVal[i][name] != null ? `${name}#${laneVal[i][name]}` : name;\nconst laneLabel = (id) => id?.includes('#') ? `${id.slice(0, id.indexOf('#'))} (${id.slice(id.indexOf('#') + 1)})` : id;\nconst laneSub = (id) => id?.includes('#') ? id.slice(id.indexOf('#') + 1) : '';\n\nconst stacks = P.map(stackOf);\nconst hits = hitsOf(P);\nconst noteOf = P.map((p, i) => notes.find((n) => n.point === p.point && (n.hit ?? 0) === hits[i]));\nP.forEach((p, i) => { if (!noteOf[i]) throw new Error(`no note for step ${i + 1} (${p.point} hit ${hits[i]}); run check.mjs`); });\nconst invAt = (v) => invStep(P, hits, v);\n// Frames from the first step's paused function inward; everything outside it is the scenario's own calls and request routing.\nconst entry = P[0].frames[0];\nconst depth = stacks.map((s) => { const i = s.findLastIndex((f) => f.url === entry.url && f.fn === entry.fn); return i < 0 ? s.length : i + 1; });\nconst flat = (x, p = '', out = []) => { if (x && typeof x === 'object' && Object.keys(x).length) { for (const [k, v] of Object.entries(x)) flat(v, p ? `${p}.${k}` : k, out); } else out.push([p, x]); return out; };\nconst kvLine = (x) => flat(x).map(([k, v]) => `${k} = ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ');\nconst keyOf = (p) => `${rel(p.frames[0].url)}:${p.frames[0].line}`;\n\n// Values.\nconst isPrim = (v) => !v || typeof v.v !== 'object' || v.v === null;\nconst fmtPrim = (v) => {\n if (!v) return '<span class=\"v-u\">undefined</span>';\n if (v.t === 'string') { const ls = String(v.v).split('\\n'); return ls.length > 3 ? `<span class=\"v-s\">\"${esc(ls[0])}</span><details class=\"more\"><summary>${ls.length - 1} more lines</summary><span class=\"v-s pre\">${esc(ls.slice(1).join('\\n'))}\"</span></details>` : `<span class=\"v-s\">\"${esc(ls.join('⏎'))}\"</span>`; }\n if (v.t === 'number' || v.t === 'bigint') return `<span class=\"v-n\">${esc(v.v)}</span>`;\n if (v.t === 'boolean') return `<span class=\"v-b\">${v.v}</span>`;\n if (v.t === 'null' || v.t === 'undefined') return `<span class=\"v-u\">${v.t}</span>`;\n if (v.t === 'function') return `<span class=\"v-f\">ƒ</span>`;\n return `<span class=\"v-o\">${esc(String(v.v ?? v.t).split('\\n')[0])}</span>`;\n};\nconst label = (v) => v.t === 'array' ? `Array(${v.n})` : v.t === 'map' || v.t === 'set' ? (v.d ?? `${v.t}(${v.n})`) : v.t;\nconst inline = (v, d = 0) => {\n if (isPrim(v)) return fmtPrim(v);\n if (d > 0) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const e = Object.entries(v.v);\n if (v.t === 'array' && e.length && e.every(([, x]) => !isPrim(x))) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const body = e.map(([k, x]) => v.t === 'array' || v.t === 'set' ? inline(x, 1) : `${esc(k)}: ${inline(x, 1)}`).join(', ');\n return `<span class=\"v-o\">${v.t === 'array' ? `(${v.n}) [` : '{'}</span>${body}<span class=\"v-o\">${v.t === 'array' ? ']' : '}'}</span>`;\n};\nconst json = (x) => JSON.stringify(x, null, 1);\nconst varsAt = (i) => Object.assign({}, ...P[i].scopes.filter((s) => s.type !== 'closure').map((s) => s.vars));\nconst prevSame = previousInCall(P);\nconst done = rec.marks.find((m) => m.phase === 'done')?.info;\n\n// HTTP: a watch value with `method` and `url` is a request, recorded where it is received. Its receiver is the actor whose `urls` matches the url\n// (else the paused frame's actor); its sender is the nearest outer frame of another actor. Its response is the next watch value with a numeric `status`\n// paused in the sender, or, when none is, the scenario's done mark ({status, body}) at the last step.\nconst isReq = (v) => v && typeof v === 'object' && typeof v.method === 'string' && typeof v.url === 'string';\nconst isResp = (v) => v && typeof v === 'object' && typeof v.status === 'number' && !('method' in v);\nconst reqs = [];\nlet doneUsed = false;\nP.forEach((p, h) => p.watch.forEach((w) => {\n if (!isReq(w.value)) return;\n const v = w.value, u = new URL(v.url);\n const to = ACT.find((a) => a.ure && a.ure.test(v.url))?.name ?? actorOfUrl(p.frames[0].url) ?? 'peer';\n const from = stacks[h].map((f) => actorOfUrl(f.url)).find((a) => a && a !== to) ?? ACT.find((a) => a.kind !== 'store' && a.name !== to)?.name ?? 'caller';\n let e = P.findIndex((q, j) => j > h && q.watch.some((x) => isResp(x.value)) && actorOfUrl(q.frames[0].url) === from);\n let status, got;\n if (e >= 0) { const r = P[e].watch.find((x) => isResp(x.value)).value; status = r.status; got = r.body ?? r.text; if (typeof got === 'string') { try { got = JSON.parse(got); } catch {} } }\n else if (done && done.status !== undefined && !doneUsed) { doneUsed = true; e = N - 1; status = done.status; got = done.body; }\n else e = null;\n reqs.push({ from: lane(from, h), to: lane(to, h), method: v.method, path: u.pathname, url: v.url, start: h, end: e, status, sent: v.body, got, viaDone: e === N - 1 && got === done?.body });\n}));\n\n// SQL: a watch value that is a list of statements ({sql} or {other}) is a batch sent to the first store; the next watch with `changed` is its answer.\nconst tableOfSql = (s) => s.match(/^\\s*(?:WITH[\\s\\S]*?\\)\\s*)?(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(\\w+)/i)?.[1] ?? s.match(/\\bFROM\\s+(\\w+)/i)?.[1] ?? '?';\nconst isStmts = (v) => Array.isArray(v) && v.length && v.every((x) => x && typeof x === 'object' && ('sql' in x || 'other' in x));\nconst batch = (() => { const j = P.findIndex((p) => p.watch.some((w) => isStmts(w.value))); if (j < 0 || !STORES.length) return null;\n const list = P[j].watch.find((w) => isStmts(w.value)).value, pw = P[j].watch.find((w) => typeof w.value === 'number'), prepared = pw?.value ?? list.length, preparedExpr = pw?.expression ?? 'the batch';\n const d = P.findIndex((p, k) => k > j && p.watch.some((w) => Array.isArray(w.value?.changed)));\n const changed = d < 0 ? null : P[d].watch.find((w) => Array.isArray(w.value?.changed)).value.changed;\n return { at: j, done: d, prepared, preparedExpr, changed, store: STORES[0], from: lane(actorOfUrl(P[j].frames[0].url), j), stmts: list.map((x) => {\n if (x.sql) { const w = /^\\s*(INSERT|UPDATE|DELETE|REPLACE)/i.test(x.sql); return { op: w ? x.sql.trim().split(/\\s+/)[0].toLowerCase() : 'select', table: tableOfSql(x.sql), name: x.name, full: x.sql }; }\n const o = JSON.parse(x.other); return { op: o.kind, table: o.table, name: String(o.row?.id ?? (o.set ? 'set ' + Object.keys(o.set).join(', ') : '')), full: json(o) }; }) }; })();\n\n// Rows: topic.stores' tables. A table key \"<store>.<table>\" belongs to that store actor, any other key to the first store.\nconst TABLES = Object.keys(P[0].rows ?? {}).filter((t) => Array.isArray(P[0].rows[t]));\nif (TABLES.length && !STORES.length) { ACT.push({ name: 'rows', kind: 'store' }); STORES.push('rows'); }\nconst storeOf = (t) => STORES.find((s) => t.startsWith(s + '.')) ?? STORES[0];\nconst pkCol = new Map();\nconst pk = (t, r) => { if (!pkCol.has(t)) pkCol.set(t, 'id' in r ? 'id' : Object.keys(r).find((k) => /(^|_)id$/i.test(k)) ?? null); const c = pkCol.get(t); return c ? String(r[c]) : JSON.stringify(r); };\nconst rowsOf = (i, t) => Array.isArray(P[i].rows?.[t]) ? P[i].rows[t] : [];\nconst tdiff = (i) => i === 0 ? [] : TABLES.map((t) => {\n const a = new Map(rowsOf(i - 1, t).map((r) => [pk(t, r), r])), b = new Map(rowsOf(i, t).map((r) => [pk(t, r), r]));\n const added = [...b.keys()].filter((k) => !a.has(k)), removed = [...a.keys()].filter((k) => !b.has(k));\n const changed = [...b.keys()].filter((k) => a.has(k) && !same(a.get(k), b.get(k))).map((k) => ({ id: k, cols: Object.keys(b.get(k)).filter((c) => !same(a.get(k)[c], b.get(k)[c])).map((c) => ({ c, from: a.get(k)[c], to: b.get(k)[c] })) }));\n return { t, added: added.map((k) => b.get(k)), removed, changed };\n}).filter((d) => d.added.length || d.removed.length || d.changed.length);\nconst diffs = P.map((_, i) => tdiff(i));\n// The writer of a row change is the actor paused at the step before it (the code that ran in between).\n// Parts: each source file with a breakpoint, on its actor's lifeline.\nconst bpFiles = new Set(P.map((p) => p.frames[0].url));\nconst partOf = (url) => bpFiles.has(url) ? rel(url) : null;\nconst chainOf = (i) => { const out = []; for (const f of stacks[i].slice(0, depth[i]).reverse()) { const p = partOf(f.url); if (!p) continue; if (out.at(-1)?.p === p) { out.at(-1).fns.push(f.fn); out.at(-1).line = f.line; } else out.push({ p, a: lane(actorOfUrl(f.url), i), fns: [f.fn], line: f.line }); } return out; };\nconst chains = P.map((_, i) => chainOf(i));\nconst fnName = (c) => c.fns.find((f) => f !== '(anonymous)') ?? c.fns[0];\nconst topFn = (i) => P[i].frames[0].fn === '(anonymous)' ? (P[i].scopes.find((s) => s.type === 'closure')?.name || '(anonymous)') : P[i].frames[0].fn;\n\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start.\nconst CH = chaptersOf(topic, P);\nif (!CH) throw new Error('a chapter\\'s point is not hit at or after the previous chapter\\'s start; run check.mjs');\nconst chapOf = P.map((_, i) => CH.find((c) => i >= c.from && i <= c.to).k);\nconst rowsChanged = batch?.changed ? batch.changed.reduce((a, b) => a + (b || 0), 0) : 0;\n// Rows the batch changed, per table, from its answer: the store square, the store rows list and the arrow all count from this.\nconst batchRows = batch?.changed ? batch.stmts.reduce((m, st, k) => (batch.changed[k] ? m.set(st.table, (m.get(st.table) ?? 0) + batch.changed[k]) : m), new Map()) : new Map();\nconst batchRowsTxt = () => [...batchRows].map(([t, c]) => `${t} ${c}`).join(', ');\nconst opsOf = (d) => [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} updated`, d.removed.length && `${d.removed.length} deleted`].filter(Boolean).join(', ');\nconst tableOps = (d) => `${d.t} ${opsOf(d)}`;\nconst respLine = (st, body) => { const l = flat(body ?? {}); const nested = l.filter(([k]) => k.includes('.')); return `${st}${l.length ? ' ' + kvLine(Object.fromEntries(nested.length ? nested : l)) : ''}`; };\nconst cd = (x) => `<code>${esc(x)}</code>`;\nconst fileLine = (i) => keyOf(P[i]);\n// The operation that defines a chapter, from the step data: an HTTP exchange, the SQL batch, a response, or the tables written.\nconst whoOf = (c) => `${[...new Set(c.idx.map(topFn))].slice(0, 3).map(cd).join(', ')} ${cd(fileLine(c.from))}`;\nconst titleOf = (c) => c.title ? code(c.title) : whoOf(c);\n\nconst DIFF = baseFile ? diffOf(topic, rec, readJson(baseFile), basename(baseFile)) : null;\nconst differs = (i) => DIFF && DIFF.steps[i].kinds.length > 0;\n\n// Changes at each step: requests and responses, the batch, table row counts, frames new to the run, and variables changed since the last pause in the same call.\n// Lifelines: every declared actor in topic order, including one no step pauses in; a keyed actor gets one per key value, in order of first use.\nconst LIFE = ACT.flatMap((a) => { const vs = keyed.has(a.name) ? [...new Set(laneVal.map((v) => v[a.name]).filter((v) => v != null))] : []; return vs.length ? vs.map((v) => ({ ...a, id: `${a.name}#${v}`, sub: v })) : [{ ...a, id: a.name, sub: '' }]; });\n// Each lifeline gets an equal column with its line in the middle, so every header fits above its own line.\nconst LX = Object.fromEntries(LIFE.map((a, k) => [a.id, (100 / LIFE.length) * (k + 0.5)]));\nconst seqRows = [];\nconst add = (o, r) => seqRows.push({ o, ...r });\nconst reqAt = (i, to) => reqs.some((r) => r.start === i && r.to === to);\nconst respAt = (i, from) => reqs.some((r) => r.end === i && r.to === from);\n// A keyed actor runs one stack per key (two sessions at once). A step is compared with the last step of its own stack, so a pause in another\n// session's stack draws no return for calls that are still running.\nconst ownKey = P.map((p) => JSON.stringify([...keyed].map((n) => keyAt(p, n) ?? null).some((v) => v != null) ? [...keyed].map((n) => keyAt(p, n) ?? null) : null));\nfor (let i = 0; i < N; i++) {\n const j = ownKey[i] === 'null' ? i - 1 : P.findLastIndex((_, k) => k < i && ownKey[k] === ownKey[i]);\n const prev = j >= 0 ? chains[j] : chains[0].slice(0, 1), cur = chains[i];\n let c = 0; while (c < prev.length && c < cur.length && prev[c].p === cur[c].p && prev[c].a === cur[c].a && prev[c].fns[0] === cur[c].fns[0]) c++;\n for (let k = prev.length - 1; k >= Math.max(c, 1); k--) {\n if (prev[k].a !== prev[k - 1].a) { if (!respAt(i, prev[k].a)) add(i, { kind: 'arr call ret', from: prev[k].a, to: prev[k - 1].a, label: `ret ${fnName(prev[k])}`, short: `ret ${fnName(prev[k])}` }); }\n else add(i, { kind: 'self ret', at: prev[k].a, d: k - 1, label: `ret <code>${esc(fnName(prev[k]))}</code> <code>${esc(base(prev[k].p))}:${prev[k].line}</code>` });\n }\n for (const r of reqs) if (r.end === i && !r.viaDone) add(i, { kind: 'arr http ret', from: r.to, to: r.from, label: `${r.status}${r.got === undefined ? '' : ' ' + (typeof r.got === 'string' ? r.got : kvLine(r.got))}`, short: String(r.status) });\n for (const r of reqs) if (r.start === i) add(i, { kind: 'arr http', from: r.from, to: r.to, label: `${r.method} ${r.path}`, short: `${r.method} ${r.path}` });\n for (let k = Math.max(c, 1); k < cur.length; k++) {\n if (cur[k].a !== cur[k - 1].a) { if (!reqAt(i, cur[k].a)) add(i, { kind: 'arr call', from: cur[k - 1].a, to: cur[k].a, label: `call ${fnName(cur[k])} ${base(cur[k].p)}:${cur[k].line}`, short: `call ${fnName(cur[k])}` }); }\n else add(i, { kind: 'self', at: cur[k].a, d: k - 1, label: `call <code>${esc(fnName(cur[k]))}</code> <code>${esc(base(cur[k].p))}:${cur[k].line}</code>` });\n }\n if (batch && i === batch.at) add(i, { kind: 'arr sql', from: batch.from, to: batch.store, label: `db.batch ${batch.stmts.length} stmts`, short: `db.batch ${batch.stmts.length} stmts` });\n if (batch && i === batch.done) add(i, { kind: 'arr sql ret', from: batch.store, to: batch.from, label: `${rowsChanged} rows changed`, short: `${rowsChanged} rows changed` });\n if (batch && i === batch.done && batchRows.size) add(i, { kind: 'mk', at: batch.store, label: esc(batchRowsTxt()), short: batchRowsTxt() });\n else if (diffs[i].length) for (const s of STORES) { const ds = diffs[i].filter((d) => storeOf(d.t) === s); if (ds.length) add(i, { kind: 'mk', at: s, label: esc(ds.map(tableOps).join(', ')), short: ds.map(tableOps).join(', ') }); }\n}\nfor (const r of reqs) if (r.viaDone) add(N - 1, { kind: 'arr http ret', from: r.to, to: r.from, label: respLine(r.status, r.got), short: respLine(r.status, r.got) });\nseqRows.forEach((r) => { if (r.kind.startsWith('arr') && (!(r.from in LX) || !(r.to in LX))) r.drop = true; });\nfor (let k = seqRows.length - 1; k >= 0; k--) if (seqRows[k].drop) seqRows.splice(k, 1);\nseqRows.forEach((r) => { r.ch = chapOf[r.o]; r.steps = []; });\n// Call stack: outermost first, indented by depth; frames new since the previous step and frames whose line moved are marked.\n// Source + scope: source around the paused line, highlighted once per file; token colours become shared classes.\nconst colours = new Map(); const cls = (l, d) => { const k = `${l}|${d}`; if (!colours.has(k)) colours.set(k, `k${colours.size}`); return colours.get(k); };\nconst tokens = new Map();\nconst langOf = (f) => /\\.(m?js|cjs|jsx)$/.test(f) ? 'javascript' : /\\.tsx$/.test(f) ? 'tsx' : /\\.go$/.test(f) ? 'go' : /\\.py$/.test(f) ? 'python' : /\\.rs$/.test(f) ? 'rust' : 'typescript';\nfor (const url of new Set(P.map((p) => p.frames[0].url))) {\n const f = url.replace('file://', '');\n const src = sourceOf(rec, url) ?? '';\n const { tokens: t } = await codeToTokens(src, { lang: langOf(f), themes: { light: 'github-light', dark: 'github-dark' }, defaultColor: false });\n tokens.set(url, { lines: src.split('\\n'), t });\n}\nconst tokHtml = (t) => t.map((x) => { const st = x.htmlStyle ?? {}; return `<span class=\"${cls(st['--shiki-light'] ?? x.color, st['--shiki-dark'])}\">${esc(x.content)}</span>`; }).join('');\nconst unpack = (x) => { if (x && typeof x === 'object' && typeof x.other === 'string') { try { return JSON.parse(x.other); } catch {} } return x; };\nconst linesHtml = (i) => {\n const f = P[i].frames[0], { lines, t } = tokens.get(f.url), L = f.line, vars = varsAt(i), before = prevSame[i] == null ? null : varsAt(prevSame[i]);\n const mark = (n) => !before ? '' : !(n in before) ? 'new' : same(before[n], vars[n]) ? '' : 'chg';\n const names = Object.keys(vars).filter((n) => n.length > 1 && vars[n]?.t !== 'function' && n !== 'this');\n const from = Math.max(1, L - 4), to = Math.min(lines.length, L + 4);\n let rows = '';\n const re = (x) => x.replace(/\\$/g, '\\\\$');\n const useRe = (x) => new RegExp(`(^|[^\\\\w$.])${re(x)}(?![\\\\w$])(?!\\\\s*:(?!:))`);\n for (let n = from; n <= to; n++) {\n const used = n === L ? names.filter((x) => useRe(x).test(lines[n - 1])) : [];\n const iv = used.map((x) => `<span class=\"iv ${mark(x)}\">${esc(x)} = ${isPrim(vars[x]) ? fmtPrim(vars[x]) : inline(vars[x])}</span>`).join('');\n if (n === L && DIFF?.steps[i].was != null) rows += `<div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(DIFF.steps[i].was)}</del></span></div>`;\n rows += `<div class=\"ln ${n === L ? 'cur' : ''}\"><span class=\"no\">${n}</span><span class=\"src\">${tokHtml(t[n - 1] ?? [])}${iv}</span></div>`;\n }\n return `<div class=\"file\"><code>${esc(rel(f.url))}:${L}</code> in <code>${esc(topFn(i))}</code>${prevSame[i] != null ? `, last paused in this call at ${A(prevSame[i], 'go', `step ${prevSame[i] + 1}`)}` : ''}</div><div class=\"code\">${rows}</div>`;\n};\n\n// Page, table first.\nconst sv = (x) => x === undefined ? 'undefined' : typeof x === 'string' ? x : JSON.stringify(x);\nconst clip = (s, n = 120) => s.length > n ? `<details class=\"clip\"><summary>${esc(s.slice(0, n))}…</summary><span class=\"full\">${esc(s)}</span></details>` : esc(s);\nconst valCell = (v) => isPrim(v) ? fmtPrim(v) : inline(v);\n// Actors: what each did in this run, from the crossings it sent and received and the frames it paused in.\n// Step detail tables.\nconst flatTable = (x) => x === undefined ? '<span class=\"dim\">not recorded</span>' : `<table class=\"t sm kvt\"><tbody>${flat(x).map(([k, v]) => `<tr><th>${esc(k)}</th><td>${clip(sv(v), 240)}</td></tr>`).join('')}</tbody></table>`;\n// Statement ops that change rows; the rest read. At the batch's answer only the statements that changed rows are listed, the full list is at the batch step.\nconst WRITE_OPS = /^(put|insert|patch|update|delete|upsert|replace)/i;\nconst stmtList = (i, changedOnly = false) => { const raw = P[batch.at].watch.find((w) => isStmts(w.value)).value;\n return `<div class=\"stl\"><div class=\"sth\"><span>Statement</span><span>Table</span><span>Rows changed</span></div>${raw.map((x, k) => { const st = batch.stmts[k], o = unpack(x) ?? {}; const { kind, table, ...rest } = o; if (changedOnly && !(batch.changed?.[k])) return '';\n const body = 'sql' in o ? `<div class=\"pre\">${esc(o.sql.trim())}</div>${o.args?.length ? `<div class=\"sub\">Arguments</div><div class=\"pre\">${esc(JSON.stringify(o.args))}</div>` : ''}` : `<table class=\"t sm kvt\"><tbody>${flat(rest).map(([p, v]) => `<tr><th>${esc(p)}</th><td class=\"pre\">${esc(typeof v === 'string' ? v : JSON.stringify(v))}</td></tr>`).join('')}</tbody></table>`;\n return `${k === batch.prepared && !changedOnly ? `<div class=\"stsplit\">Statements ${k} to ${raw.length - 1}, after the ${batch.prepared} in <code>${esc(batch.preparedExpr)}</code>, in the same transaction</div>` : ''}<details class=\"stq\"><summary><span><span class=\"dim\">${k}</span> ${esc(st.op)}</span><span><code>${esc(st.table)}</code></span><span class=\"num\">${batch.changed ? batch.changed[k] ?? 0 : ''}</span></summary><div class=\"stb\">${st.name ? `<div class=\"sub\">Name <code>${esc(st.name)}</code></div>` : ''}${body}</div></details>`; }).join('')}</div>`; };\nconst wireTables = (i) => {\n const out = [];\n for (const r of reqs) {\n if (r.start === i) out.push(`<h4>${esc(laneLabel(r.from))} to ${esc(laneLabel(r.to))}, ${esc(r.method)} <code>${esc(r.path)}</code>${r.end !== null ? `, answered at ${A(r.end, 'go', 'step ' + (r.end + 1))}` : ''}</h4>${flatTable(r.sent)}`);\n if (r.end === i) out.push(`<h4>${esc(laneLabel(r.to))} answers ${esc(laneLabel(r.from))}, ${esc(r.status)} for ${esc(r.method)} <code>${esc(r.path)}</code>, sent at ${A(r.start, 'go', 'step ' + (r.start + 1))}</h4>${flatTable(r.got)}`);\n }\n const pw = P[i].watch.filter((w) => !isReq(w.value) && !isStmts(w.value) && !(Array.isArray(w.value?.changed)));\n if (batch && i === batch.at) out.push(`<h4>${esc(laneLabel(batch.from))} to ${esc(batch.store)}, <code>db.batch</code> of ${plural(batch.stmts.length, 'statement')}, ${batch.stmts.filter((x) => WRITE_OPS.test(x.op)).length} that write and ${batch.stmts.filter((x) => !WRITE_OPS.test(x.op)).length} that read</h4>${stmtList(i)}<p class=\"dim\">Rows changed come from the answer at ${A(batch.done, 'go', 'step ' + (batch.done + 1))}.</p>`);\n if (batch && i === batch.done) out.push(`<h4>${esc(batch.store)} answers ${esc(laneLabel(batch.from))}, ${plural(rowsChanged, 'row')} changed in ${plural(batchRows.size, 'table')}</h4>${stmtList(i, true)}<p class=\"dim\">The other ${plural(batch.stmts.length - batch.changed.filter(Boolean).length, 'statement')} changed no rows. ${A(batch.at, 'go', `Step ${batch.at + 1}`, open(batch.at))} lists all ${batch.stmts.length}.</p>`);\n for (const w of pw) out.push(`<h4>Recorded expression <code>${clip(w.expression, 80)}</code></h4>${w.value === null || typeof w.value !== 'object' ? `<p><code>${esc(sv(w.value))}</code></p>` : flatTable(w.value)}`);\n if (i === N - 1 && done) out.push(`<h4>Scenario result</h4>${flatTable(done)}`);\n return out.join('');\n};\n// Glossary terms: the first use of a term in a text becomes a <details class=\"term\"> with its definition. A term matches a backticked name\n// exactly, and, when it is plain lowercase words, the same words (or their plural) in the prose.\nconst GL = (topic.glossary ?? []).map((g) => ({ ...g, re: /^[a-z][a-z ]*$/.test(g.term) ? new RegExp(`\\\\b${g.term}s?\\\\b`, 'i') : null }));\nconst termHtml = (label, g) => `<details class=\"term\"><summary>${label}</summary><span class=\"def\"><b>${esc(g.term)}</b> ${esc(g.def)}</span></details>`;\nconst termify = (text) => {\n const used = new Set(); let out = '';\n String(text).split('`').forEach((seg, k) => {\n if (k % 2) { const g = GL.find((x) => x.term === seg && !used.has(x.term)); if (g) used.add(g.term); out += g ? termHtml(`<code>${esc(seg)}</code>`, g) : `<code>${esc(seg)}</code>`; return; }\n let rest = seg;\n for (;;) {\n let best = null;\n for (const g of GL) { if (!g.re || used.has(g.term)) continue; const m = g.re.exec(rest); if (m && (!best || m.index < best.m.index || (m.index === best.m.index && m[0].length > best.m[0].length))) best = { g, m }; }\n if (!best) { out += esc(rest); break; }\n out += esc(rest.slice(0, best.m.index)) + termHtml(esc(best.m[0]), best.g); used.add(best.g.term); rest = rest.slice(best.m.index + best.m[0].length);\n }\n });\n return out;\n};\nconst kindsTxt = (k) => k.length > 1 ? `${k.slice(0, -1).join(', ')} and ${k.at(-1)}` : k[0];\nconst dfTag = (i) => differs(i) ? `<span class=\"dk\">Differs from base in ${esc(kindsTxt(DIFF.steps[i].kinds))}</span>` : '';\nconst chDf = (c) => { const n = DIFF ? c.idx.filter(differs).length : 0; return n ? `<span class=\"dk\">${n} ${n > 1 ? 'steps differ' : 'step differs'} from base</span>` : ''; };\nconst DSTEPS = DIFF ? P.map((_, i) => i).filter(differs) : [];\nconst andList = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : xs[0];\nconst banner = DIFF ? `<p class=\"banner\"><b>Showing <code>${esc(basename(recFile))}</code>, compared with <code>${esc(DIFF.file)}</code>.</b> ${DSTEPS.length ? `${A(DSTEPS[0], 'bl', `${DSTEPS.length} ${DSTEPS.length > 1 ? 'steps differ' : 'step differs'}`)} in ${esc(andList([...new Set(DSTEPS.flatMap((i) => DIFF.steps[i].kinds))]))}. Steps ${andList(DSTEPS.map((i) => A(i, 'bl', i + 1)))}.` : `No step differs from the base recording, <code>${esc(DIFF.file)}</code>.`}${DIFF.missingBase.length ? ` Steps only in the base, ${DIFF.missingBase.map((k) => k + 1).join(', ')}.` : ''}</p>` : '';\n// Authored text (story, outcome, chapter titles, invariants) whose `values` this run does not have but the base run does, as check.mjs --base\n// reports it. Only these get the tag; text without such values is checked against this run and needs none.\nconst STALE = (() => {\n if (!DIFF) return {};\n const B = readJson(baseFile).pauses, base = authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B)) ?? [];\n return Object.fromEntries(authoredOf(topic, P, hits, CH).map((a, k) => [a.key, a.missing.filter((v) => base[k] && !base[k].missing.includes(v))]).filter(([, vs]) => vs.length));\n})();\nconst baseTag = (key) => STALE[key] ? `<div class=\"btag\">Written for the base recording, where ${andList(STALE[key].map((v) => `<code>${esc(v.name)}</code> is <code>${esc(v.value)}</code>`))}. This run differs.</div>` : '';\nconst ruleTag = (v) => baseTag(`inv${INV.indexOf(v)}`);\nconst wherePath = (x) => esc(x).replace(/\\//g, '/<wbr>');\nconst open = (i) => `Open step ${i + 1}`;\n\n// Sequence diagram: only crossings (arrows between actors and store writes), grouped by chapter. A chapter with no crossing gets one self-call row\n// in its lane, labelled with the function and the note of the chapter's last step. A chapter whose first steps come before its first crossing\n// gets a self-call row for them at its top, so their dots never sit on a later row. Each step's dot sits on the latest row of its chapter at or before it.\n// A self-call row's label: the step's function, unless its note already starts with it, then the note.\nconst selfLabel = (i) => `${noteOf[i].text.startsWith('`' + topFn(i) + '`') ? '' : `<code>${esc(topFn(i))}</code> `}${code(noteOf[i].text)}`;\nconst xRows = seqRows.filter((r) => !r.kind.startsWith('self'));\nfor (const c of CH) if (!xRows.some((r) => r.ch === c.k)) { const row = { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.to) }; const at = xRows.findIndex((r) => r.ch > c.k); xRows.splice(at < 0 ? xRows.length : at, 0, row); }\nfor (const c of CH) { const at = xRows.findIndex((r) => r.ch === c.k); if (at >= 0 && xRows[at].o > c.from) xRows.splice(at, 0, { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.from) }); }\nP.forEach((_, i) => { const inCh = xRows.filter((r) => r.ch === chapOf[i]); (inCh.filter((r) => r.o <= i).at(-1) ?? inCh[0]).steps.push(i); });\nconst lls = LIFE.map((a) => `<span class=\"ll\" style=\"left:${LX[a.id]}%\"></span>`).join('');\nconst atX = (x, cls, label) => x > 60 ? `<span class=\"${cls} rt\" style=\"margin-right:${100 - x}%\"><span class=\"lab\">${label}</span></span>` : `<span class=\"${cls}\" style=\"margin-left:${x}%\"><span class=\"lab\">${label}</span></span>`;\n// Run ids (a token of 20 or more letters and digits, with a digit) are cut to their first 6 characters after any prefix in diagram labels; the inspector shows them whole.\nconst shortIds = (x) => String(x).replace(/\\b([a-z]+_)?(?=[0-9A-Za-z]*\\d)([0-9A-Za-z]{6})[0-9A-Za-z]{14,}\\b/g, '$1$2\\u2026');\nconst seqRow = (r) => {\n let body;\n if (r.kind === 'mk') body = atX(LX[r.at], r.kind, r.label);\n else if (r.kind.startsWith('self')) body = `<span class=\"${r.kind}\" style=\"margin-left:${LX[r.at] ?? 0}%\"><span class=\"lab\">${r.label}</span></span>`;\n else { const a = LX[r.from], b = LX[r.to], sl = shortIds(r.label); body = `<span class=\"alab ${r.kind.replace('arr ', '')}\" style=\"margin-left:${Math.min(a, b)}%\"><span class=\"lab\"${sl === r.label ? '' : ` title=\"${esc(r.label)}\"`}>${esc(sl)}</span></span><span class=\"${r.kind} ${b < a ? 'rtl' : 'ltr'}\" style=\"margin-left:${Math.min(a, b)}%;width:${Math.abs(b - a)}%\"></span>`; }\n const s0 = r.steps[0] ?? r.o;\n return `<div class=\"row${r.steps.map((s) => ' h' + s).join('')}${r.steps.some(differs) ? ' rdf' : ''}\"><span class=\"gut\">${r.steps.map((s) => A(s, `sd sd${s}${differs(s) ? ' df' : ''}`, s + 1, open(s) + (differs(s) ? `, differs from base in ${kindsTxt(DIFF.steps[s].kinds)}` : ''))).join('')}</span>${A(s0, 'area', lls + body, open(s0))}</div>`;\n};\nconst actorOf = (n) => topic.actors.find((a) => a.name === n), aboutOf = (n) => actorOf(n)?.about ?? '', roleOf = (n) => actorOf(n)?.role ?? actorOf(n)?.kind ?? '';\nconst rng = (c) => `${c.from + 1}${c.to > c.from ? '-' + (c.to + 1) : ''}`;\nconst stepsWord = (c) => c.to > c.from ? `Steps ${c.from + 1} to ${c.to + 1}` : `Step ${c.from + 1}`;\nconst chHead = (c) => `<a class=\"chh\" href=\"${href(c.from)}\" title=\"Go to step ${c.from + 1}\"><span class=\"rng\">${c.k + 1}</span> ${titleOf(c)} <span class=\"dim\">${stepsWord(c)}</span> ${chDf(c)}${c.src != null && STALE[`ch${c.src}`] ? ' <span class=\"dk\">Title written for the base recording</span>' : ''}</a>`;\nconst seqHtml = `<div class=\"seq\"><div class=\"row heads\"><span class=\"gut\"></span><span class=\"area\">${LIFE.map((a) => `<span class=\"lh\" title=\"${esc([roleOf(a.name), aboutOf(a.name)].filter(Boolean).join('. '))}\"><b>${esc(a.name)}</b>${a.sub ? `<span><code>${esc(a.sub)}</code></span>` : ''}</span>`).join('')}</span></div>${CH.map((c) => chHead(c) + xRows.filter((r) => r.ch === c.k).map(seqRow).join('')).join('')}</div>`;\nconst stepsHtml = `<div class=\"steps\"><div class=\"sh\"><span>Step</span><span>Where</span><span>What happens</span></div>${CH.map((c) => chHead(c) + `<div class=\"chg chg${c.k}\">` + c.idx.map((i) => `<a class=\"step sr sr${i}\" id=\"step-${i + 1}\" href=\"${href(i)}\" title=\"${open(i)}\"><span class=\"sn\">${i + 1}</span><span class=\"wh\"><b title=\"${esc(aboutOf(actorOfUrl(P[i].frames[0].url)))}\">${esc(actorOfUrl(P[i].frames[0].url) ?? '')}</b> <span class=\"role\">${esc(roleOf(actorOfUrl(P[i].frames[0].url)))}${laneSub(lane(actorOfUrl(P[i].frames[0].url), i)) ? `, <code>${esc(laneSub(lane(actorOfUrl(P[i].frames[0].url), i)))}</code>` : ''}</span><br> <code>${esc(topFn(i))}</code><span class=\"loc\">${wherePath(keyOf(P[i]))}</span></span><span class=\"what\">${code(noteOf[i].text)}${dfTag(i)}</span></a>`).join('') + '</div>').join('')}</div>`;\n// The chapter list (start view and Chapters level): a row per chapter with a stretched \"Go to step N\" link, so terms stay clickable above it.\nconst chapterList = `<div class=\"chs\">${CH.map((c) => `<div class=\"chr chr${c.k}\"><span class=\"cnum\">${c.k + 1}</span><div class=\"ct\">${c.title ? termify(c.title) : whoOf(c)} ${chDf(c)}${c.src != null ? baseTag(`ch${c.src}`) : ''}<span class=\"rng2\">${stepsWord(c)}</span></div><a class=\"go2\" href=\"${href(c.from)}\">Go to step ${c.from + 1}</a></div>`).join('')}${topic.outcome ? `<div class=\"chr out\"><span class=\"cnum\"></span><div class=\"ct\"><b>Outcome</b> ${termify(topic.outcome)}${baseTag('outcome')}</div></div>` : ''}</div>`;\nconst INV = topic.invariants ?? [];\nconst lower1 = (x) => x.charAt(0).toLowerCase() + x.slice(1);\nconst rulesHtml = INV.length ? `<details class=\"rules\" open><summary>Rules this flow is checked against (${INV.length})</summary><ol>${INV.map((v) => { const j = invAt(v); return `<li><div class=\"ir\">${termify(v.rule)}</div>${ruleTag(v)}<div class=\"irf\"><span class=\"loc\">${wherePath(`${v.file}:${v.line}`)}</span>${j >= 0 ? A(j, 'go', `Step ${j + 1}`, open(j)) : ''}</div></li>`; }).join('')}</ol></details>` : '';\nconst pv = rec.provenance;\nconst provenance = pv ? `A recorded run of the real code at commit <code>${esc(pv.commit)}</code>${pv.changed?.length ? ` with uncommitted changes to ${andList(pv.changed.map((f) => `<code>${esc(f)}</code>`))}` : ''}, scenario <code>${esc(rel('file://' + pv.scenario))}</code>, recorded ${esc(pv.date)}, ${pv.pauses} pauses.`\n : `A recorded run of the real code under <code>${esc(ROOT)}</code>, scenario <code>${esc(rel('file://' + (rec.scenario ?? topic.scenario)))}</code>, ${N} pauses. This recording predates provenance, so its commit and date are not stored.`;\nconst startHtml = `<section class=\"start\"><div class=\"sw\">\n<div class=\"story\">${topic.story ? termify(topic.story) : code(topic.summary ?? '')}</div>${baseTag('story')}<p class=\"prov\">${provenance}</p>\n<h2 class=\"vbg-heading-16\">Who takes part</h2><table class=\"cast\"><tbody>${topic.actors.map((a) => `<tr><td><b>${esc(a.name)}</b></td><td class=\"dim\">${esc(a.role ?? a.kind)}</td><td>${code(a.about ?? '')}</td></tr>`).join('')}</tbody></table>\n<div class=\"go1\"><a class=\"vbg-button\" data-variant=\"primary\" href=\"#step-1\">Start at step 1</a><span class=\"dim\">or pick a chapter below</span></div>\n${rulesHtml}<h2 class=\"vbg-heading-16\">Chapters</h2>${chapterList}<footer class=\"ft\"><span class=\"vbg-logo\" role=\"img\" aria-label=\"Vercel\"></span></footer></div></section>`;\n\n// Inspector panels, in a fixed order.\nconst full = (v) => { if (isPrim(v) || longStr(v)) return fmtPrim(v); const j = JSON.stringify(plain(v), null, 1).replace(/\\[\\s+([^[\\]{}]*?)\\s+\\]/g, (m, a) => `[${a.replace(/\\s*\\n\\s*/g, ' ')}]`), n = j.split('\\n').length;\n return n > 12 ? `<details class=\"more\"><summary>${Array.isArray(plain(v)) ? 'array' : 'object'}, ${n} lines</summary><span class=\"v-o pre\">${esc(j)}</span></details>` : `<span class=\"v-o pre\">${esc(j)}</span>`; };\nconst inScope = (b) => Object.keys(b).filter((n) => b[n]?.t !== 'function' && n !== 'this');\nconst changedPanel = (i) => {\n const b = varsAt(i), j = prevSame[i];\n let vt;\n if (j == null) vt = `<p class=\"dim\">First pause in this call of <code>${esc(topFn(i))}</code>, so nothing here has an earlier value. ${plural(inScope(b).length, 'variable')} in scope, listed under Variables in Panels.</p>`;\n else { const a = varsAt(j), c = inScope(b).filter((n) => !same(a[n], b[n]));\n vt = c.length ? `<p class=\"sub\">Since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call</p><table class=\"t sm\"><thead><tr><th>Name</th><th>Before</th><th>After</th></tr></thead><tbody>${c.map((n) => `<tr><td><code>${esc(n)}</code></td><td class=\"val\">${n in a ? (holderOf(a, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(a, n))}</code></span>` : full(a[n])) : '<span class=\"dim\">new</span>'}</td><td class=\"val\">${holderOf(b, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(b, n))}</code></span>` : full(b[n])}</td></tr>`).join('')}</tbody></table>` : `<p class=\"dim\">No variable changed since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call.</p>`; }\n const rw = diffs[i].map((d) => { const parts = [];\n if (d.added.length) parts.push(`<details class=\"more\"><summary>${d.added.length} ${d.added.length > 1 ? 'rows' : 'row'} added</summary><table class=\"t sm kvt\"><tbody>${d.added.map((r) => `<tr><th>${esc(pk(d.t, r))}</th><td class=\"pre\">${esc(Object.entries(r).filter(([, v]) => v !== null).map(([k, v]) => `${k} = ${sv(v)}`).join(', '))}</td></tr>`).join('')}</tbody></table></details>`);\n for (const ch of d.changed) parts.push(`row <code>${esc(ch.id)}</code> ${ch.cols.map((y) => `<code>${esc(y.c)}</code> from <span class=\"pre\">${esc(sv(y.from))}</span> to <span class=\"pre\">${esc(sv(y.to))}</span>`).join(', ')}`);\n if (d.removed.length) parts.push(`${d.removed.length} ${d.removed.length > 1 ? 'rows' : 'row'} deleted (${d.removed.map(esc).join(', ')})`);\n return `<li><code>${esc(d.t)}</code> ${parts.join('; ')}</li>`; });\n if (batch && i === batch.done) for (const [t, c] of batchRows) if (!diffs[i].some((d) => d.t === t)) rw.push(`<li><code>${esc(t)}</code> ${c} ${c > 1 ? 'rows' : 'row'} written. The recording doesn't snapshot this table, so its rows aren't shown.</li>`);\n return `${vt}${rw.length ? `<p class=\"sub\">Store rows written since the previous step${batch && i === batch.done ? `, ${rowsChanged} in all, the same count as the batch answer` : ''}</p><ul class=\"rowl\">${rw.join('')}</ul>` : ''}`;\n};\nconst stackPanel = (i) => { const s = stacks[i], showA = new Set(s.map((f) => actorOfUrl(f.url) ?? '')).size > 1;\n const tbl = (fs, head) => `<table class=\"t sm\">${head ? `<thead><tr><th>Function</th><th>File and line</th>${showA ? '<th>Actor</th>' : ''}</tr></thead>` : ''}<tbody>${fs.map((f) => `<tr><td><code>${esc(f.fn)}</code>${f.async ? ' <span class=\"dim\">awaited</span>' : ''}</td><td class=\"loc\">${esc(rel(f.url))}:${f.line}</td>${showA ? `<td>${esc(actorOfUrl(f.url) ?? '')}</td>` : ''}</tr>`).join('')}</tbody></table>`;\n return `${tbl(s.slice(0, 3), true)}${s.length > 3 ? `<details class=\"more\"><summary>${s.length - 3} more frames</summary>${tbl(s.slice(3), false)}</details>` : ''}${showA ? '' : `<p class=\"dim\">Every frame is in <b>${esc(actorOfUrl(s[0].url) ?? 'one place')}</b>.</p>`}`; };\nconst longStr = (v) => v?.t === 'string' && String(v.v).split('\\n').length > 3;\nconst holderOf = (b, n) => longStr(b[n]) ? inScope(b).find((m) => m !== n && b[m]?.t === 'string' && String(b[m].v).length > String(b[n].v).length && String(b[m].v).includes(String(b[n].v))) : undefined;\nconst varsPanel = (i) => { const b = varsAt(i), a = prevSame[i] != null ? varsAt(prevSame[i]) : null;\n return `<table class=\"t sm\"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>${inScope(b).map((n) => { const ch = a && !same(a[n], b[n]), h = holderOf(b, n);\n const v = h ? `<span class=\"dim\">Included in <code>${esc(h)}</code>, shown in that row.</span>` : valCell(b[n]);\n return `<tr${ch ? ' class=\"dupv\"' : ''}><td><code>${esc(n)}</code></td><td class=\"val\">${ch ? '<span class=\"seewc dim\">Changed, see What changed.</span><span class=\"vfull\">' + v + '</span>' : v}</td></tr>`; }).join('')}</tbody></table>`; };\nconst crossAt = (j) => reqs.some((r) => r.start === j) ? 'request' : reqs.some((r) => r.end === j) ? 'response' : batch && j === batch.at ? 'SQL batch' : batch && j === batch.done ? 'SQL answer' : null;\nconst wirePanel = (i) => { const w = wireTables(i); if (w) return w; let j = i + 1; while (j < N && !crossAt(j)) j++;\n return `<p class=\"dim\">Nothing crosses a boundary here. ${j < N ? `The next ${crossAt(j)} is at ${A(j, 'go', `step ${j + 1}`, open(j))}.` : 'No later step crosses one.'}</p>`; };\nconst rulesAt = (i) => INV.map((v, k) => ({ v, k })).filter(({ v }) => invAt(v) === i);\nconst invPanel = (i) => { const vs = rulesAt(i); return vs.length ? `<ul class=\"invl\">${vs.map(({ v, k }) => `<li>Rule ${k + 1}. ${termify(v.rule)} <span class=\"loc\">${esc(v.file)}:${v.line}</span>${ruleTag(v)}</li>`).join('')}</ul>` : `<p class=\"dim\">This step shows none of the ${INV.length} rules.</p>`; };\n// On a head page, a note at a step whose source changed was carried from the base run: its values are this run's, but its reasoning may not be.\nconst notePanel = (i) => `<div class=\"note\">${termify(noteOf[i].text)}</div>${DIFF && DIFF.steps[i].kinds.includes('source') ? `<div class=\"btag\">Written for the base recording. The source at <code>${esc(rel(P[i].frames[0].url))}:${DIFF.steps[i].at}</code> changed, so check this note against the head code.</div>` : ''}`;\nconst SHOW = [['note', 'Note', 'what this step shows, in one sentence', notePanel], ['source', 'Source', 'the code around the paused line', linesHtml], ['changed', 'What changed', 'the values that changed since the last pause in the same call, before and after', changedPanel], ['stack', 'Call stack', 'the functions that led here, innermost first', stackPanel], ['vars', 'Variables', 'every variable in scope at this line', varsPanel], ['wire', 'SQL, wire and recorded expressions', 'requests, responses and SQL that cross a boundary here, and the recorded expressions', wirePanel], ['inv', 'Rules', 'the rules from the start page that this step shows', invPanel]];\nconst LEVELS = [['ch', 'Chapters', chapterList + rulesHtml, 'Pick a chapter to go to its first step.'], ['seq', 'Sequence diagram', seqHtml, 'Click a numbered circle or an arrow to open that step.'], ['steps', 'Steps', stepsHtml, 'Click a row to open that step.']];\nconst defs = Object.fromEntries((process.env.VIEWER_DEFAULTS ?? '').split(';').filter(Boolean).map((kv) => kv.split('=')));\nconst lvOn = defs.level ?? 'ch', inspOn = (defs.inspector ?? 'on') !== 'off', showOn = new Set((defs.show ?? 'note,changed').split(','));\nif (!LEVELS.some(([k]) => k === lvOn) || [...showOn].some((k) => !SHOW.some(([s]) => s === k))) { console.error(`bad VIEWER_DEFAULTS ${process.env.VIEWER_DEFAULTS}`); process.exit(2); }\nconst diffBlock = (i) => { if (!differs(i)) return ''; const d = DIFF.steps[i];\n return `<div class=\"dfl\">${dfTag(i)}${d.was != null ? `<div class=\"srcdiff\"><div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(d.was.trim())}</del></span></div><div class=\"ln now\"><span class=\"no\">head</span><span class=\"src\">${esc(d.now.trim())}</span></div><div class=\"dim\">${esc(rel(P[i].frames[0].url))}:${d.at}</div></div>` : ''}${d.detail.length ? `<div class=\"dd2\">${d.detail.map(esc).join('; ')}</div>` : ''}</div>`; };\nconst badge = (i) => rulesAt(i).map(({ v, k }) => `<div class=\"badge\">This step shows rule ${k + 1}: ${termify(lower1(v.rule))}${ruleTag(v)}</div>`).join('');\nconst article = (i) => `<article class=\"art art${i}\">${diffBlock(i)}<p class=\"emp\">No panel is on. Pick some under Panels above.</p>${SHOW.map(([k, n, , f]) => `<section class=\"pn p-${k}\"><h3 class=\"vbg-heading-16\">${n}</h3>${f(i)}</section>${k === 'note' ? badge(i) : ''}`).join('')}</article>`;\nconst bar = P.map((_, i) => { const c = CH[chapOf[i]]; return `<div class=\"stp s${i}\"><span class=\"cnt\">Step ${i + 1} of ${N}</span><span class=\"chn\">Chapter ${c.k + 1} of ${CH.length}, ${c.title ? code(c.title) : whoOf(c)}</span><span class=\"btns\">${i ? A(i - 1, 'vbg-button', 'Previous', open(i - 1)) : '<span class=\"vbg-button btn off\">Previous</span>'}${i < N - 1 ? `<a class=\"vbg-button\" data-variant=\"primary\" href=\"${href(i + 1)}\" title=\"${esc(open(i + 1))}\">Next</a>` : '<span class=\"vbg-button btn off\" data-variant=\"primary\">Next</span>'}</span></div>`; }).join('');\nconst legend = `<details class=\"dd\"><summary class=\"vbg-button\">How to read this</summary><div class=\"ddc\"><ul class=\"lgd\">\n<li><i class=\"lg arr1\"></i>A solid arrow is a call or a request from one actor to another.${reqs.length || batch ? ` Its label names the ${andList([reqs.length && 'HTTP request', batch && 'SQL statement'].filter(Boolean))}.` : ''}</li>\n<li><i class=\"lg arr2\"></i>A dashed arrow is the response to a request.</li>\n${TABLES.length ? '<li><i class=\"lg sq\"></i>A square is a write to a store the run made between two steps, with the tables and rows written.</li>' : ''}\n<li><span class=\"sd\">7</span>A numbered circle is a step. Click it to open that step.</li>\n${DIFF ? '<li><span class=\"sd df\">7</span><span class=\"dk\">Differs</span>A red ring or a red tag means the step differs from the base recording.</li>' : ''}\n<li><i class=\"lg hl\"></i>A highlighted row is the current step.</li>\n<li><span class=\"term-demo\">term</span>A dotted underline is a term. Click it to read its definition.</li>\n<li>A step is one pause of the recorded run at a line the page's author chose, in the order the run reached them. The page replays the recording; nothing runs live.</li>\n<li>A recorded expression is a value the page's author asked the recorder to evaluate at that pause, shown in What changed and in SQL, wire and recorded expressions.</li>\n</ul></div></details>`;\nconst glossary = GL.length ? `<details class=\"dd\"><summary class=\"vbg-button\">Glossary</summary><div class=\"ddc\"><dl class=\"gl\">${[...GL].sort((x, y) => x.term.localeCompare(y.term, 'en', { sensitivity: 'base' })).map((g) => `<dt>${/^[a-z][a-z ]*$/.test(g.term) ? esc(g.term) : `<code>${esc(g.term)}</code>`}</dt><dd>${esc(g.def)}</dd>`).join('')}</dl></div></details>` : '';\nconst sel = (i) => `body:has(#step-${i + 1}:target)`;\nconst masks = Array.from({ length: 1 << SHOW.length }, (_, m) => `.ictl${SHOW.map(([k], b) => (m >> b) & 1 ? `:has(#sh-${k}:checked)` : `:not(:has(#sh-${k}:checked))`).join('')} .pc${[...m.toString(2)].filter((x) => x === '1').length}{display:inline}`);\n// Vercel's published Geist foundation, inlined with its logo images embedded as data URLs so the page opens offline: its tokens, type and controls.\nconst VBG_URL = 'https://vercel.com/geist/vercel-brand.css';\nconst vbgRes = await fetch(VBG_URL, { signal: AbortSignal.timeout(30_000) });\nif (!vbgRes.ok || !/text\\/css/.test(vbgRes.headers.get('content-type') ?? '')) throw new Error(`${VBG_URL}: HTTP ${vbgRes.status} ${vbgRes.headers.get('content-type')}`);\nconst vbgRemote = await vbgRes.text();\nconst vbgAssets = new Map(await Promise.all([...new Set(vbgRemote.match(/https:\\/\\/[^\"')\\s]+/g) ?? [])].map(async (url) => {\n const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });\n const type = res.headers.get('content-type')?.split(';')[0] ?? '';\n if (!res.ok || !type.startsWith('image/')) throw new Error(`${url}: HTTP ${res.status} ${type}`);\n return [url, `data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`];\n})));\nconst vbgCss = vbgRemote.replace(/https:\\/\\/[^\"')\\s]+/g, (url) => vbgAssets.get(url));\nconst mix = (token, pct) => `color-mix(in oklch,var(${token}) ${pct}%,var(--vbg-surface-primary))`;\nconst SEL = mix('--vbg-text-primary', 10), ADD = mix('--vbg-color-success', 14), REM = mix('--vbg-color-error', 12), REM_ROW = mix('--vbg-color-error', 6), CHG = mix('--vbg-color-warning', 16);\nconst CHEVRON = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M14.06 5.5l-.53.53-4.82 4.82a1 1 0 0 1-1.42 0L2.47 6.03 1.94 5.5 3 4.44l.53.53L8 9.44l4.47-4.47.53-.53 1.06 1.06z'/%3E%3C/svg%3E\")`;\nconst rules = [\n 'body:has(.step:target) .start{display:none}body:not(:has(.step:target)) :is(.cols,.stepbar,.help){display:none}',\n ...LEVELS.map(([k]) => `body:has(#lv-${k}:checked) .h-${k}{display:block}body:has(#lv-${k}:checked) .lv-${k}{visibility:visible;z-index:1}`),\n ...SHOW.map(([k]) => `body:not(:has(#sh-${k}:checked)) .p-${k}{display:none}`),\n 'body:not(:has(.show:checked)) .emp{display:block}',\n 'body:has(#sh-changed:checked) .dupv .vfull{display:none}body:has(#sh-changed:checked) .dupv .seewc{display:inline}',\n 'body:has(#lv-steps:checked) .allsteps{display:inline-flex}body:has(#sh-all:checked) .chg .sr{display:grid}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .chg${c.k} .sr{display:grid}`),\n ...masks,\n 'body:not(:has(#sh-insp:checked)) .cols{grid-template-columns:minmax(0,1fr)}body:not(:has(#sh-insp:checked)) .col.insp{display:none}body:not(:has(#sh-insp:checked)) .col.nav{border-right:0}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .cols .chr${c.k}{background:${SEL}}`),\n ...P.map((_, i) => { const S = sel(i); return `${S} .stp.s${i}{display:flex}${S} .art${i}{display:block}${S} .sr${i}{background:${SEL}}${S} .sr${i} .sn,${S} .sd${i}{background:var(--vbg-text-primary);color:var(--vbg-surface-primary);border-color:var(--vbg-text-primary)}${S} .seq .h${i},${sel(i)} .h${i} .lab{background:${SEL}}`; }),\n].join('\\n');\nconst colourCss = [...colours].map(([k, c]) => { const [l] = k.split('|'); return `.${c}{color:${l}}`; }).join('') + '@media (prefers-color-scheme:dark){' + [...colours].map(([k, c]) => `.${c}{color:${k.split('|')[1]}}`).join('') + '}';\nconst fontDir = `${dirname(require.resolve('geist/font'))}/fonts`;\nconst b64 = (f) => readFileSync(`${fontDir}/${f}`).toString('base64');\nconst html = `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>${esc(topic.title)}</title><style>${vbgCss}</style><style>\n@font-face{font-family:Geist;src:url(data:font/woff2;base64,${b64('geist-sans/Geist-Variable.woff2')}) format(\"woff2\");font-weight:100 900}\n@font-face{font-family:\"Geist Mono\";src:url(data:font/woff2;base64,${b64('geist-mono/GeistMono-Regular.woff2')}) format(\"woff2\")}\nhtml,body{margin:0;height:100%}body{overflow:hidden;font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact)}\na{color:inherit;text-decoration:none}\ncode{overflow-wrap:anywhere}.loc,.code,.pre{font-family:\"Geist Mono\",ui-monospace,monospace;font-size:12px}\n.dim{color:var(--vbg-text-secondary);font-weight:var(--vbg-weight-regular)}.loc{color:var(--vbg-text-secondary);overflow-wrap:anywhere}\n.app{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr);grid-template-columns:minmax(0,1fr)}\n.top{border-bottom:1px solid var(--vbg-border-subtle);padding:var(--vbg-space-3) var(--vbg-space-6);background:var(--vbg-surface-primary);z-index:5;position:relative}\n.trow{display:flex;align-items:center;gap:var(--vbg-space-4)}.top h1{flex:1;min-width:0;margin:0;overflow-wrap:anywhere}\n.tools{display:flex;gap:var(--vbg-space-2);flex:none}\n.dd{position:relative}.dd>summary{list-style:none;white-space:nowrap;box-sizing:border-box}.dd>summary::-webkit-details-marker{display:none}\n.term,.dd,.stq,.clip,.more,.rules{border-top:0;padding-block:0}.term>summary,.stq>summary,.clip>summary,.more>summary{font-weight:inherit}\n.more>summary,.rules>summary{list-style:none;display:flex;white-space:nowrap;align-items:center;gap:var(--vbg-space-1)}.more>summary::-webkit-details-marker,.rules>summary::-webkit-details-marker{display:none}\n.more>summary::before,.rules>summary::before{transform:rotate(-90deg)}.more[open]>summary::before,.rules[open]>summary::before{transform:none}.more>summary::before{width:12px;height:12px}\n.dd>summary::after,.stq>summary>span:first-child::before,.more>summary::before,.rules>summary::before{content:\"\";display:inline-block;flex:none;width:16px;height:16px;background:currentColor;-webkit-mask:${CHEVRON} center/contain no-repeat;mask:${CHEVRON} center/contain no-repeat}\n.ddc{position:absolute;right:0;top:calc(100% + var(--vbg-space-2));z-index:30;width:440px;max-height:70vh;overflow:auto;background:var(--vbg-surface-primary);border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius);box-shadow:0 4px 8px -4px rgba(0,0,0,.08),0 16px 24px -8px rgba(0,0,0,.12);padding:var(--vbg-space-3) var(--vbg-space-4)}\n.lgd{list-style:none;margin:0;padding:0}.lgd li{display:flex;gap:var(--vbg-space-3);align-items:center;padding:var(--vbg-space-1) 0}.lgd li>:first-child{flex:none}\n.lg{display:inline-block;width:32px;height:12px;position:relative;color:var(--vbg-text-secondary)}.lg.arr1{border-bottom:1.5px solid currentColor;height:7px}.lg.arr1::after,.lg.arr2::after{content:\"\";position:absolute;right:-2px;bottom:-5px;border:4px solid transparent;border-left:7px solid currentColor;border-right:0}\n.lg.arr2{border-bottom:1.5px dashed currentColor;height:7px}.lg.sq{width:12px;height:12px;border-radius:2px;background:currentColor;margin:0 10px}.lg.hl{background:${SEL};height:16px}\n.term-demo{text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}\n.gl{margin:0}.gl dt{font-weight:var(--vbg-weight-semibold);margin-top:var(--vbg-space-2)}.gl dt:first-child{margin-top:0}.gl dd{margin:var(--vbg-space-1) 0 0;color:var(--vbg-text-secondary)}\n.stepbar{margin-top:var(--vbg-space-2)}.stp{display:none;align-items:center;gap:var(--vbg-space-4);min-width:0}.cnt{flex:none;font-weight:var(--vbg-weight-semibold);font-variant-numeric:tabular-nums}\n.chn{flex:1;min-width:0;color:var(--vbg-text-secondary);font-size:var(--vbg-type-label)}.chn code{font-size:12px}\n.btns{flex:none;display:flex;gap:var(--vbg-space-2)}.btn.off{opacity:.5;cursor:not-allowed;pointer-events:none}\n.help{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.help>span{display:none}.help>span.hn{display:inline}\n.banner{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.banner b{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-medium)}.banner .bl{color:var(--vbg-text-primary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px}\n.start{overflow:auto;min-height:0}.sw{max-width:760px;margin:0 auto;padding:var(--vbg-space-8) var(--vbg-space-6) var(--vbg-space-10)}\n.story{font-size:var(--vbg-type-lede);line-height:var(--vbg-leading-lede)}.prov{font-size:var(--vbg-type-metadata);line-height:var(--vbg-leading-caption);color:var(--vbg-text-secondary);margin:var(--vbg-space-3) 0 0}.prov code{background:none;border:0;padding:0}\n.start h2{margin:var(--vbg-space-8) 0 var(--vbg-space-2)}\n.cast{border-collapse:collapse;width:100%}.cast td{padding:var(--vbg-space-2) var(--vbg-space-3) var(--vbg-space-2) 0;border-bottom:1px solid var(--vbg-border-subtle);vertical-align:baseline}.cast td:first-child{white-space:nowrap;width:1%}.cast td:nth-child(2){width:1%;padding-right:var(--vbg-space-4)}\n.go1{display:flex;align-items:center;gap:var(--vbg-space-4);margin:var(--vbg-space-6) 0 var(--vbg-space-1)}\n.chs{border-top:1px solid var(--vbg-border-default)}\n.chr{position:relative;display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:0 var(--vbg-space-3);align-items:baseline;padding:var(--vbg-space-3);border-bottom:1px solid var(--vbg-border-subtle)}.chr:hover{background:var(--vbg-surface-secondary)}\n.cnum{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);text-align:right}.ct{font-weight:var(--vbg-weight-medium);overflow-wrap:anywhere}.rng2{display:block;font-weight:var(--vbg-weight-regular);font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}\n.go2{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);white-space:nowrap}.go2::after{content:\"\";position:absolute;inset:0}.chr:hover .go2{color:var(--vbg-text-primary);text-decoration:underline;text-underline-offset:3px}\n.chr .term,.ir .term,.note .term,.story .term,.badge .term,.invl .term{position:relative;z-index:2}\n.chr.out .ct{font-weight:var(--vbg-weight-regular)}.chr.out b{margin-right:var(--vbg-space-2)}\n.term{display:inline-block}.term[open]{display:block}.term>summary{display:inline;list-style:none;cursor:help;text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}.term>summary::-webkit-details-marker{display:none}.term>summary:hover,.term[open]>summary{text-decoration-color:currentColor}\n.term .def{display:block;margin:var(--vbg-space-1) 0 var(--vbg-space-2);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact);font-weight:var(--vbg-weight-regular);color:var(--vbg-text-primary);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}.term .def b{margin-right:var(--vbg-space-2)}\n.rules{margin:var(--vbg-space-6) 0 0;border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius);padding:0 var(--vbg-space-4)}.rules>summary{cursor:pointer;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) 0}.rules ol{margin:0 0 var(--vbg-space-3);padding-left:var(--vbg-space-6)}.rules li{margin:0 0 var(--vbg-space-3)}.irf{display:flex;gap:var(--vbg-space-3);align-items:baseline;margin-top:var(--vbg-space-1)}\n.lv .chs{border-top:0}.lv .rules{margin:var(--vbg-space-3)}\n.cols{display:grid;grid-template-columns:minmax(0,45fr) minmax(0,55fr);min-height:0}\n.col{display:flex;flex-direction:column;min-height:0;min-width:0}.col.nav{border-right:1px solid var(--vbg-border-subtle)}\n.ctl{flex:none;display:flex;flex-wrap:wrap;align-items:center;gap:var(--vbg-space-2) var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-4);border-bottom:1px solid var(--vbg-border-subtle)}\n.clab{font-size:var(--vbg-type-label);font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary)}\n.spec{display:flex;flex-direction:column;gap:var(--vbg-space-1)}.seg{display:flex;gap:2px;padding:2px;border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius-small);background:var(--vbg-surface-secondary)}\n.seg input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}\n.seg label{padding:var(--vbg-space-1) var(--vbg-space-3);font-size:var(--vbg-type-label);font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);cursor:pointer;border-radius:4px}\n.seg label:hover{color:var(--vbg-text-primary)}.seg input:checked+label{background:var(--vbg-surface-primary);color:var(--vbg-text-primary);box-shadow:0 0 0 1px var(--vbg-border-default)}.seg input:focus-visible+label{outline:2px solid var(--vbg-focus);outline-offset:1px}\n.cap{font-size:12px;color:var(--vbg-text-secondary);text-align:center}\n.chk{display:inline-flex;align-items:center;gap:var(--vbg-space-2);font-size:var(--vbg-type-label);cursor:pointer;white-space:nowrap}.chk input{accent-color:var(--vbg-text-primary);margin:0;width:16px;height:16px;cursor:pointer;flex:none}\n.navshow{margin-left:auto}.allsteps{display:none}.chg .sr{display:none}.role{font-size:12px;color:var(--vbg-text-secondary)}\n.seewc{display:none}.btag{margin:var(--vbg-space-1) 0 2px;padding:2px var(--vbg-space-2);font-size:12px;line-height:16px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;display:block}\n.stl{font-size:12px;border-top:1px solid var(--vbg-border-default)}.sth,.stq>summary{display:grid;grid-template-columns:minmax(76px,22%) minmax(0,1fr) 92px;gap:0 var(--vbg-space-3);padding:var(--vbg-space-1) var(--vbg-space-2)}.sth{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-secondary);border-bottom:1px solid var(--vbg-border-default)}.sth span:last-child{text-align:right}\n.stq{border-bottom:1px solid var(--vbg-border-subtle)}.stq>summary{list-style:none;cursor:pointer;font-family:\"Geist Mono\",monospace}.stq>summary::-webkit-details-marker{display:none}.stq>summary:hover{background:var(--vbg-surface-secondary)}.stq>summary>span:first-child::before{width:12px;height:12px;margin-right:var(--vbg-space-1);vertical-align:-2px;color:var(--vbg-text-secondary);transform:rotate(-90deg)}.stq[open]>summary>span:first-child::before{transform:none}\n.stq>summary code{background:none;border:0;padding:0;overflow-wrap:normal;word-break:keep-all;white-space:nowrap}.stq .num{text-align:right}.stb{padding:var(--vbg-space-1) var(--vbg-space-2) var(--vbg-space-3) var(--vbg-space-6)}.stb .pre{background:var(--vbg-surface-secondary);border-radius:4px;padding:var(--vbg-space-1) var(--vbg-space-2)}\n.stsplit{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:var(--vbg-space-2) var(--vbg-space-2) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default)}\n.ictl{justify-content:space-between}.pc{display:none}\n.pnl .ddc{left:auto;right:0;width:400px}.pnl label{display:flex;align-items:flex-start;gap:var(--vbg-space-2);padding:var(--vbg-space-1) 0;cursor:pointer;white-space:normal}.pnl label input{margin-top:3px;accent-color:var(--vbg-text-primary)}.pnl label b{font-weight:var(--vbg-weight-medium);display:block}.pnl label span{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}\n.scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain}\n/* Each detail level scrolls on its own and stays laid out while hidden, so switching levels keeps each one's scroll position. */\n.nav .scroll{position:relative;overflow:hidden}.lv{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;visibility:hidden}\n.art,.emp{display:none}\n.chh{display:block;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) var(--vbg-space-3) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default);cursor:pointer;overflow-wrap:anywhere}.chh:hover{text-decoration:underline;text-underline-offset:3px}.chh .dim{font-size:12px;margin-left:var(--vbg-space-1)}.rng{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);margin-right:var(--vbg-space-2)}\n.steps{font-size:var(--vbg-type-label)}.sh,.sr{display:grid;grid-template-columns:36px minmax(min-content,34%) minmax(0,1fr);gap:0 var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-3)}.sh{font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-primary);border-bottom:1px solid var(--vbg-border-default);position:sticky;top:0;z-index:2}\n.sr{border-bottom:1px solid var(--vbg-border-subtle);cursor:pointer;scroll-margin-top:120px}.sr:hover{background:var(--vbg-surface-secondary)}.sr>span{min-width:0;overflow-wrap:anywhere}.sr>span.wh{overflow-wrap:normal}.sr .wh b{font-weight:var(--vbg-weight-medium)}.sr .wh .loc{display:block;margin-top:2px;overflow-wrap:normal;word-break:normal}\n.sn{display:inline-block;align-self:start;min-width:28px;text-align:center;font-size:12px;line-height:20px;font-variant-numeric:tabular-nums;border:1px solid var(--vbg-border-strong);border-radius:4px;color:var(--vbg-text-secondary)}.sr:hover .sn{border-color:var(--vbg-text-primary);color:var(--vbg-text-primary)}\n.seq{font-size:12px}.seq .row{display:flex;cursor:pointer}.seq .row:hover{background:var(--vbg-surface-secondary)}.seq .gut{display:flex;flex-wrap:wrap;gap:var(--vbg-space-1);align-content:flex-start;justify-content:flex-end;flex:0 0 60px;padding:var(--vbg-space-1) var(--vbg-space-2) 0 0}\n.seq .area{position:relative;flex:1;min-width:0;display:flex;flex-direction:column;padding:2px 0;min-height:24px;margin-right:var(--vbg-space-3)}.seq a.area{color:inherit}\n.sd{display:inline-block;min-width:20px;height:18px;text-align:center;border:1px solid var(--vbg-border-strong);border-radius:9px;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);background:var(--vbg-surface-primary);cursor:pointer;padding:0 var(--vbg-space-1)}.sd:hover{border-color:var(--vbg-text-primary);color:var(--vbg-text-primary)}\n.ll{position:absolute;top:0;bottom:0;border-left:1px dashed var(--vbg-border-default)}.heads{position:sticky;top:0;z-index:3;background:var(--vbg-surface-primary);cursor:default!important;border-bottom:1px solid var(--vbg-border-subtle)}.heads:hover{background:var(--vbg-surface-primary)!important}.heads .area{min-height:40px}\n.seq .heads .area{flex-direction:row;align-items:flex-start}.lh{flex:1 1 0;min-width:0;text-align:center;padding:var(--vbg-space-1) var(--vbg-space-1) var(--vbg-space-2)}.lh b{display:block;font-size:12px;line-height:16px;font-weight:var(--vbg-weight-semibold);overflow-wrap:anywhere}.lh code{font-size:12px}.lh span{display:block;font-size:12px;line-height:16px;color:var(--vbg-text-secondary)}\n.lab{position:relative;background:var(--vbg-surface-primary);overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace;font-size:12px;padding:0 var(--vbg-space-1);border-radius:4px}.row:hover .lab,.lab code{background:none;border:0;padding:0;font-size:12px;font-weight:var(--vbg-weight-semibold)}\n.alab{display:block;padding:0 var(--vbg-space-2)}\n.arr{position:relative;display:block;height:0;border-bottom:1.5px solid var(--vbg-text-secondary);margin:1px 0 var(--vbg-space-1);color:var(--vbg-text-secondary)}.arr.ret{border-bottom-style:dashed}\n.arr::after{content:\"\";position:absolute;bottom:-5px;border:4px solid transparent}.arr.ltr::after{right:-2px;border-left:7px solid currentColor;border-right:0}.arr.rtl::after{left:-2px;border-right:7px solid currentColor;border-left:0}\n.self{display:block;border-left:2px solid var(--vbg-border-default);padding-left:var(--vbg-space-2);color:var(--vbg-text-secondary)}.self .lab{font-family:Geist,sans-serif}.self .lab code{font-family:\"Geist Mono\",monospace;font-weight:var(--vbg-weight-medium)}\n.mk{display:block;position:relative;padding-left:var(--vbg-space-3)}.mk::before{content:\"\";position:absolute;left:-5px;top:4px;width:10px;height:10px;border-radius:2px;background:var(--vbg-text-secondary)}.mk.rt{padding-left:0;padding-right:var(--vbg-space-3);text-align:right}.mk.rt::before{left:auto;right:-5px}\n.dk{display:inline-block;font-family:Geist,sans-serif;font-size:12px;line-height:16px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-error);background:${REM};border-radius:4px;padding:0 var(--vbg-space-1);margin-left:var(--vbg-space-2);white-space:nowrap;vertical-align:1px}\n.what .dk{display:table;margin:var(--vbg-space-1) 0 0}.sd.df{border-color:var(--vbg-color-error);box-shadow:0 0 0 1px var(--vbg-color-error);color:var(--vbg-color-error)}.seq .row.rdf{background:${REM_ROW}}.lgd .dk{margin:0}\n.insp .scroll{padding:0 var(--vbg-space-6) var(--vbg-space-10)}\n.emp{color:var(--vbg-text-secondary);margin:var(--vbg-space-6) 0}\n.pn{padding:var(--vbg-space-3) 0;border-bottom:1px solid var(--vbg-border-subtle)}.pn h3{margin:0 0 var(--vbg-space-2)}.sub{font-size:12px;color:var(--vbg-text-secondary);margin:var(--vbg-space-2) 0 var(--vbg-space-1)}\n.note{font-size:var(--vbg-type-body);line-height:var(--vbg-leading-body)}\n.badge{margin:var(--vbg-space-2) 0 var(--vbg-space-1);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-label);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}\n.dfl{margin:var(--vbg-space-3) 0 0;padding:var(--vbg-space-2) var(--vbg-space-3);border:1px solid var(--vbg-color-error);border-radius:var(--vbg-radius-small)}.dfl>.dk{margin:0 0 var(--vbg-space-2)}.dd2{font:12px/18px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);overflow-wrap:anywhere;margin-top:var(--vbg-space-1)}\n.srcdiff{font:12px/18px \"Geist Mono\",monospace;margin-top:var(--vbg-space-2)}.srcdiff .dim{margin-top:2px}\n.ln.was{background:${REM}}.ln.was del{text-decoration:line-through;text-decoration-color:var(--vbg-color-error);color:var(--vbg-text-secondary)}.ln.now{background:${ADD}}.ln.was .no,.ln.now .no{font-family:Geist,sans-serif}\n.t{border-collapse:collapse;width:100%}.t th,.t td{text-align:left;vertical-align:baseline;padding:var(--vbg-space-1) var(--vbg-space-2);border-bottom:1px solid var(--vbg-border-subtle)}.t thead th{font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-secondary);border-bottom:1px solid var(--vbg-border-default)}\n.sm{font-size:12px}.sm td{overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace}.sm td code{background:none;border:0;padding:0}.sm td:first-child code{white-space:nowrap}.kvt th{font:var(--vbg-weight-medium) 12px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);width:1%;overflow-wrap:break-word;word-break:normal}\n.tagc{font-family:Geist,sans-serif;font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;padding:0 var(--vbg-space-1)}\n.stmts tr.split td{border-top:2px solid var(--vbg-border-strong)}.num{text-align:right}.stmts td:nth-child(-n+2){white-space:nowrap}.stmts .sql{min-width:18ch}\n.pre{white-space:pre-wrap;overflow-wrap:anywhere}.val{overflow-wrap:anywhere}\n.rowl{margin:0;padding-left:var(--vbg-space-5);font-size:12px}.rowl li{margin:2px 0}\n.more summary,.clip summary{cursor:pointer;color:var(--vbg-text-secondary);font-size:12px;padding:var(--vbg-space-1) 0}.more summary:hover,.clip summary:hover{color:var(--vbg-text-primary)}.clip{display:inline}.clip[open] summary{display:none}.full{white-space:pre-wrap}\n.go{display:inline-block;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px;white-space:nowrap}.go:hover{color:var(--vbg-text-primary);text-decoration-color:currentColor}\n.invl{margin:0;padding-left:var(--vbg-space-5)}.invl li{margin:0 0 var(--vbg-space-2)}\n.file{margin:0 0 var(--vbg-space-2);font-size:var(--vbg-type-label);overflow-wrap:anywhere}.file code{background:none;border:0;padding:0}.code{border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small);padding:var(--vbg-space-1) 0;overflow:hidden}\n.ln{display:flex;white-space:pre-wrap;overflow-wrap:anywhere;line-height:18px}.src{min-width:0;flex:1;padding-right:var(--vbg-space-2)}.ln.cur{background:${SEL}}.no{width:40px;flex:none;text-align:right;padding-right:var(--vbg-space-3);color:var(--vbg-text-secondary)}.ln.cur .no{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-semibold)}\n.kv{border-collapse:collapse;font:12px/18px \"Geist Mono\",monospace;width:100%}.kv th{text-align:left;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:1px var(--vbg-space-3) 1px 0;vertical-align:baseline;overflow-wrap:break-word;word-break:normal;border:0}.kv td{overflow-wrap:anywhere;padding:1px 0;border:0}\n.iv{margin-left:var(--vbg-space-3);padding:0 var(--vbg-space-1);border-radius:4px;background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);font-size:12px;color:var(--vbg-text-secondary);white-space:normal;overflow-wrap:anywhere;display:inline-block;max-width:100%;vertical-align:top}.iv.new,.iv.chg{background:${CHG}}\n.v-s,.v-n,.v-b{color:var(--vbg-text-primary)}.v-u,.v-o,.v-f{color:var(--vbg-text-secondary)}\n.ft{display:flex;align-items:center;gap:var(--vbg-space-3);margin-top:var(--vbg-space-10);color:var(--vbg-text-secondary);font-size:var(--vbg-type-metadata)}\n@media (max-width:900px){body{overflow:auto}.app{height:auto;display:block}.top{position:sticky;top:0;padding:var(--vbg-space-3) var(--vbg-space-4)}.trow .vbg-identity{display:none}.cols{grid-template-columns:minmax(0,1fr)}.col.nav{border-right:0;border-bottom:1px solid var(--vbg-border-subtle)}.col.nav .scroll{height:60vh}.insp .scroll{overflow:visible}.sr{scroll-margin-top:160px}.stp{flex-wrap:wrap}}\n${colourCss}\n${rules}\n</style></head><body class=\"vbg-report\"><div class=\"app\">\n<header class=\"top\"><div class=\"trow\"><span class=\"vbg-identity\"><span class=\"vbg-wordmark\" role=\"img\" aria-label=\"Vercel\"></span></span><h1 class=\"vbg-heading-16\">${code(topic.title)}</h1><div class=\"tools\">${legend}${glossary}</div></div>${banner}<div class=\"stepbar\">${bar}</div><p class=\"help\">${LEVELS.map(([k, , , h]) => `<span class=\"h-${k}\">${h}</span>`).join('')} <span class=\"hn\">Use Previous and Next to step through.</span></p></header>\n${startHtml}\n<div class=\"cols\">\n<section class=\"col nav\" aria-label=\"Navigator\"><div class=\"ctl\"><span class=\"clab\">Detail</span><div class=\"spec\"><div class=\"seg\" role=\"radiogroup\" aria-label=\"Detail\">${LEVELS.map(([k, n]) => `<input type=\"radio\" name=\"level\" id=\"lv-${k}\"${k === lvOn ? ' checked' : ''}><label for=\"lv-${k}\">${n}</label>`).join('')}</div><div class=\"cap\">Less detail to more detail</div></div><label class=\"chk allsteps\"><input type=\"checkbox\" id=\"sh-all\">Show all ${N} steps</label><label class=\"chk navshow\"><input type=\"checkbox\" id=\"sh-insp\"${inspOn ? ' checked' : ''}>Show inspector</label></div>\n<div class=\"scroll\">${LEVELS.map(([k, , h]) => `<div class=\"lv lv-${k}\">${h}</div>`).join('')}</div></section>\n<section class=\"col insp\" aria-label=\"Inspector\"><div class=\"ctl ictl\"><span class=\"clab\">Inspector, the current step in detail</span><details class=\"dd pnl\"><summary class=\"vbg-button\">${SHOW.map((_, k) => `<span class=\"pc pc${k}\">Panels, ${k} of ${SHOW.length}</span>`).join('')}<span class=\"pc pc${SHOW.length}\">Panels, ${SHOW.length} of ${SHOW.length}</span></summary><div class=\"ddc\">${SHOW.map(([k, n, d]) => `<label><input type=\"checkbox\" class=\"show\" id=\"sh-${k}\"${showOn.has(k) ? ' checked' : ''}><span><b>${n}</b>${d}</span></label>`).join('')}</div></details></div>\n<div class=\"scroll\">${P.map((_, i) => article(i)).join('')}</div></section>\n</div></div></body></html>`;\nwriteFileSync(out, html);\nconsole.log(out, Buffer.byteLength(html));\n","check.mjs":"// Fails (exit 1) when a note or the topic's authored text says something the recording does not show.\n// Usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]\n// --base: for the head page of a two-recording pair. Authored values that hold in the base but not here are reported as\n// \"written for the base recording\" (the page tags them) instead of failing.\nimport { readFileSync } from 'node:fs';\nimport { readJson, loadTopic, rootOf, relTo, pointPath, urlOf, stackOf, hitsOf, stepAt, leavesOf, nameMatches, CITE, sameFile, WINDOW, invStep, chaptersOf, authoredOf, sourceOf } from './lib.mjs';\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile] = argv;\nif (!notesFile) { console.error('usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, root = rootOf(topic, rec), rel = relTo(root, topic.dir), hits = hitsOf(P);\nconst fail = [], warn = [];\nconst points = new Map(topic.points.map((p) => [p.id, p]));\nconst hitPoints = new Set(P.map((p) => p.point));\n// A breakpoint position counts as recorded when its point was hit; so does the line V8 actually paused on.\nconst bpLines = new Set(topic.points.filter((p) => hitPoints.has(p.id)).map((p) => `${rel(urlOf(pointPath(topic, root, p)))}:${p.line}`));\nfor (const p of P) bpLines.add(`${rel(p.frames[0].url)}:${p.frames[0].line}`);\nconst covered = new Set();\nlet refs = 0, values = 0;\n// Register: no em dash and no ≠ in anything the page prints from the author.\nconst register = (s, at) => { for (const [ch, name] of [['\\u2014', 'an em dash'], ['\\u2260', 'the \\u2260 glyph']]) if (String(s).includes(ch)) fail.push(`${at}: uses ${name}; write a sentence instead`); };\nnotes.forEach((n, k) => {\n const at = `note ${k} (${n.point}#${n.hit ?? 0})`;\n register(n.text, at);\n if (!points.has(n.point)) return fail.push(`${at}: point ${n.point} is not in topic.points`);\n const i = stepAt(P, hits, n.point, n.hit ?? 0);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${n.hit ?? 0} at ${n.point}`);\n if (covered.has(i)) fail.push(`${at}: step ${i + 1} already has a note`);\n covered.add(i);\n const frames = new Set(stackOf(P[i]).map((f) => `${rel(f.url)}:${f.line}`));\n for (const r of n.refs ?? []) {\n refs++;\n const key = `${r.file}:${r.line}`;\n if (!frames.has(key) && !bpLines.has(key)) fail.push(`${at}: ref ${key} is neither a frame of step ${i + 1} nor a breakpoint that was hit`);\n }\n for (const m of String(n.text).matchAll(CITE)) if (!(n.refs ?? []).some((r) => sameFile(r.file, m[1]) && r.line === +m[2])) fail.push(`${at}: text cites ${m[1]}:${m[2]}, which is not in its refs`);\n const leaves = leavesOf(P[i]);\n for (const v of n.values ?? []) {\n values++;\n if (!leaves.some((l) => nameMatches(l.path, v.name) && l.str === String(v.value))) {\n const near = leaves.filter((l) => nameMatches(l.path, v.name)).slice(0, 3).map((l) => `${l.path}=${l.str.slice(0, 60)}`);\n fail.push(`${at}: value ${v.name} = ${JSON.stringify(v.value)} is not in step ${i + 1}'s scopes, watch or rows${near.length ? ` (it has ${near.join('; ')})` : ''}`);\n }\n }\n});\nP.forEach((p, i) => { if (!covered.has(i)) fail.push(`step ${i + 1} (${p.point}#${hits[i]}, ${rel(p.frames[0].url)}:${p.frames[0].line}) has no note`); });\n// Invariants: a rule, a cited line that exists, and a linked step (point and hit) whose source window shows that line.\n(topic.invariants ?? []).forEach((v, k) => {\n const at = `invariant ${k}`;\n if (!v.rule) fail.push(`${at}: no rule`);\n register(v.rule ?? '', at);\n if (/^\\s*[A-Z]\\w*:/.test(v.rule ?? '')) fail.push(`${at}: opens with a label (\"${v.rule.split(':')[0]}:\"); state the rule the code keeps, or the rule it breaks`);\n if (!v.file || !v.line) return fail.push(`${at}: no file or line`);\n const src = sourceOf(rec, urlOf(`${root}/${v.file}`));\n if (src == null) fail.push(`${at}: ${v.file} is not in the recording's sources, or does not exist under ${root}`);\n else { const lines = src.split('\\n'); if (v.line > lines.length || !lines[v.line - 1].trim()) fail.push(`${at}: ${v.file}:${v.line} is past the end or blank`); }\n if (!v.point || !points.has(v.point)) return fail.push(`${at}: point ${v.point} is not in topic.points`);\n const i = invStep(P, hits, v);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${v.hit ?? 0} at ${v.point}`);\n const top = P[i].frames[0], file = rel(top.url);\n if (file !== v.file || Math.abs(v.line - top.line) > WINDOW) fail.push(`${at}: cites ${v.file}:${v.line}, but its step ${i + 1} shows ${file}:${Math.max(1, top.line - WINDOW)}-${top.line + WINDOW}; link a step whose source window holds the line, or add a point there`);\n});\nconst CH = chaptersOf(topic, P);\nif (!CH) fail.push('chapters: a chapter\\'s point is not hit at or after the previous chapter\\'s start');\n(topic.chapters ?? []).forEach((c, k) => { register(c.title, `chapter ${k}`); if (!hitPoints.has(c.from)) fail.push(`chapter ${k} \"${c.title}\": point ${c.from} was never hit`); });\n// Authored fields the viewer shows: story, outcome, actors[].about and role, and a glossary that covers every code name in chapter titles and invariants.\nconst fnNames = new Set(P.map((p) => p.frames[0].fn));\nfor (const k of ['story', 'outcome']) { if (typeof topic[k] !== 'string' || !topic[k].trim()) fail.push(`topic: no ${k}`); else register(topic[k], k); }\n(topic.actors ?? []).forEach((a) => { for (const k of ['about', 'role']) if (typeof a[k] !== 'string' || !a[k].trim()) fail.push(`actor ${a.name}: no ${k}`); });\nconst terms = new Set();\nif (!Array.isArray(topic.glossary) || !topic.glossary.length) fail.push('topic: no glossary');\nelse topic.glossary.forEach((g, k) => { if (!g?.term || !g?.def) fail.push(`glossary ${k}: needs term and def`); else if (terms.has(g.term)) fail.push(`glossary: ${g.term} is listed twice`); else { terms.add(g.term); register(g.def, `glossary ${g.term}`); } });\nconst needTerms = (s, at) => { for (const m of String(s).matchAll(/`([^`]+)`/g)) if (!terms.has(m[1]) && !fnNames.has(m[1])) fail.push(`${at}: \\`${m[1]}\\` has no glossary entry and is not a step's function name`); };\n(topic.chapters ?? []).forEach((c, k) => needTerms(c.title, `chapter ${k} \"${c.title}\"`));\n(topic.invariants ?? []).forEach((v, k) => needTerms(v.rule, `invariant ${k}`));\n// Authored values: story, outcome, chapter titles and invariants that state run values must match this recording.\nconst B = baseFile ? readJson(baseFile).pauses : null;\nconst baseAuth = B && authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B));\nlet authored = 0;\nfor (const [k, a] of (CH ? authoredOf(topic, P, hits, CH) : []).entries()) {\n authored += a.values.length;\n for (const v of a.missing) {\n const msg = `${a.at}: says ${v.name} = ${JSON.stringify(v.value)}, which no step it speaks for has`;\n if (baseAuth && !baseAuth[k].missing.includes(v)) warn.push(`${msg}; it holds in the base recording, so the page tags it \"Written for the base recording\"`); else fail.push(msg);\n }\n}\nif (!rec.provenance) fail.push('recording: no provenance; record it again with record.mjs');\nelse if (rec.provenance.pauses !== P.length) fail.push(`provenance: pauses ${rec.provenance.pauses} but the recording has ${P.length}`);\nif (warn.length) console.log(warn.join('\\n'));\nif (fail.length) { console.error(fail.join('\\n')); console.error(`check failed: ${fail.length} problems`); process.exit(1); }\nconsole.log(`check ok: ${P.length} steps, ${notes.length} notes, ${refs} refs, ${values} values, ${(topic.invariants ?? []).length} invariants, ${authored} authored values, ${terms.size} glossary terms${warn.length ? `, ${warn.length} written for the base` : ''}`);\n","carry-notes.mjs":"// Carries notes written against one recording to another recording of the same topic (a re-record, or a head run for a two-recording page):\n// each value takes the new run's value at the same path, and the note text takes the same substitution wherever the value is in backticks.\n// Usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json\nimport { readJson, leavesOf, hitsOf, nameMatches } from './lib.mjs';\nconst [oldFile, notesFile, newFile] = process.argv.slice(2);\nif (!newFile) { console.error('usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json'); process.exit(2); }\nconst B = readJson(oldFile), H = readJson(newFile), notes = readJson(notesFile);\nconst at = (R, n) => { const h = hitsOf(R.pauses); return R.pauses.find((p, i) => p.point === n.point && h[i] === (n.hit ?? 0)); };\nconst out = notes.map((n) => {\n const pb = at(B, n), ph = at(H, n);\n if (!pb || !ph) { console.error(`note ${n.point}#${n.hit ?? 0}: step missing in ${pb ? 'new' : 'old'} recording, kept as is`); return n; }\n const lb = leavesOf(pb), lh = leavesOf(ph);\n let text = n.text;\n const values = (n.values ?? []).map((v) => {\n const b = lb.find((l) => nameMatches(l.path, v.name) && l.str === String(v.value)), h = b && lh.find((l) => l.path === b.path);\n if (h && h.str !== String(v.value)) { text = text.split('`' + v.value + '`').join('`' + h.str + '`'); return { ...v, value: h.str }; }\n return v;\n });\n return { ...n, text, values };\n});\nconsole.log(JSON.stringify(out, null, 1));\n","shot.mjs":"// Screenshots a rendered page with JavaScript off. Usage: node shot.mjs page.html out.png [width=1440] [light|dark] [#step-N] [level ch|seq|steps] [full]\nimport { chromium } from 'playwright';\nconst [page, out, w = '1440', scheme = 'light', hash = '', level = '', full = ''] = process.argv.slice(2);\nconst b = await chromium.launch();\nconst ctx = await b.newContext({ javaScriptEnabled: false, viewport: { width: +w, height: 1000 }, colorScheme: scheme });\nconst p = await ctx.newPage();\nawait p.goto('file://' + new URL(page, 'file://' + process.cwd() + '/').pathname + hash);\nif (level) await p.click(`label[for=lv-${level}]`);\nif (hash && level) await p.evaluate((h) => document.querySelector(h).scrollIntoView(), hash);\nawait p.screenshot({ path: out, fullPage: full === 'full' });\nconsole.log(out, 'scrollWidth', await p.evaluate(() => document.documentElement.scrollWidth));\nawait b.close();\n","lib.mjs":"// Shared by record.mjs, render.mjs and check.mjs: how a topic's paths resolve and how a recording's frames, steps and values are named.\nimport { existsSync, readFileSync } from 'node:fs';\nimport { dirname, isAbsolute, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nexport const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));\n// topic.json with `root`, `scenario`, `stores` and `import` resolved against the topic file's directory, so a topic can sit next to its scenario.\nexport const loadTopic = (file, rootOverride) => {\n const t = readJson(file), dir = dirname(resolve(file)), at = (p) => p == null ? p : isAbsolute(p) ? p : resolve(dir, p);\n return { ...t, dir, root: (rootOverride ? resolve(rootOverride) : at(t.root)).replace(/\\/$/, ''), scenario: at(t.scenario), stores: at(t.stores), import: at(t.import) };\n};\n// Source text of a file the recording paused in or an invariant cites, from the copy the recorder stored, so check and render need no checkout.\n// A recording made before sources were stored falls back to the file on disk.\nexport const sourceOf = (rec, url) => { if (rec.sources) return rec.sources[url] ?? null; const f = url.replace(/^file:\\/\\//, ''); return existsSync(f) ? readFileSync(f, 'utf8') : null; };\nexport const rootOf = (topic, rec) => (rec?.root ?? topic.root).replace(/\\/$/, '');\n// A point's file is relative to topic.root; one that starts with ./ is relative to the topic's directory (fakes and stubs that ship with the topic).\nexport const pointPath = (topic, root, p) => isAbsolute(p.file) ? p.file : p.file.startsWith('./') ? resolve(topic.dir, p.file) : resolve(root, p.file);\n// How a frame url is shown: relative to the recorded root when under it, else relative to the topic's directory when under that, else the path.\nexport const relTo = (root, dir) => (url) => {\n const u = url || '', r = `file://${root}/`, d = dir && `file://${dir}/`;\n return u.startsWith(r) ? u.slice(r.length) : d && u.startsWith(d) ? u.slice(d.length) : u.replace(/^file:\\/\\//, '');\n};\nexport const urlOf = (path) => pathToFileURL(path).href;\nexport const stackOf = (p) => { const all = p.frames.map((f) => ({ ...f, async: false })); for (const a of p.asyncFrames) a.frames.forEach((f, i) => all.push({ ...f, async: i === 0 })); return all; };\n// Step k is the hit-th (0-based) pause at its point.\nexport const hitsOf = (P) => { const n = new Map(); return P.map((p) => { const h = n.get(p.point) ?? 0; n.set(p.point, h + 1); return h; }); };\nexport const stepAt = (P, hits, point, hit = 0) => P.findIndex((p, i) => p.point === point && hits[i] === hit);\n// A recorded value (the recorder's {t, v, n} preview) as plain JSON.\nexport const plain = (x) => {\n if (!x) return undefined;\n if (x.t === 'undefined') return undefined;\n if (x.t === 'null') return null;\n if (x.v === null || typeof x.v !== 'object') return x.t === 'number' && typeof x.v === 'string' && !isNaN(+x.v) ? +x.v : x.v;\n if (x.t === 'array' || x.t === 'set') return Object.values(x.v).map(plain);\n if (x.t === 'map') return Object.values(x.v).map(plain);\n return Object.fromEntries(Object.entries(x.v).map(([k, y]) => [k, plain(y)]));\n};\nconst str = (v) => v === undefined ? 'undefined' : typeof v === 'string' ? v : JSON.stringify(v);\n// Every value a step shows, at every depth: scope variables (name.path), watch values (watch.path for the first watch, watch#k.path for watch k,\n// and the watch expression itself, e.g. queue.length) and row cells (table.column).\nexport const leavesOf = (p) => {\n const out = [];\n const walk = (v, path, src) => {\n out.push({ path, str: str(v), val: v, src });\n if (v && typeof v === 'object') for (const [k, y] of Object.entries(v)) walk(y, `${path}.${k}`, src);\n else if (typeof v === 'string' && /^[[{]/.test(v)) { try { const j = JSON.parse(v); if (j && typeof j === 'object') for (const [k, y] of Object.entries(j)) walk(y, `${path}.${k}`, src); } catch {} }\n };\n for (const s of p.scopes) for (const [n, v] of Object.entries(s.vars)) walk(plain(v), n, 'scope');\n p.watch.forEach((w, k) => { walk(w.value, k ? `watch#${k}` : 'watch', 'watch'); if (w.expression) walk(w.value, w.expression, 'watch'); });\n for (const [t, rows] of Object.entries(p.rows ?? {})) if (Array.isArray(rows)) for (const r of rows) for (const [c, v] of Object.entries(r)) walk(v, `${t}.${c}`, 'row');\n return out;\n};\nexport const nameMatches = (path, name) => !name || path === name || path.endsWith('.' + name);\nexport const hasValue = (p, v) => leavesOf(p).some((l) => nameMatches(l.path, v.name) && l.str === String(v.value));\n// file:line citations in note text.\nexport const CITE = /(?<![\\w/.-])((?:[\\w.-]+\\/)*[\\w.-]+\\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|rs|sql)):(\\d+)(?!\\d)/g;\nexport const sameFile = (a, b) => a === b || a.endsWith('/' + b) || b.endsWith('/' + a);\n// The source window a step shows: this many lines either side of the paused line.\nexport const WINDOW = 4;\n// The step an invariant links to: the hit-th (0-based, default 0) pause at its point.\nexport const invStep = (P, hits, v) => stepAt(P, hits, v.point, v.hit ?? 0);\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start. `src` is the topic.chapters index\n// (null for the untitled chapter added when the first one starts after step 1). Returns null when a chapter's point is not hit in order.\nexport const chaptersOf = (topic, P) => {\n const N = P.length, starts = []; let from = 0;\n for (const [src, c] of (topic.chapters ?? []).entries()) { const i = P.findIndex((p, j) => j >= from && p.point === c.from); if (i < 0) return null; starts.push({ at: i, title: c.title, src }); from = i; }\n if (!starts.length || starts[0].at > 0) starts.unshift({ at: 0, title: null, src: null });\n return starts.map((s, k) => { const to = (starts[k + 1]?.at ?? N) - 1; return { k, src: s.src, from: s.at, to, title: s.title, idx: Array.from({ length: to - s.at + 1 }, (_, n) => s.at + n) }; });\n};\n// Authored text that states run values (story, outcome, a chapter title, an invariant rule) lists them in `values` (story and outcome in\n// `storyValues` and `outcomeValues`), and each must be in the recording at a step the text speaks for: a chapter's steps, an invariant's\n// linked step, any step for story and outcome. `missing` is what no such step has; `base` (with a base recording) is what the base has instead.\nexport const authoredOf = (topic, P, hits, CH) => {\n const all = P.map((_, i) => i), out = [];\n if (topic.story) out.push({ at: 'story', key: 'story', text: topic.story, values: topic.storyValues ?? [], steps: all });\n if (topic.outcome) out.push({ at: 'outcome', key: 'outcome', text: topic.outcome, values: topic.outcomeValues ?? [], steps: all });\n (topic.chapters ?? []).forEach((c, k) => out.push({ at: `chapter ${k} \"${c.title}\"`, key: `ch${k}`, text: c.title, values: c.values ?? [], steps: CH?.find((x) => x.src === k)?.idx ?? [] }));\n (topic.invariants ?? []).forEach((v, k) => { const i = invStep(P, hits, v); out.push({ at: `invariant ${k}`, key: `inv${k}`, text: v.rule, values: v.values ?? [], steps: i < 0 ? [] : [i] }); });\n return out.map((a) => ({ ...a, missing: a.values.filter((v) => !a.steps.some((i) => hasValue(P[i], v))) }));\n};\nexport const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;\n// The last earlier pause in the same call. The recorder counts each function's entries and exits around its points: an earlier pause is in\n// this call when the function was entered no more times since and exactly one call of it was live then. Anything less shows no Before.\nexport const previousInCall = (P) => P.map((p, i) => {\n for (let j = i - 1; j >= 0; j--) if (P[j].call?.fn === p.call?.fn && p.call) return P[j].call.entries === p.call.entries && P[j].call.live === 1 ? j : null;\n return null;\n});\n// Diff against a base recording: the same step is the same point at the same hit, and values compare exactly. A topic records with `pin` so ids\n// and times repeat; whatever still differs per run (a temp directory, say) the topic names in `generated`, regexes whose matches compare equal.\nclass Container { constructor(container) { this.container = container; } }\nexport const diffOf = (topic, rec, B, file) => {\n const P = rec.pauses, hits = hitsOf(P), rel = relTo(rootOf(topic, rec), topic.dir), BR = relTo(rootOf(topic, B), topic.dir), bh = hitsOf(B.pauses);\n const GEN = (topic.generated ?? []).map((r) => new RegExp(r, 'g'));\n const norm = (v) => GEN.reduce((t, r) => t.replace(r, '(generated)'), v);\n const normDeep = (v) => typeof v === 'string' ? norm(v) : Array.isArray(v) ? v.map(normDeep) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, y]) => [k, normDeep(y)])) : v;\n const same = (x, y) => typeof x === typeof y && (x === y || JSON.stringify(normDeep(x)) === JSON.stringify(normDeep(y)));\n const show = (v) => v instanceof Container ? `(${v.container})` : v === undefined ? 'undefined' : JSON.stringify(v).slice(0, 80);\n const srcLine = (R, url, line) => { const s = sourceOf(R, url); return s == null ? null : s.split('\\n')[line - 1] ?? ''; };\n const rowWrites = (Q, i) => { if (i === 0) return []; const ts = Object.keys(Q[i].rows ?? {}).filter((t) => Array.isArray(Q[i].rows[t]));\n return ts.flatMap((t) => { const a = new Set((Q[i - 1].rows[t] ?? []).map((r) => JSON.stringify(r))); return Q[i].rows[t].filter((r) => !a.has(JSON.stringify(r))).map((r) => ({ t, r })); }); };\n // A container leaf compares by its kind (array, object, or a string holding JSON) and its children carry the content, so a type change shows.\n const jsonText = (v) => { try { const j = JSON.parse(v); return j !== null && typeof j === 'object'; } catch { return false; } };\n const valueLeaves = (p) => leavesOf({ ...p, rows: {} }).map((l) => ({ path: l.path, val: Array.isArray(l.val) ? new Container('array') : l.val && typeof l.val === 'object' ? new Container('object') : typeof l.val === 'string' && jsonText(l.val) ? new Container('JSON string') : l.val }));\n const rowDelta = (x, y) => `${y.t} ${[...new Set([...Object.keys(x.r), ...Object.keys(y.r)])].filter((c) => !same(x.r[c], y.r[c])).map((c) => `${c} ${show(x.r[c])} → ${show(y.r[c])}`).join(', ')}`;\n const out = P.map((p, i) => {\n const j = B.pauses.findIndex((q, k) => q.point === p.point && bh[k] === hits[i]);\n if (j < 0) return { i, kinds: ['step'], detail: ['no such step in base'], names: [] };\n const q = B.pauses[j], kinds = [], detail = []; let was = null, now = null;\n // Source: the first line of the step's source window (the paused line plus or minus WINDOW) that the two checkouts differ on.\n let at = null;\n for (let d = -WINDOW; d <= WINDOW && at == null; d++) {\n const ls = srcLine(rec, p.frames[0].url, p.frames[0].line + d), lb = srcLine(B, q.frames[0].url, q.frames[0].line + d);\n if (ls !== null && lb !== null && ls.trim() !== lb.trim()) { kinds.push('source'); was = lb; now = ls; at = p.frames[0].line + d; }\n }\n const fs = (s, R) => s.map((f) => `${f.fn} ${R(f.url)}:${f.line}`).join('\\n');\n if (fs(stackOf(p), rel) !== fs(stackOf(q), BR)) kinds.push('calls');\n const mh = new Map(valueLeaves(p).map((l) => [l.path, l.val])), mb = new Map(valueLeaves(q).map((l) => [l.path, l.val]));\n const vd = [...new Set([...mb.keys(), ...mh.keys()])].filter((k) => !(mh.has(k) && mb.has(k) && same(mb.get(k), mh.get(k))));\n if (vd.length) { kinds.push('values'); for (const k of vd.slice(0, 4)) detail.push(`${k} ${mb.has(k) ? show(mb.get(k)) : 'absent'} → ${mh.has(k) ? show(mh.get(k)) : 'absent'}`); if (vd.length > 4) detail.push(`+${vd.length - 4} more values`); }\n const rh = rowWrites(P, i), rb = rowWrites(B.pauses, j);\n const rk = rh.length !== rb.length ? -1 : rh.findIndex((x, k) => x.t !== rb[k].t || !same(rb[k].r, x.r));\n if (rh.length !== rb.length || rk >= 0) { kinds.push('rows'); detail.push(rk >= 0 ? `row ${rowDelta(rb[rk], rh[rk])}` : `row writes ${rb.length} → ${rh.length}`); }\n return { i, kinds, detail, was, now, at, names: [...new Set(vd.map((k) => k.split('.')[0]))] };\n });\n return { file, steps: out, missingBase: B.pauses.map((q, k) => k).filter((k) => !P.some((p, i) => p.point === B.pauses[k].point && hits[i] === bh[k])) };\n};\n","pin.mjs":"// Makes a recording reproducible: with `\"pin\": true` in the topic, record.mjs imports this before the scenario. Random bytes and UUIDs come from\n// a seeded generator, and the clock starts at a fixed instant and moves 1 ms per read, so two recordings of the same code write the same ids\n// and times and a base/head diff compares values exactly.\nimport crypto from 'node:crypto';\nimport { syncBuiltinESMExports } from 'node:module';\n\nlet state = 0x9e3779b9;\nconst next = () => {\n state = (state + 0x6d2b79f5) >>> 0;\n let t = state;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return (t ^ (t >>> 14)) >>> 0;\n};\nconst fill = (view) => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n for (let k = 0; k < bytes.length; k++) bytes[k] = next() & 255;\n return view;\n};\nconst uuid = () => {\n const b = fill(new Uint8Array(16));\n b[6] = (b[6] & 0x0f) | 0x40;\n b[8] = (b[8] & 0x3f) | 0x80;\n const h = Buffer.from(b).toString('hex');\n return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;\n};\n\ncrypto.randomBytes = (n, cb) => {\n const out = fill(Buffer.alloc(n));\n if (cb) { process.nextTick(cb, null, out); return undefined; }\n return out;\n};\ncrypto.randomUUID = uuid;\nObject.defineProperty(globalThis.crypto, 'getRandomValues', { value: fill, configurable: true, writable: true });\nObject.defineProperty(globalThis.crypto, 'randomUUID', { value: uuid, configurable: true, writable: true });\nsyncBuiltinESMExports();\nMath.random = () => next() / 2 ** 32;\n\nconst RealDate = Date, start = RealDate.UTC(2026, 0, 1);\nlet clock = start;\nconst now = () => ++clock;\nglobalThis.Date = class Date extends RealDate {\n constructor(...a) { if (a.length) super(...a); else super(now()); }\n static now() { return now(); }\n};\nperformance.now = () => clock - start;\n","package.json":"{\n \"name\": \"deep-dive-skill\",\n \"private\": true,\n \"description\": \"Render and screenshot dependencies of the deep-dive skill: shiki and geist for render.mjs, playwright for shot.mjs.\",\n \"dependencies\": {\n \"geist\": \"1.9.2\",\n \"playwright\": \"1.65.0\",\n \"shiki\": \"4.6.0\"\n }\n}\n"}};
2
+ export default {"description":"How to build a deep-dive explainer page for a code path (a call stack, a dataflow or a protocol) from a recorded run of the real code. Load when someone asks for a tech explainer, a walkthrough of how a flow works, a stepped repro of a bug, or a page that shows what a PR changes in a flow.","content":"# Deep-dive explainer pages\n\nA deep-dive page explains one flow in real code by stepping through a recorded run of it. Every step is a breakpoint pause: its call stack, its variables, the rows its stores hold and the requests in flight. A note per step says what happens there in plain words. The page is one HTML file with no JavaScript. It works in the Mac doc pane and under 3 MiB.\n\nThe kit is the files next to this one:\n\n- `record.mjs` runs the topic's scenario under the V8 inspector and writes the recording.\n- `render.mjs` turns the topic, the recording and the notes into the page.\n- `ui.mjs` holds the shadcn/ui class recipes the page's controls use and compiles them with Tailwind.\n- `check.mjs` fails when a note or the topic's authored text says something the recording does not show.\n- `carry-notes.mjs` carries notes from one recording to another of the same topic, for a two-recording page.\n- `shot.mjs` screenshots a page at a step, a level and a width, so you can look at it.\n- `controls.mjs` checks the page's controls by keyboard and by click.\n- `pin.mjs` makes a recording repeat its ids and times, for a topic with `pin` (step 3).\n- `lib.mjs` holds what the others share.\n\nInstall the render dependencies once, in this skill's directory. Its `package.json` declares them at exact versions:\n\n```\nnpm i --prefix \"$SKILL_DIR\"\nnpx --prefix \"$SKILL_DIR\" playwright install --with-deps chromium webkit\n```\n\n`$SKILL_DIR` is the directory this file is in (`$HOME/.agents/skills/deep-dive`). `record.mjs` and `check.mjs` need only Node 24. `shiki`, `geist`, `tailwindcss`, `class-variance-authority` and `tailwind-merge` are for `render.mjs`, and `playwright` is for `shot.mjs` and `controls.mjs` only. `render.mjs` also downloads Vercel's Geist foundation stylesheet, `https://vercel.com/geist/vercel-brand.css`, and inlines it, so it needs network access. The page's own styles use only that stylesheet's tokens and type sizes, so the page follows Geist in light and dark. The controls are shadcn/ui's Button, Tabs, Checkbox, Popover, DropdownMenu, Badge and Table recipes, with shadcn's colour variables set from the Geist tokens. Each control is a native element (a link, a radio, a checkbox, a `popover`, a `details` or a table) with the recipe's classes, because the page runs no JavaScript: the Mac app's document pane blocks script in bot-written HTML, so a Radix component would render but do nothing there.\n\n## Who the page is for\n\nA page is done when a cold critic, a fresh session that has never seen the code, passes all five story checks on it:\n\n| # | Reader | Check |\n|---|---|---|\n| 1 | New engineer | After 5 minutes, page closed, draws the actors and crossings and names the stores written. 4 of 5 answers right. |\n| 2 | Debugger | Reaches the write site and the deciding rule from page open in under 60 seconds. |\n| 3 | PR reviewer | Given base and head recordings, the page marks every step whose calls, rows or values differ. |\n| 4 | The human | Each invariant is stated in one sentence, with `file:line`, linked to the step that enforces it, before any data. |\n| 5 | Authoring bot | A new topic renders with no renderer edits, and every note's `file:line` and value matches the recording. |\n\nA second cold reader lists every question the page raises. Fewer than 40 is the bar.\n\n## The method\n\nDo these in order. Each one is cheap to redo, and each later step depends on the earlier ones being right.\n\n### 1. Read the code and pick actors and rules first\n\nBefore writing anything, read the flow end to end and write down two lists.\n\n- **Actors.** The processes, modules, stores and fakes that take part. Each is one lifeline on the page. Name them as the code does.\n- **Invariants.** The rules this flow keeps, each one sentence with the `file:line` that enforces it. A bug is stated as the rule the code breaks, with the line that breaks it. Never label a rule \"Broken:\".\n\nThese two lists are the page's skeleton. If you can't name the rules, you don't understand the flow yet, and no amount of recording will fix that.\n\n### 2. Write the scenario\n\nThe scenario is a module that drives one run of the flow through the real code:\n\n```js\nexport async function scenario(mark, { root }) {\n const { thing } = await import(`${root}/src/thing.ts`);\n mark('ready', { file: dbPath }); // passed to the stores reader\n await thing(input);\n mark('done', { result });\n}\n```\n\n- Import the code under test from `root`, so the same scenario records against another checkout (`--root`).\n- Fake only at the network edge: a fake token service, an in-memory Cosmos, a recording broker. The real code runs everywhere else. Put fakes in the topic folder, and say on the page (in `story` and the fake actor's `about`) which parts are fakes.\n- Make it deterministic. Stub `crypto.randomUUID` and clocks so two recordings of the same code match.\n- When a package can't be installed in the sandbox, map its import to a stub with a loader hook (`module.registerHooks`) and pass the hook with `import` in the topic or `--import`. A stub that is asked for a case it doesn't model throws \"not modelled\"; it never silently returns.\n\n### 3. Write `topic.json`\n\nPaths are relative to the topic file's directory.\n\n```json\n{\n \"title\": \"How a post reaches a bot\",\n \"root\": \"../../hosted-hub\",\n \"scenario\": \"scenario.ts\",\n \"stores\": \"stores.mjs\",\n \"import\": \"stubs/register.mjs\",\n \"points\": [{ \"id\": \"p1\", \"file\": \"src/api/workspace.ts\", \"line\": 348, \"watch\": [\"req.url\"] }],\n \"actors\": [{ \"name\": \"hub\", \"kind\": \"process\", \"role\": \"server\", \"frames\": \"^src/\", \"urls\": \"/api/\", \"about\": \"...\" }],\n \"invariants\": [{ \"rule\": \"...\", \"file\": \"src/store/messages.ts\", \"line\": 84, \"point\": \"p5\", \"hit\": 0, \"values\": [] }],\n \"chapters\": [{ \"title\": \"...\", \"from\": \"p1\", \"values\": [] }],\n \"story\": \"...\", \"storyValues\": [],\n \"outcome\": \"...\", \"outcomeValues\": [],\n \"glossary\": [{ \"term\": \"clientId\", \"def\": \"...\" }]\n}\n```\n\n- **`points`.** One breakpoint per moment worth a step. A point in a file outside `root` starts with `./` (the scenario's own lines). `watch` expressions are evaluated at the pause and shown in the inspector. A list of SQL statements (`{sql}` items) in a watch is drawn as a batch sent to the first store.\n- **`actors`.** `frames` is a regex over the frame's path relative to `root`, and `urls` matches the requests it serves. `kind` is `process`, `module` or `store`. `role` is the short label under its name, and `about` is one sentence for the cast table. An actor with `key` (a JS expression, such as `sessionId`) gets one lifeline per value, for two sessions running at once.\n- **`stores`.** A module exporting `rows(ready)`. It gets the `ready` mark's data and returns `{ table: rows[] }`, read-only. The page diffs rows between steps and shows each write. A topic with no database leaves it out.\n- **`invariants`.** `point` and `hit` (0-based) choose the step that enforces the rule. The cited line must sit inside that step's source window (the paused line plus or minus 4), or `check.mjs` fails.\n- **`chapters`.** Each starts at a point (`from`, with an optional `hit`). Titles are one sentence about what happens, not a function name.\n- **`story`** is the start view's first paragraph: what the flow is for, who starts it, what the reader will watch happen, and which parts are fakes. **`outcome`** closes the chapter list with what the run ended with.\n- **`values`** on any authored text (`storyValues`, `outcomeValues`, a chapter's or an invariant's `values`) list the run values it states, as `{ name, value }`. `check.mjs` fails when the recording doesn't have them, and the head page of a two-recording pair tags text written for the base run.\n- **`pin`** and **`generated`**, for a topic that gets a base and a head recording. With `\"pin\": true` the recorder seeds random bytes, UUIDs and `Math.random` and starts the clock at a fixed instant that moves 1 ms per read, so two runs of the same code write the same ids and times. A diff compares values exactly. Anything that still differs per run, such as a temp directory the code makes with `mkdtemp`, goes in `generated` as a regex, and its matches compare equal: `\"generated\": [\"/tmp/app-[A-Za-z0-9]{6}\"]`. Under `pin`, a duration counts clock reads, so a note never presents it as time.\n- **`glossary`.** Every backticked name in a chapter title or an invariant is a glossary term or a function in the recording. Terms open in place where they appear.\n\n### 4. Record\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording.json\n```\n\nIt prints the points that were never hit and the breakpoints V8 moved to another line (a breakpoint inside a multi-line statement pauses at the statement's end). Move points until every one is hit where you meant. The first step's paused function is the page's entry: frames outside it are left out of the diagram, so make step 1 a pause in the outermost function you want drawn.\n\nThe recorder also counts each function's entries and returns around its points, and every pause stores them as `call`. The inspector shows \"Since step N, the last pause in this call\" only when no new call of the function started between the two pauses and only one call of it was running. Otherwise the step shows no Before column. A second call from the same line, or two async calls running at once, never share one.\n\nThe recording stores `provenance` (the commit, the files changed against it, the scenario, the date and the pause count) and `sources`, the text of every file a step pauses in or a rule cites. `check.mjs` and `render.mjs` read source from there, so a recording checks and renders without the checkout it came from.\n\n### 5. Write the notes\n\n`notes.json` has one note per step: `{ point, hit, text, refs: [{ file, line }], values: [{ name, value }] }`.\n\n- Say what happens at this step and why, in plain words, with the values that decide it. \"`prepareMessage` tests `existing` at `src/store/messages.ts:84`. It is undefined, so the post is written as new.\"\n- Every `file:line` in the text is in `refs`, and every ref is a frame of this step or a breakpoint that was hit.\n- Every value the text states is in `values`, named by its scope path (`posted.to`), watch expression (`queue.length`) or row cell (`messages.kind`). `check.mjs` compares each one with the recording.\n\n### 6. Check, render and look\n\n```\nnode \"$SKILL_DIR/check.mjs\" topic.json recording.json notes.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording.json notes.json page.html\nnode \"$SKILL_DIR/shot.mjs\" page.html start.png 1040 light\nnode \"$SKILL_DIR/shot.mjs\" page.html seq.png 1040 light '#step-9' seq\nnode \"$SKILL_DIR/controls.mjs\" page.html\n```\n\n`controls.mjs` presses Tab, the arrow keys, Space, Enter and Escape through the page and clicks each kind of control, and fails when one does nothing or has no focus ring. It also loads the page in Chromium and WebKit at 280, 600 and 900 px, widths the Mac app's document pane takes (it opens at 280 px), and fails when a Detail level shows an empty list, a sticky header fills more than half the window, or the page scrolls sideways. Fix every check failure at its source: a wrong note, a point on the wrong line, a rule cited outside its step. Never loosen a note until it passes. Then open the screenshots yourself: the start view, the sequence level at a busy step, the steps level, and dark mode. The page fits 1040 px with no horizontal scroll, and nothing overlaps.\n\n### 7. Two recordings, for a PR\n\nTo show what a change does to the flow, set `pin` in the topic (see step 3), record the base, record the same topic against the head checkout and carry the notes. Before the head, record the base a second time and render it with `--base recording.json`: the banner must say no step differs. A step that differs between two runs of the same code names a value to add to `generated`.\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording-head.json --root /path/to/head\nnode \"$SKILL_DIR/carry-notes.mjs\" recording.json notes.json recording-head.json > notes-head.json\nnode \"$SKILL_DIR/check.mjs\" topic.json recording-head.json notes-head.json --base recording.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording-head.json notes-head.json pr.html --base recording.json\n```\n\n`carry-notes.mjs` gives each note's values the head run's values and replaces each changed value where the note shows it in backticks. Reread every carried note at a step the page marks as differing, because a sentence that explains why a value was set can stop being true. The page marks each step whose calls, rows or values differ, and tags authored text whose `values` held only in the base run.\n\n### 8. Hand it to cold readers\n\nSend the page to two fresh sessions that have never seen the code. Give the critic the five story checks above and ask for pass or fail, the time taken and the answers given. Ask the reader to list every question the page raises. Fix what they find in the topic and the notes, re-render and send it again until all five pass and the reader lists fewer than 40 questions.\n\n## Register and layout checklist\n\n- Every sentence on the page is a whole sentence in plain words. No arrows, no `≠`, no em dashes, no \"Broken:\" label.\n- Counts agree with their nouns (\"1 file\", \"2 files\").\n- Name code by its exact name in backticks. Define every name the reader can't know in the glossary.\n- A note says what the step shows. It never says \"as we saw\" or points at another step for its meaning.\n- Chapter titles say what happens (\"A bot author's request waits until its delivery is tried\"), never a function name alone.\n- The page has 0 `<script>` tags, is under 3 MiB and fits 1040 px.\n\n## Examples\n\n`examples/` in the hub repo (`packages/agent-kit/deep-dive/examples/`) has three complete topics: `hub` (a post reaching a bot, with SQLite rows and a two-recording steer change), `eve` (the GitHub credential lease, two sessions on one sandbox, with fakes) and `api` (a deployment from create to READY, with loader-hook stubs for packages the sandbox can't install). Read the one closest to your flow before writing a topic.\n","files":{"record.mjs":"// Records a real run of topic.scenario: breakpoints set from a worker over the V8 inspector at topic.points; each pause captures the stack,\n// async stack, scopes, watch values and, when topic.stores is set, the rows its rows(ready) returns.\n// Usage: node record.mjs topic.json recording.json [--root /other/checkout] [--import hooks.mjs]\n// --root records the same topic against another copy of topic.root (a patched head for a two-recording page).\n// --import loads a module before the scenario, for loader hooks (registerHooks) that map imports the sandbox can't install.\n// An actor with `key` (a JS expression) gets it evaluated on every frame of that actor, so the page can draw one lifeline per key value.\n// `pauseKey` is evaluated on the paused frame of every pause and stored as pauses[].keys, for pauses with no frame of the actor.\n// Each pause stores `call` (the paused function's entry count and live calls; see track) and, with `pin` in the topic, ids and times repeat.\nimport { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';\nimport { pathToFileURL } from 'node:url';\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { execFileSync } from 'node:child_process';\nimport { loadTopic, pointPath, relTo, urlOf } from './lib.mjs';\nconst SELF = import.meta.url;\nif (isMainThread) {\n const args = process.argv.slice(2);\n const flag = (n) => { const k = args.indexOf(n); if (k < 0) return null; const v = args[k + 1]; args.splice(k, 2); return v; };\n const rootOverride = flag('--root'), hooks = flag('--import');\n const [topicFile, out] = args;\n if (!topicFile || !out) { console.error('usage: node record.mjs topic.json recording.json [--root dir] [--import hooks.mjs]'); process.exit(2); }\n const base = loadTopic(topicFile), topic = loadTopic(topicFile, rootOverride);\n const root = topic.root;\n const scenarioPath = rootOverride && base.scenario.startsWith(base.root + '/') ? root + base.scenario.slice(base.root.length) : topic.scenario;\n const hookPath = hooks ? new URL(hooks, pathToFileURL(process.cwd() + '/')).href : topic.import ? urlOf(topic.import) : null;\n if (hookPath) await import(hookPath);\n const date = new Date().toISOString().slice(0, 10);\n if (topic.pin) await import('./pin.mjs');\n const points = topic.points.map((p) => ({ ...p, url: urlOf(pointPath(topic, root, p)) }));\n const keys = topic.actors.filter((a) => a.key || a.pauseKey).map((a) => ({ name: a.name, frames: a.frames, key: a.key, pauseKey: a.pauseKey }));\n const w = new Worker(new URL(SELF), { workerData: { points, root, dir: topic.dir, keys, stores: topic.stores ?? null } });\n w.on('error', (e) => { console.error('recorder worker', e); process.exit(1); });\n const keep = setInterval(() => {}, 50);\n await new Promise((r) => w.once('message', r));\n const { scenario } = await import(pathToFileURL(scenarioPath).href);\n const marks = [];\n const mark = (phase, info) => { marks.push({ phase, info }); if (phase === 'ready') w.postMessage({ arm: info ?? {} }); if (phase === 'done') w.postMessage({ disarm: true }); };\n await scenario(mark, { root });\n const got = new Promise((r) => w.once('message', r));\n w.postMessage({ dump: true });\n const { pauses, unresolved } = await got;\n clearInterval(keep);\n const commit = execFileSync('git', ['-C', root, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();\n const dirty = execFileSync('git', ['-C', root, 'diff', '--name-only', '--relative', 'HEAD'], { encoding: 'utf8' }).split('\\n').filter(Boolean);\n const provenance = { commit, changed: dirty, scenario: relTo(root, topic.dir)(urlOf(scenarioPath)), date, pauses: pauses.length };\n // Every file a step's source panel shows or an invariant cites, as it was when recorded.\n const srcUrls = new Set([...pauses.map((p) => p.frames[0].url), ...(topic.invariants ?? []).map((v) => urlOf(`${root}/${v.file}`))]);\n const sources = Object.fromEntries([...srcUrls].map((u) => [u, readFileSync(u.replace(/^file:\\/\\//, ''), 'utf8')]));\n writeFileSync(out, JSON.stringify({ root, scenario: scenarioPath, provenance, marks, pauses, sources }, (k, v) => typeof v === 'bigint' ? String(v) : v, 1));\n console.log(`pauses ${pauses.length}`);\n const hitIds = new Set(pauses.map((p) => p.point));\n const missed = topic.points.filter((p) => !hitIds.has(p.id)).map((p) => `${p.id} ${p.file}:${p.line}`);\n if (unresolved.length) console.log('breakpoints that resolved to another line:', unresolved.join(', '));\n if (missed.length) console.log('points never hit:', missed.join(', '));\n process.exit(0);\n} else {\n setInterval(() => {}, 1000);\n const { Session } = await import('node:inspector');\n const { points, root, dir, keys, stores } = workerData;\n const rel = relTo(root, dir), KEYS = keys.map((k) => ({ ...k, re: new RegExp(k.frames) }));\n const rowsOf = stores ? (await import(pathToFileURL(stores).href)).rows : null;\n const s = new Session(); s.connectToMainThread();\n const call = (m, p = {}) => new Promise((res, rej) => s.post(m, p, (e, r) => e ? rej(e) : res(r)));\n let armed = null; const pauses = []; const scripts = new Map(); const bpPoint = new Map(); const unresolved = [];\n const resolved = new Map(), counter = new Map(), placed = new Map(), fnOfPoint = new Map(), calls = new Map();\n s.on('Debugger.scriptParsed', ({ params }) => scripts.set(params.scriptId, params.url));\n const WATCH = new Map(points.filter((b) => b.watch).map((b) => [b.id, b.watch]));\n const preview = async (obj, depth) => {\n if (!obj) return null;\n if (obj.type !== 'object' || obj.subtype === 'null') return { t: obj.subtype ?? obj.type, v: obj.value ?? obj.unserializableValue ?? obj.description ?? null };\n if ((obj.subtype === 'map' || obj.subtype === 'set') && depth > 0) {\n const { internalProperties = [] } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const entries = internalProperties.find((p) => p.name === '[[Entries]]');\n const out = [];\n if (entries) { const { result } = await call('Runtime.getProperties', { objectId: entries.value.objectId, ownProperties: true });\n for (const p of result.filter((p) => /^\\d+$/.test(p.name)).slice(0, 40)) out.push(await preview(p.value, depth - 1)); }\n return { t: obj.subtype, v: Object.fromEntries(out.map((e, i) => [i, e])), n: out.length, d: obj.description };\n }\n if (depth <= 0 || obj.subtype === 'promise' || /^(Function|Db|Client)/.test(obj.className ?? '')) return { t: obj.className ?? obj.subtype, v: obj.description };\n const { result } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const props = {};\n for (const p of result.filter((p) => p.enumerable !== false && p.value).slice(0, 40)) props[p.name] = await preview(p.value, depth - 1);\n return { t: obj.subtype === 'array' ? 'array' : (obj.className ?? 'Object'), v: props, n: obj.subtype === 'array' ? result.filter(p => /^\\d+$/.test(p.name)).length : undefined };\n };\n const frameOf = (url, fn, line, col) => ({ fn: fn || '(anonymous)', url, line: line + 1, col: col + 1 });\n s.on('Debugger.paused', async ({ params }) => {\n try {\n if (params.reason === 'instrumentation') return await track(params.data.scriptId);\n const hit = params.hitBreakpoints ?? [];\n const counts = hit.flatMap((b) => (counter.get(b) ?? []).map((k) => ({ ...k, b })));\n for (const { fn, b } of counts.filter((k) => k.kind === 'entry')) {\n const c = calls.get(fn);\n // The first candidate a call reaches is the one every call reaches first; the other candidates go.\n if (!c.entry) {\n c.entry = b;\n for (const o of c.candidates.filter((o) => o !== b)) {\n const rest = counter.get(o).filter((k) => !(k.kind === 'entry' && k.fn === fn));\n if (rest.length) counter.set(o, rest); else { counter.delete(o); for (const [at, id] of placed) if (id === o) placed.delete(at); await call('Debugger.removeBreakpoint', { breakpointId: o }); }\n }\n }\n c.entries++;\n }\n const exits = counts.filter((k) => k.kind === 'exit');\n const point = hit.map((b) => bpPoint.get(b)).find(Boolean) ?? null;\n try {\n if (!point || !armed) return;\n await record(params, point, hit.map((b) => fnOfPoint.get(b)?.get(params.callFrames[0].location.scriptId)).find(Boolean));\n } finally { for (const c of exits) calls.get(c.fn).exits++; }\n } finally { await call('Debugger.resume'); }\n });\n const record = async (params, point, fn) => {\n const top = params.callFrames[0];\n const frames = params.callFrames.map((f) => frameOf(scripts.get(f.location.scriptId) ?? f.url, f.functionName, f.location.lineNumber, f.location.columnNumber));\n const asyncFrames = [];\n for (let a = params.asyncStackTrace; a; a = a.parent) asyncFrames.push({ description: a.description, frames: a.callFrames.map((f) => frameOf(scripts.get(f.scriptId) ?? f.url, f.functionName, f.lineNumber, f.columnNumber)).filter((f) => f.url !== SELF) });\n const scopes = [];\n for (const sc of top.scopeChain.filter((x) => x.type === 'local' || x.type === 'block' || x.type === 'closure').slice(0, 3)) {\n const { result } = await call('Runtime.getProperties', { objectId: sc.object.objectId, ownProperties: true });\n const vars = {};\n for (const p of result) if (p.value) vars[p.name] = await preview(p.value, 3);\n scopes.push({ type: sc.type, name: sc.name ?? null, vars });\n }\n for (const [k, f] of params.callFrames.entries()) {\n const url = scripts.get(f.location.scriptId) ?? f.url, ks = KEYS.filter((a) => a.re.test(rel(url)) || a.re.test(url));\n for (const a of ks.filter((x) => x.key)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: f.callFrameId, expression: a.key, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) { (frames[k].keys ??= {})[a.name] = r.result.value; } }\n }\n // pauseKey: evaluated on the paused frame whatever actor it belongs to (an AsyncLocalStorage lookup, say), for pauses whose stack has no\n // frame of the actor to read `key` from, such as a fake resumed inside one session's await.\n const pkeys = {};\n for (const a of KEYS.filter((x) => x.pauseKey)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression: a.pauseKey, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) pkeys[a.name] = r.result.value; }\n const watch = [];\n for (const expression of WATCH.get(point) ?? []) {\n const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression, returnByValue: true });\n watch.push({ expression, value: r.exceptionDetails ? { error: r.exceptionDetails.exception?.description ?? r.exceptionDetails.text } : r.result.value });\n }\n let rows = {};\n if (rowsOf) try { rows = await rowsOf(armed); } catch (e) { rows = { error: String(e?.message ?? e) }; }\n pauses.push({ at: performance.now(), wall: Date.now(), point, call: fn ? { fn, entries: calls.get(fn).entries, live: calls.get(fn).entries - calls.get(fn).exits } : null, hit: params.hitBreakpoints, frames: frames.filter((f) => f.url !== SELF), asyncFrames, ...(Object.keys(pkeys).length ? { keys: pkeys } : {}), scopes, watch, rows });\n };\n // Which call a pause belongs to. Before a script with a point runs, find the function around each point: scanning breakable locations\n // from the top of the script, the first one whose function-restricted range reaches the point starts that range. Breakpoints on the\n // function's first location and on its returns count entries and exits; a pause stores both, so check and render can treat two pauses\n // as one call only when no entry came between them and exactly one call was live.\n const texts = new Map();\n const source = async (scriptId) => texts.get(scriptId) ?? texts.set(scriptId, (await call('Debugger.getScriptSource', { scriptId })).scriptSource).get(scriptId);\n // Entry candidates are the function's locations outside the parameter list (a default value runs only for some calls), in source order,\n // up to the first that isn't a `call` location: V8 breaks at a call after its arguments, so any of them may run first. A parameter location is inside parentheses\n // that close into `{` or `=>` and don't follow `if`, `for`, `while`, `switch`, `catch` or `with`.\n const inParams = (text, l) => {\n let i = 0;\n for (let n = 0; n < l.lineNumber; n++) i = text.indexOf('\\n', i) + 1;\n i += l.columnNumber;\n for (let depth = 0; i < text.length; i++) {\n const c = text[i];\n if (c === '\"' || c === \"'\" || c === '`') { for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\\\') i++; }\n else if (c === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) { i = text.indexOf(text[i + 1] === '/' ? '\\n' : '*/', i + 2); if (i < 0) return false; }\n else if ('([{'.includes(c)) depth++;\n else if (')]}'.includes(c) && depth) depth--;\n else if (c !== ')' && ')]}'.includes(c)) return false;\n else if (c === ')') {\n if (!/^\\s*(\\{|=>)/.test(text.slice(i + 1, i + 200))) return false;\n let open = i - 1;\n for (let d = 0; open >= 0; open--) { if (text[open] === ')') d++; else if (text[open] === '(') { if (!d) break; d--; } }\n return !/\\b(if|for|while|switch|catch|with)\\s*$/.test(text.slice(Math.max(0, open - 20), open));\n }\n }\n return false;\n };\n const track = async (scriptId) => {\n const loc = (l) => `${l.lineNumber}:${l.columnNumber}`;\n for (const [bp, locs] of resolved) for (const at of locs.filter((l) => l.scriptId === scriptId)) {\n const end = { ...at, columnNumber: at.columnNumber + 1 };\n const { locations: before } = await call('Debugger.getPossibleBreakpoints', { start: { scriptId, lineNumber: 0, columnNumber: 0 }, end });\n let first = null;\n for (const q of before) {\n const { locations } = await call('Debugger.getPossibleBreakpoints', { start: q, end, restrictToFunction: true });\n if (locations.some((l) => loc(l) === loc(at))) { first = locations[0]; break; }\n }\n if (!first) continue;\n const fn = `${scriptId}:${loc(first)}`;\n if (!calls.has(fn)) {\n const { locations: body } = await call('Debugger.getPossibleBreakpoints', { start: first, restrictToFunction: true });\n const text = await source(scriptId), own = body.filter((l) => !inParams(text, l)), last = own.findIndex((l) => l.type !== 'call');\n if (last < 0) continue;\n const c = { entries: 0, exits: 0, candidates: [], entry: null };\n calls.set(fn, c);\n for (const [kind, l] of [...own.slice(0, last + 1).map((l) => ['entry', l]), ...body.filter((l) => l.type === 'return').map((l) => ['exit', l])]) {\n const at = `${scriptId}:${loc(l)}`, id = placed.get(at) ?? (await call('Debugger.setBreakpoint', { location: l })).breakpointId;\n placed.set(at, id);\n counter.set(id, [...(counter.get(id) ?? []), { kind, fn }]);\n if (kind === 'entry') c.candidates.push(id);\n }\n }\n if (!fnOfPoint.has(bp)) fnOfPoint.set(bp, new Map());\n fnOfPoint.get(bp).set(scriptId, fn);\n }\n };\n await call('Debugger.enable');\n await call('Debugger.setInstrumentationBreakpoint', { instrumentation: 'beforeScriptExecution' });\n await call('Debugger.setAsyncCallStackDepth', { maxDepth: 32 });\n for (const b of points) {\n const r = await call('Debugger.setBreakpointByUrl', { url: b.url, lineNumber: b.line - 1, columnNumber: b.col ? b.col - 1 : 0 });\n bpPoint.set(r.breakpointId, b.id);\n }\n s.on('Debugger.breakpointResolved', ({ params }) => { resolved.set(params.breakpointId, [...(resolved.get(params.breakpointId) ?? []), params.location]); const id = bpPoint.get(params.breakpointId); const p = points.find((x) => x.id === id); if (p && params.location.lineNumber + 1 !== p.line) unresolved.push(`${id} ${p.file}:${p.line} -> :${params.location.lineNumber + 1}`); });\n parentPort.on('message', (m) => { if (m.arm) armed = m.arm; if (m.disarm) armed = null; if (m.dump) parentPort.postMessage({ pauses, unresolved }); });\n parentPort.postMessage('ready');\n}\n","render.mjs":"// The page: a top bar with a stepper, a navigator (chapters, sequence diagram or steps) and an inspector for the current step, all without JS.\n// Usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]\n// VIEWER_DEFAULTS='level=seq;show=note,stack;inspector=off' changes which inputs start checked (level: ch, seq, steps; show: note stack vars values wire source inv; inspector: on, off).\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { basename, dirname } from 'node:path';\nimport { codeToTokens } from 'shiki';\nimport { cn, buttonVariants, badgeVariants, tabsList, tabsTrigger, checkbox, label as LABEL, menuContent, menuCheckboxItem, popoverContent, table as TBL, themeCss, tailwindCss } from './ui.mjs';\nimport { readJson, loadTopic, rootOf, relTo, stackOf, hitsOf, plain, invStep, authoredOf, chaptersOf, plural, sourceOf, diffOf, previousInCall } from './lib.mjs';\nconst require = createRequire(import.meta.url);\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile, out] = argv;\nif (!out) { console.error('usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, N = P.length;\nconst ROOT = rootOf(topic, rec);\nconst rel = relTo(ROOT, topic.dir);\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst code = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');\nconst base = (url) => rel(url).split('/').pop();\nconst same = (a, b) => JSON.stringify(a) === JSON.stringify(b);\nconst href = (i) => `#step-${i + 1}`;\nconst A = (i, cls, body, title) => `<a class=\"${cls}\" href=\"${href(i)}\"${title ? ` title=\"${esc(title)}\"` : ''}>${body}</a>`;\n\n// Actors. A frame belongs to the first actor whose `frames` regex matches its path relative to topic.root (its full url when outside it).\nconst ACT = topic.actors.map((a) => ({ ...a, fre: a.frames ? new RegExp(a.frames) : null, ure: a.urls ? new RegExp(a.urls) : null }));\nconst frameActor = new Map();\nconst actorOfUrl = (url) => { if (!frameActor.has(url)) { const r = rel(url); frameActor.set(url, ACT.find((a) => a.fre && (a.fre.test(r) || a.fre.test(url)))?.name ?? null); } return frameActor.get(url); };\nconst STORES = ACT.filter((a) => a.kind === 'store').map((a) => a.name);\n// Lanes: an actor with `key` gets one lifeline per value record.mjs evaluated on its frames. At each step the actor's lane is the value on\n// its innermost frame that has one, else its lane at the step before.\nconst keyed = new Set(ACT.filter((a) => a.key || a.pauseKey).map((a) => a.name));\n// A pause's lane key: from a frame of the actor (key), else from the pause itself (pauseKey), else the last one seen.\nconst keyAt = (p, n) => p.frames.find((x) => x.keys?.[n] !== undefined)?.keys[n] ?? p.keys?.[n];\nconst laneVal = (() => { const last = {}; return P.map((p) => { for (const n of keyed) { const v = keyAt(p, n); if (v !== undefined) last[n] = String(v); } return { ...last }; }); })();\nconst lane = (name, i) => name && keyed.has(name) && laneVal[i][name] != null ? `${name}#${laneVal[i][name]}` : name;\nconst laneLabel = (id) => id?.includes('#') ? `${id.slice(0, id.indexOf('#'))} (${id.slice(id.indexOf('#') + 1)})` : id;\nconst laneSub = (id) => id?.includes('#') ? id.slice(id.indexOf('#') + 1) : '';\n\nconst stacks = P.map(stackOf);\nconst hits = hitsOf(P);\nconst noteOf = P.map((p, i) => notes.find((n) => n.point === p.point && (n.hit ?? 0) === hits[i]));\nP.forEach((p, i) => { if (!noteOf[i]) throw new Error(`no note for step ${i + 1} (${p.point} hit ${hits[i]}); run check.mjs`); });\nconst invAt = (v) => invStep(P, hits, v);\n// Frames from the first step's paused function inward; everything outside it is the scenario's own calls and request routing.\nconst entry = P[0].frames[0];\nconst depth = stacks.map((s) => { const i = s.findLastIndex((f) => f.url === entry.url && f.fn === entry.fn); return i < 0 ? s.length : i + 1; });\nconst flat = (x, p = '', out = []) => { if (x && typeof x === 'object' && Object.keys(x).length) { for (const [k, v] of Object.entries(x)) flat(v, p ? `${p}.${k}` : k, out); } else out.push([p, x]); return out; };\nconst kvLine = (x) => flat(x).map(([k, v]) => `${k} = ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ');\nconst keyOf = (p) => `${rel(p.frames[0].url)}:${p.frames[0].line}`;\n\n// Values.\nconst isPrim = (v) => !v || typeof v.v !== 'object' || v.v === null;\nconst fmtPrim = (v) => {\n if (!v) return '<span class=\"v-u\">undefined</span>';\n if (v.t === 'string') { const ls = String(v.v).split('\\n'); return ls.length > 3 ? `<span class=\"v-s\">\"${esc(ls[0])}</span><details class=\"more\"><summary>${ls.length - 1} more lines</summary><span class=\"v-s pre\">${esc(ls.slice(1).join('\\n'))}\"</span></details>` : `<span class=\"v-s\">\"${esc(ls.join('⏎'))}\"</span>`; }\n if (v.t === 'number' || v.t === 'bigint') return `<span class=\"v-n\">${esc(v.v)}</span>`;\n if (v.t === 'boolean') return `<span class=\"v-b\">${v.v}</span>`;\n if (v.t === 'null' || v.t === 'undefined') return `<span class=\"v-u\">${v.t}</span>`;\n if (v.t === 'function') return `<span class=\"v-f\">ƒ</span>`;\n return `<span class=\"v-o\">${esc(String(v.v ?? v.t).split('\\n')[0])}</span>`;\n};\nconst label = (v) => v.t === 'array' ? `Array(${v.n})` : v.t === 'map' || v.t === 'set' ? (v.d ?? `${v.t}(${v.n})`) : v.t;\nconst inline = (v, d = 0) => {\n if (isPrim(v)) return fmtPrim(v);\n if (d > 0) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const e = Object.entries(v.v);\n if (v.t === 'array' && e.length && e.every(([, x]) => !isPrim(x))) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const body = e.map(([k, x]) => v.t === 'array' || v.t === 'set' ? inline(x, 1) : `${esc(k)}: ${inline(x, 1)}`).join(', ');\n return `<span class=\"v-o\">${v.t === 'array' ? `(${v.n}) [` : '{'}</span>${body}<span class=\"v-o\">${v.t === 'array' ? ']' : '}'}</span>`;\n};\nconst json = (x) => JSON.stringify(x, null, 1);\nconst varsAt = (i) => Object.assign({}, ...P[i].scopes.filter((s) => s.type !== 'closure').map((s) => s.vars));\nconst prevSame = previousInCall(P);\nconst done = rec.marks.find((m) => m.phase === 'done')?.info;\n\n// HTTP: a watch value with `method` and `url` is a request, recorded where it is received. Its receiver is the actor whose `urls` matches the url\n// (else the paused frame's actor); its sender is the nearest outer frame of another actor. Its response is the next watch value with a numeric `status`\n// paused in the sender, or, when none is, the scenario's done mark ({status, body}) at the last step.\nconst isReq = (v) => v && typeof v === 'object' && typeof v.method === 'string' && typeof v.url === 'string';\nconst isResp = (v) => v && typeof v === 'object' && typeof v.status === 'number' && !('method' in v);\nconst reqs = [];\nlet doneUsed = false;\nP.forEach((p, h) => p.watch.forEach((w) => {\n if (!isReq(w.value)) return;\n const v = w.value, u = new URL(v.url);\n const to = ACT.find((a) => a.ure && a.ure.test(v.url))?.name ?? actorOfUrl(p.frames[0].url) ?? 'peer';\n const from = stacks[h].map((f) => actorOfUrl(f.url)).find((a) => a && a !== to) ?? ACT.find((a) => a.kind !== 'store' && a.name !== to)?.name ?? 'caller';\n let e = P.findIndex((q, j) => j > h && q.watch.some((x) => isResp(x.value)) && actorOfUrl(q.frames[0].url) === from);\n let status, got;\n if (e >= 0) { const r = P[e].watch.find((x) => isResp(x.value)).value; status = r.status; got = r.body ?? r.text; if (typeof got === 'string') { try { got = JSON.parse(got); } catch {} } }\n else if (done && done.status !== undefined && !doneUsed) { doneUsed = true; e = N - 1; status = done.status; got = done.body; }\n else e = null;\n reqs.push({ from: lane(from, h), to: lane(to, h), method: v.method, path: u.pathname, url: v.url, start: h, end: e, status, sent: v.body, got, viaDone: e === N - 1 && got === done?.body });\n}));\n\n// SQL: a watch value that is a list of statements ({sql} or {other}) is a batch sent to the first store; the next watch with `changed` is its answer.\nconst tableOfSql = (s) => s.match(/^\\s*(?:WITH[\\s\\S]*?\\)\\s*)?(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(\\w+)/i)?.[1] ?? s.match(/\\bFROM\\s+(\\w+)/i)?.[1] ?? '?';\nconst isStmts = (v) => Array.isArray(v) && v.length && v.every((x) => x && typeof x === 'object' && ('sql' in x || 'other' in x));\nconst batch = (() => { const j = P.findIndex((p) => p.watch.some((w) => isStmts(w.value))); if (j < 0 || !STORES.length) return null;\n const list = P[j].watch.find((w) => isStmts(w.value)).value, pw = P[j].watch.find((w) => typeof w.value === 'number'), prepared = pw?.value ?? list.length, preparedExpr = pw?.expression ?? 'the batch';\n const d = P.findIndex((p, k) => k > j && p.watch.some((w) => Array.isArray(w.value?.changed)));\n const changed = d < 0 ? null : P[d].watch.find((w) => Array.isArray(w.value?.changed)).value.changed;\n return { at: j, done: d, prepared, preparedExpr, changed, store: STORES[0], from: lane(actorOfUrl(P[j].frames[0].url), j), stmts: list.map((x) => {\n if (x.sql) { const w = /^\\s*(INSERT|UPDATE|DELETE|REPLACE)/i.test(x.sql); return { op: w ? x.sql.trim().split(/\\s+/)[0].toLowerCase() : 'select', table: tableOfSql(x.sql), name: x.name, full: x.sql }; }\n const o = JSON.parse(x.other); return { op: o.kind, table: o.table, name: String(o.row?.id ?? (o.set ? 'set ' + Object.keys(o.set).join(', ') : '')), full: json(o) }; }) }; })();\n\n// Rows: topic.stores' tables. A table key \"<store>.<table>\" belongs to that store actor, any other key to the first store.\nconst TABLES = Object.keys(P[0].rows ?? {}).filter((t) => Array.isArray(P[0].rows[t]));\nif (TABLES.length && !STORES.length) { ACT.push({ name: 'rows', kind: 'store' }); STORES.push('rows'); }\nconst storeOf = (t) => STORES.find((s) => t.startsWith(s + '.')) ?? STORES[0];\nconst pkCol = new Map();\nconst pk = (t, r) => { if (!pkCol.has(t)) pkCol.set(t, 'id' in r ? 'id' : Object.keys(r).find((k) => /(^|_)id$/i.test(k)) ?? null); const c = pkCol.get(t); return c ? String(r[c]) : JSON.stringify(r); };\nconst rowsOf = (i, t) => Array.isArray(P[i].rows?.[t]) ? P[i].rows[t] : [];\nconst tdiff = (i) => i === 0 ? [] : TABLES.map((t) => {\n const a = new Map(rowsOf(i - 1, t).map((r) => [pk(t, r), r])), b = new Map(rowsOf(i, t).map((r) => [pk(t, r), r]));\n const added = [...b.keys()].filter((k) => !a.has(k)), removed = [...a.keys()].filter((k) => !b.has(k));\n const changed = [...b.keys()].filter((k) => a.has(k) && !same(a.get(k), b.get(k))).map((k) => ({ id: k, cols: Object.keys(b.get(k)).filter((c) => !same(a.get(k)[c], b.get(k)[c])).map((c) => ({ c, from: a.get(k)[c], to: b.get(k)[c] })) }));\n return { t, added: added.map((k) => b.get(k)), removed, changed };\n}).filter((d) => d.added.length || d.removed.length || d.changed.length);\nconst diffs = P.map((_, i) => tdiff(i));\n// The writer of a row change is the actor paused at the step before it (the code that ran in between).\n// Parts: each source file with a breakpoint, on its actor's lifeline.\nconst bpFiles = new Set(P.map((p) => p.frames[0].url));\nconst partOf = (url) => bpFiles.has(url) ? rel(url) : null;\nconst chainOf = (i) => { const out = []; for (const f of stacks[i].slice(0, depth[i]).reverse()) { const p = partOf(f.url); if (!p) continue; if (out.at(-1)?.p === p) { out.at(-1).fns.push(f.fn); out.at(-1).line = f.line; } else out.push({ p, a: lane(actorOfUrl(f.url), i), fns: [f.fn], line: f.line }); } return out; };\nconst chains = P.map((_, i) => chainOf(i));\nconst fnName = (c) => c.fns.find((f) => f !== '(anonymous)') ?? c.fns[0];\nconst topFn = (i) => P[i].frames[0].fn === '(anonymous)' ? (P[i].scopes.find((s) => s.type === 'closure')?.name || '(anonymous)') : P[i].frames[0].fn;\n\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start.\nconst CH = chaptersOf(topic, P);\nif (!CH) throw new Error('a chapter\\'s point is not hit at or after the previous chapter\\'s start; run check.mjs');\nconst chapOf = P.map((_, i) => CH.find((c) => i >= c.from && i <= c.to).k);\nconst rowsChanged = batch?.changed ? batch.changed.reduce((a, b) => a + (b || 0), 0) : 0;\n// Rows the batch changed, per table, from its answer: the store square, the store rows list and the arrow all count from this.\nconst batchRows = batch?.changed ? batch.stmts.reduce((m, st, k) => (batch.changed[k] ? m.set(st.table, (m.get(st.table) ?? 0) + batch.changed[k]) : m), new Map()) : new Map();\nconst batchRowsTxt = () => [...batchRows].map(([t, c]) => `${t} ${c}`).join(', ');\nconst opsOf = (d) => [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} updated`, d.removed.length && `${d.removed.length} deleted`].filter(Boolean).join(', ');\nconst tableOps = (d) => `${d.t} ${opsOf(d)}`;\nconst respLine = (st, body) => { const l = flat(body ?? {}); const nested = l.filter(([k]) => k.includes('.')); return `${st}${l.length ? ' ' + kvLine(Object.fromEntries(nested.length ? nested : l)) : ''}`; };\nconst cd = (x) => `<code>${esc(x)}</code>`;\nconst fileLine = (i) => keyOf(P[i]);\n// The operation that defines a chapter, from the step data: an HTTP exchange, the SQL batch, a response, or the tables written.\nconst whoOf = (c) => `${[...new Set(c.idx.map(topFn))].slice(0, 3).map(cd).join(', ')} ${cd(fileLine(c.from))}`;\nconst titleOf = (c) => c.title ? code(c.title) : whoOf(c);\n\nconst DIFF = baseFile ? diffOf(topic, rec, readJson(baseFile), basename(baseFile)) : null;\nconst differs = (i) => DIFF && DIFF.steps[i].kinds.length > 0;\n\n// Changes at each step: requests and responses, the batch, table row counts, frames new to the run, and variables changed since the last pause in the same call.\n// Lifelines: every declared actor in topic order, including one no step pauses in; a keyed actor gets one per key value, in order of first use.\nconst LIFE = ACT.flatMap((a) => { const vs = keyed.has(a.name) ? [...new Set(laneVal.map((v) => v[a.name]).filter((v) => v != null))] : []; return vs.length ? vs.map((v) => ({ ...a, id: `${a.name}#${v}`, sub: v })) : [{ ...a, id: a.name, sub: '' }]; });\n// Each lifeline gets an equal column with its line in the middle, so every header fits above its own line.\nconst LX = Object.fromEntries(LIFE.map((a, k) => [a.id, (100 / LIFE.length) * (k + 0.5)]));\nconst seqRows = [];\nconst add = (o, r) => seqRows.push({ o, ...r });\nconst reqAt = (i, to) => reqs.some((r) => r.start === i && r.to === to);\nconst respAt = (i, from) => reqs.some((r) => r.end === i && r.to === from);\n// A keyed actor runs one stack per key (two sessions at once). A step is compared with the last step of its own stack, so a pause in another\n// session's stack draws no return for calls that are still running.\nconst ownKey = P.map((p) => JSON.stringify([...keyed].map((n) => keyAt(p, n) ?? null).some((v) => v != null) ? [...keyed].map((n) => keyAt(p, n) ?? null) : null));\nfor (let i = 0; i < N; i++) {\n const j = ownKey[i] === 'null' ? i - 1 : P.findLastIndex((_, k) => k < i && ownKey[k] === ownKey[i]);\n const prev = j >= 0 ? chains[j] : chains[0].slice(0, 1), cur = chains[i];\n let c = 0; while (c < prev.length && c < cur.length && prev[c].p === cur[c].p && prev[c].a === cur[c].a && prev[c].fns[0] === cur[c].fns[0]) c++;\n for (let k = prev.length - 1; k >= Math.max(c, 1); k--) {\n if (prev[k].a !== prev[k - 1].a) { if (!respAt(i, prev[k].a)) add(i, { kind: 'arr call ret', from: prev[k].a, to: prev[k - 1].a, label: `ret ${fnName(prev[k])}`, short: `ret ${fnName(prev[k])}` }); }\n else add(i, { kind: 'self ret', at: prev[k].a, d: k - 1, label: `ret <code>${esc(fnName(prev[k]))}</code> <code>${esc(base(prev[k].p))}:${prev[k].line}</code>` });\n }\n for (const r of reqs) if (r.end === i && !r.viaDone) add(i, { kind: 'arr http ret', from: r.to, to: r.from, label: `${r.status}${r.got === undefined ? '' : ' ' + (typeof r.got === 'string' ? r.got : kvLine(r.got))}`, short: String(r.status) });\n for (const r of reqs) if (r.start === i) add(i, { kind: 'arr http', from: r.from, to: r.to, label: `${r.method} ${r.path}`, short: `${r.method} ${r.path}` });\n for (let k = Math.max(c, 1); k < cur.length; k++) {\n if (cur[k].a !== cur[k - 1].a) { if (!reqAt(i, cur[k].a)) add(i, { kind: 'arr call', from: cur[k - 1].a, to: cur[k].a, label: `call ${fnName(cur[k])} ${base(cur[k].p)}:${cur[k].line}`, short: `call ${fnName(cur[k])}` }); }\n else add(i, { kind: 'self', at: cur[k].a, d: k - 1, label: `call <code>${esc(fnName(cur[k]))}</code> <code>${esc(base(cur[k].p))}:${cur[k].line}</code>` });\n }\n if (batch && i === batch.at) add(i, { kind: 'arr sql', from: batch.from, to: batch.store, label: `db.batch ${batch.stmts.length} stmts`, short: `db.batch ${batch.stmts.length} stmts` });\n if (batch && i === batch.done) add(i, { kind: 'arr sql ret', from: batch.store, to: batch.from, label: `${rowsChanged} rows changed`, short: `${rowsChanged} rows changed` });\n if (batch && i === batch.done && batchRows.size) add(i, { kind: 'mk', at: batch.store, label: esc(batchRowsTxt()), short: batchRowsTxt() });\n else if (diffs[i].length) for (const s of STORES) { const ds = diffs[i].filter((d) => storeOf(d.t) === s); if (ds.length) add(i, { kind: 'mk', at: s, label: esc(ds.map(tableOps).join(', ')), short: ds.map(tableOps).join(', ') }); }\n}\nfor (const r of reqs) if (r.viaDone) add(N - 1, { kind: 'arr http ret', from: r.to, to: r.from, label: respLine(r.status, r.got), short: respLine(r.status, r.got) });\nseqRows.forEach((r) => { if (r.kind.startsWith('arr') && (!(r.from in LX) || !(r.to in LX))) r.drop = true; });\nfor (let k = seqRows.length - 1; k >= 0; k--) if (seqRows[k].drop) seqRows.splice(k, 1);\nseqRows.forEach((r) => { r.ch = chapOf[r.o]; r.steps = []; });\n// Call stack: outermost first, indented by depth; frames new since the previous step and frames whose line moved are marked.\n// Source + scope: source around the paused line, highlighted once per file; token colours become shared classes.\nconst colours = new Map(); const cls = (l, d) => { const k = `${l}|${d}`; if (!colours.has(k)) colours.set(k, `k${colours.size}`); return colours.get(k); };\nconst tokens = new Map();\nconst langOf = (f) => /\\.(m?js|cjs|jsx)$/.test(f) ? 'javascript' : /\\.tsx$/.test(f) ? 'tsx' : /\\.go$/.test(f) ? 'go' : /\\.py$/.test(f) ? 'python' : /\\.rs$/.test(f) ? 'rust' : 'typescript';\nfor (const url of new Set(P.map((p) => p.frames[0].url))) {\n const f = url.replace('file://', '');\n const src = sourceOf(rec, url) ?? '';\n const { tokens: t } = await codeToTokens(src, { lang: langOf(f), themes: { light: 'github-light', dark: 'github-dark' }, defaultColor: false });\n tokens.set(url, { lines: src.split('\\n'), t });\n}\nconst tokHtml = (t) => t.map((x) => { const st = x.htmlStyle ?? {}; return `<span class=\"${cls(st['--shiki-light'] ?? x.color, st['--shiki-dark'])}\">${esc(x.content)}</span>`; }).join('');\nconst unpack = (x) => { if (x && typeof x === 'object' && typeof x.other === 'string') { try { return JSON.parse(x.other); } catch {} } return x; };\nconst linesHtml = (i) => {\n const f = P[i].frames[0], { lines, t } = tokens.get(f.url), L = f.line, vars = varsAt(i), before = prevSame[i] == null ? null : varsAt(prevSame[i]);\n const mark = (n) => !before ? '' : !(n in before) ? 'new' : same(before[n], vars[n]) ? '' : 'chg';\n const names = Object.keys(vars).filter((n) => n.length > 1 && vars[n]?.t !== 'function' && n !== 'this');\n const from = Math.max(1, L - 4), to = Math.min(lines.length, L + 4);\n let rows = '';\n const re = (x) => x.replace(/\\$/g, '\\\\$');\n const useRe = (x) => new RegExp(`(^|[^\\\\w$.])${re(x)}(?![\\\\w$])(?!\\\\s*:(?!:))`);\n for (let n = from; n <= to; n++) {\n const used = n === L ? names.filter((x) => useRe(x).test(lines[n - 1])) : [];\n const iv = used.map((x) => `<span class=\"iv ${mark(x)}\">${esc(x)} = ${isPrim(vars[x]) ? fmtPrim(vars[x]) : inline(vars[x])}</span>`).join('');\n if (n === L && DIFF?.steps[i].was != null) rows += `<div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(DIFF.steps[i].was)}</del></span></div>`;\n rows += `<div class=\"ln ${n === L ? 'cur' : ''}\"><span class=\"no\">${n}</span><span class=\"src\">${tokHtml(t[n - 1] ?? [])}${iv}</span></div>`;\n }\n return `<div class=\"file\"><code>${esc(rel(f.url))}:${L}</code> in <code>${esc(topFn(i))}</code>${prevSame[i] != null ? `, last paused in this call at ${A(prevSame[i], 'go', `step ${prevSame[i] + 1}`)}` : ''}</div><div class=\"code\">${rows}</div>`;\n};\n\n// Page, table first.\nconst sv = (x) => x === undefined ? 'undefined' : typeof x === 'string' ? x : JSON.stringify(x);\nconst clip = (s, n = 120) => s.length > n ? `<details class=\"clip\"><summary>${esc(s.slice(0, n))}…</summary><span class=\"full\">${esc(s)}</span></details>` : esc(s);\nconst valCell = (v) => isPrim(v) ? fmtPrim(v) : inline(v);\n// Actors: what each did in this run, from the crossings it sent and received and the frames it paused in.\n// Step detail tables.\nconst flatTable = (x) => x === undefined ? '<span class=\"dim\">not recorded</span>' : `<table class=\"${TBL} t sm kvt\"><tbody>${flat(x).map(([k, v]) => `<tr><th>${esc(k)}</th><td>${clip(sv(v), 240)}</td></tr>`).join('')}</tbody></table>`;\n// Statement ops that change rows; the rest read. At the batch's answer only the statements that changed rows are listed, the full list is at the batch step.\nconst WRITE_OPS = /^(put|insert|patch|update|delete|upsert|replace)/i;\nconst stmtList = (i, changedOnly = false) => { const raw = P[batch.at].watch.find((w) => isStmts(w.value)).value;\n return `<div class=\"stl\"><div class=\"sth\"><span>Statement</span><span>Table</span><span>Rows changed</span></div>${raw.map((x, k) => { const st = batch.stmts[k], o = unpack(x) ?? {}; const { kind, table, ...rest } = o; if (changedOnly && !(batch.changed?.[k])) return '';\n const body = 'sql' in o ? `<div class=\"pre\">${esc(o.sql.trim())}</div>${o.args?.length ? `<div class=\"sub\">Arguments</div><div class=\"pre\">${esc(JSON.stringify(o.args))}</div>` : ''}` : `<table class=\"${TBL} t sm kvt\"><tbody>${flat(rest).map(([p, v]) => `<tr><th>${esc(p)}</th><td class=\"pre\">${esc(typeof v === 'string' ? v : JSON.stringify(v))}</td></tr>`).join('')}</tbody></table>`;\n return `${k === batch.prepared && !changedOnly ? `<div class=\"stsplit\">Statements ${k} to ${raw.length - 1}, after the ${batch.prepared} in <code>${esc(batch.preparedExpr)}</code>, in the same transaction</div>` : ''}<details class=\"stq\"><summary><span><span class=\"dim\">${k}</span> ${esc(st.op)}</span><span><code>${esc(st.table)}</code></span><span class=\"num\">${batch.changed ? batch.changed[k] ?? 0 : ''}</span></summary><div class=\"stb\">${st.name ? `<div class=\"sub\">Name <code>${esc(st.name)}</code></div>` : ''}${body}</div></details>`; }).join('')}</div>`; };\nconst wireTables = (i) => {\n const out = [];\n for (const r of reqs) {\n if (r.start === i) out.push(`<h4>${esc(laneLabel(r.from))} to ${esc(laneLabel(r.to))}, ${esc(r.method)} <code>${esc(r.path)}</code>${r.end !== null ? `, answered at ${A(r.end, 'go', 'step ' + (r.end + 1))}` : ''}</h4>${flatTable(r.sent)}`);\n if (r.end === i) out.push(`<h4>${esc(laneLabel(r.to))} answers ${esc(laneLabel(r.from))}, ${esc(r.status)} for ${esc(r.method)} <code>${esc(r.path)}</code>, sent at ${A(r.start, 'go', 'step ' + (r.start + 1))}</h4>${flatTable(r.got)}`);\n }\n const pw = P[i].watch.filter((w) => !isReq(w.value) && !isStmts(w.value) && !(Array.isArray(w.value?.changed)));\n if (batch && i === batch.at) out.push(`<h4>${esc(laneLabel(batch.from))} to ${esc(batch.store)}, <code>db.batch</code> of ${plural(batch.stmts.length, 'statement')}, ${batch.stmts.filter((x) => WRITE_OPS.test(x.op)).length} that write and ${batch.stmts.filter((x) => !WRITE_OPS.test(x.op)).length} that read</h4>${stmtList(i)}<p class=\"dim\">Rows changed come from the answer at ${A(batch.done, 'go', 'step ' + (batch.done + 1))}.</p>`);\n if (batch && i === batch.done) out.push(`<h4>${esc(batch.store)} answers ${esc(laneLabel(batch.from))}, ${plural(rowsChanged, 'row')} changed in ${plural(batchRows.size, 'table')}</h4>${stmtList(i, true)}<p class=\"dim\">The other ${plural(batch.stmts.length - batch.changed.filter(Boolean).length, 'statement')} changed no rows. ${A(batch.at, 'go', `Step ${batch.at + 1}`, open(batch.at))} lists all ${batch.stmts.length}.</p>`);\n for (const w of pw) out.push(`<h4>Recorded expression <code>${clip(w.expression, 80)}</code></h4>${w.value === null || typeof w.value !== 'object' ? `<p><code>${esc(sv(w.value))}</code></p>` : flatTable(w.value)}`);\n if (i === N - 1 && done) out.push(`<h4>Scenario result</h4>${flatTable(done)}`);\n return out.join('');\n};\n// Glossary terms: the first use of a term in a text becomes a <details class=\"term\"> with its definition. A term matches a backticked name\n// exactly, and, when it is plain lowercase words, the same words (or their plural) in the prose.\nconst GL = (topic.glossary ?? []).map((g) => ({ ...g, re: /^[a-z][a-z ]*$/.test(g.term) ? new RegExp(`\\\\b${g.term}s?\\\\b`, 'i') : null }));\nconst termHtml = (label, g) => `<details class=\"term\"><summary>${label}</summary><span class=\"def\"><b>${esc(g.term)}</b> ${esc(g.def)}</span></details>`;\nconst termify = (text) => {\n const used = new Set(); let out = '';\n String(text).split('`').forEach((seg, k) => {\n if (k % 2) { const g = GL.find((x) => x.term === seg && !used.has(x.term)); if (g) used.add(g.term); out += g ? termHtml(`<code>${esc(seg)}</code>`, g) : `<code>${esc(seg)}</code>`; return; }\n let rest = seg;\n for (;;) {\n let best = null;\n for (const g of GL) { if (!g.re || used.has(g.term)) continue; const m = g.re.exec(rest); if (m && (!best || m.index < best.m.index || (m.index === best.m.index && m[0].length > best.m[0].length))) best = { g, m }; }\n if (!best) { out += esc(rest); break; }\n out += esc(rest.slice(0, best.m.index)) + termHtml(esc(best.m[0]), best.g); used.add(best.g.term); rest = rest.slice(best.m.index + best.m[0].length);\n }\n });\n return out;\n};\nconst kindsTxt = (k) => k.length > 1 ? `${k.slice(0, -1).join(', ')} and ${k.at(-1)}` : k[0];\nconst DK = badgeVariants({ variant: 'destructive' }), SB = badgeVariants({ variant: 'outline' });\nconst dfTag = (i) => differs(i) ? `<span class=\"${DK} dk\">Differs from base in ${esc(kindsTxt(DIFF.steps[i].kinds))}</span>` : '';\nconst chDf = (c) => { const n = DIFF ? c.idx.filter(differs).length : 0; return n ? `<span class=\"${DK} dk\">${n} ${n > 1 ? 'steps differ' : 'step differs'} from base</span>` : ''; };\nconst DSTEPS = DIFF ? P.map((_, i) => i).filter(differs) : [];\nconst andList = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : xs[0];\nconst banner = DIFF ? `<p class=\"banner\"><b>Showing <code>${esc(basename(recFile))}</code>, compared with <code>${esc(DIFF.file)}</code>.</b> ${DSTEPS.length ? `${A(DSTEPS[0], 'bl', `${DSTEPS.length} ${DSTEPS.length > 1 ? 'steps differ' : 'step differs'}`)} in ${esc(andList([...new Set(DSTEPS.flatMap((i) => DIFF.steps[i].kinds))]))}. Steps ${andList(DSTEPS.map((i) => A(i, 'bl', i + 1)))}.` : `No step differs from the base recording, <code>${esc(DIFF.file)}</code>.`}${DIFF.missingBase.length ? ` Steps only in the base, ${DIFF.missingBase.map((k) => k + 1).join(', ')}.` : ''}</p>` : '';\n// Authored text (story, outcome, chapter titles, invariants) whose `values` this run does not have but the base run does, as check.mjs --base\n// reports it. Only these get the tag; text without such values is checked against this run and needs none.\nconst STALE = (() => {\n if (!DIFF) return {};\n const B = readJson(baseFile).pauses, base = authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B)) ?? [];\n return Object.fromEntries(authoredOf(topic, P, hits, CH).map((a, k) => [a.key, a.missing.filter((v) => base[k] && !base[k].missing.includes(v))]).filter(([, vs]) => vs.length));\n})();\nconst baseTag = (key) => STALE[key] ? `<div class=\"btag\">Written for the base recording, where ${andList(STALE[key].map((v) => `<code>${esc(v.name)}</code> is <code>${esc(v.value)}</code>`))}. This run differs.</div>` : '';\nconst ruleTag = (v) => baseTag(`inv${INV.indexOf(v)}`);\nconst wherePath = (x) => esc(x).replace(/\\//g, '/<wbr>');\nconst open = (i) => `Open step ${i + 1}`;\n\n// Sequence diagram: only crossings (arrows between actors and store writes), grouped by chapter. A chapter with no crossing gets one self-call row\n// in its lane, labelled with the function and the note of the chapter's last step. A chapter whose first steps come before its first crossing\n// gets a self-call row for them at its top, so their dots never sit on a later row. Each step's dot sits on the latest row of its chapter at or before it.\n// A self-call row's label: the step's function, unless its note already starts with it, then the note.\nconst selfLabel = (i) => `${noteOf[i].text.startsWith('`' + topFn(i) + '`') ? '' : `<code>${esc(topFn(i))}</code> `}${code(noteOf[i].text)}`;\nconst xRows = seqRows.filter((r) => !r.kind.startsWith('self'));\nfor (const c of CH) if (!xRows.some((r) => r.ch === c.k)) { const row = { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.to) }; const at = xRows.findIndex((r) => r.ch > c.k); xRows.splice(at < 0 ? xRows.length : at, 0, row); }\nfor (const c of CH) { const at = xRows.findIndex((r) => r.ch === c.k); if (at >= 0 && xRows[at].o > c.from) xRows.splice(at, 0, { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.from) }); }\nP.forEach((_, i) => { const inCh = xRows.filter((r) => r.ch === chapOf[i]); (inCh.filter((r) => r.o <= i).at(-1) ?? inCh[0]).steps.push(i); });\nconst lls = LIFE.map((a) => `<span class=\"ll\" style=\"left:${LX[a.id]}%\"></span>`).join('');\nconst atX = (x, cls, label) => x > 60 ? `<span class=\"${cls} rt\" style=\"margin-right:${100 - x}%\"><span class=\"lab\">${label}</span></span>` : `<span class=\"${cls}\" style=\"margin-left:${x}%\"><span class=\"lab\">${label}</span></span>`;\n// Run ids (a token of 20 or more letters and digits, with a digit) are cut to their first 6 characters after any prefix in diagram labels; the inspector shows them whole.\nconst shortIds = (x) => String(x).replace(/\\b([a-z]+_)?(?=[0-9A-Za-z]*\\d)([0-9A-Za-z]{6})[0-9A-Za-z]{14,}\\b/g, '$1$2\\u2026');\nconst seqRow = (r) => {\n let body;\n if (r.kind === 'mk') body = atX(LX[r.at], r.kind, r.label);\n else if (r.kind.startsWith('self')) body = `<span class=\"${r.kind}\" style=\"margin-left:${LX[r.at] ?? 0}%\"><span class=\"lab\">${r.label}</span></span>`;\n else { const a = LX[r.from], b = LX[r.to], sl = shortIds(r.label); body = `<span class=\"alab ${r.kind.replace('arr ', '')}\" style=\"margin-left:${Math.min(a, b)}%\"><span class=\"lab\"${sl === r.label ? '' : ` title=\"${esc(r.label)}\"`}>${esc(sl)}</span></span><span class=\"${r.kind} ${b < a ? 'rtl' : 'ltr'}\" style=\"margin-left:${Math.min(a, b)}%;width:${Math.abs(b - a)}%\"></span>`; }\n const s0 = r.steps[0] ?? r.o;\n return `<div class=\"row${r.steps.map((s) => ' h' + s).join('')}${r.steps.some(differs) ? ' rdf' : ''}\"><span class=\"gut\">${r.steps.map((s) => A(s, `${SB} sd sd${s}${differs(s) ? ' df' : ''}`, s + 1, open(s) + (differs(s) ? `, differs from base in ${kindsTxt(DIFF.steps[s].kinds)}` : ''))).join('')}</span>${A(s0, 'area', lls + body, open(s0))}</div>`;\n};\nconst actorOf = (n) => topic.actors.find((a) => a.name === n), aboutOf = (n) => actorOf(n)?.about ?? '', roleOf = (n) => actorOf(n)?.role ?? actorOf(n)?.kind ?? '';\nconst rng = (c) => `${c.from + 1}${c.to > c.from ? '-' + (c.to + 1) : ''}`;\nconst stepsWord = (c) => c.to > c.from ? `Steps ${c.from + 1} to ${c.to + 1}` : `Step ${c.from + 1}`;\nconst chHead = (c) => `<a class=\"chh\" href=\"${href(c.from)}\" title=\"Go to step ${c.from + 1}\"><span class=\"rng\">${c.k + 1}</span> ${titleOf(c)} <span class=\"dim\">${stepsWord(c)}</span> ${chDf(c)}${c.src != null && STALE[`ch${c.src}`] ? ` <span class=\"${DK} dk\">Title written for the base recording</span>` : ''}</a>`;\nconst seqHtml = `<div class=\"seq\"><div class=\"row heads\"><span class=\"gut\"></span><span class=\"area\">${LIFE.map((a) => `<span class=\"lh\" title=\"${esc([roleOf(a.name), aboutOf(a.name)].filter(Boolean).join('. '))}\"><b>${esc(a.name)}</b>${a.sub ? `<span><code>${esc(a.sub)}</code></span>` : ''}</span>`).join('')}</span></div>${CH.map((c) => chHead(c) + xRows.filter((r) => r.ch === c.k).map(seqRow).join('')).join('')}</div>`;\nconst stepsHtml = `<div class=\"steps\"><div class=\"sh\"><span>Step</span><span>Where</span><span>What happens</span></div>${CH.map((c) => chHead(c) + `<div class=\"chg chg${c.k}\">` + c.idx.map((i) => `<a class=\"step sr sr${i}\" id=\"step-${i + 1}\" href=\"${href(i)}\" title=\"${open(i)}\"><span class=\"${SB} sn\">${i + 1}</span><span class=\"wh\"><b title=\"${esc(aboutOf(actorOfUrl(P[i].frames[0].url)))}\">${esc(actorOfUrl(P[i].frames[0].url) ?? '')}</b> <span class=\"role\">${esc(roleOf(actorOfUrl(P[i].frames[0].url)))}${laneSub(lane(actorOfUrl(P[i].frames[0].url), i)) ? `, <code>${esc(laneSub(lane(actorOfUrl(P[i].frames[0].url), i)))}</code>` : ''}</span><br> <code>${esc(topFn(i))}</code><span class=\"loc\">${wherePath(keyOf(P[i]))}</span></span><span class=\"what\">${code(noteOf[i].text)}${dfTag(i)}</span></a>`).join('') + '</div>').join('')}</div>`;\n// The chapter list (start view and Chapters level): a row per chapter with a stretched \"Go to step N\" link, so terms stay clickable above it.\nconst chapterList = `<div class=\"chs\">${CH.map((c) => `<div class=\"chr chr${c.k}\"><span class=\"cnum\">${c.k + 1}</span><div class=\"ct\">${c.title ? termify(c.title) : whoOf(c)} ${chDf(c)}${c.src != null ? baseTag(`ch${c.src}`) : ''}<span class=\"rng2\">${stepsWord(c)}</span></div><a class=\"go2\" href=\"${href(c.from)}\">Go to step ${c.from + 1}</a></div>`).join('')}${topic.outcome ? `<div class=\"chr out\"><span class=\"cnum\"></span><div class=\"ct\"><b>Outcome</b> ${termify(topic.outcome)}${baseTag('outcome')}</div></div>` : ''}</div>`;\nconst INV = topic.invariants ?? [];\nconst lower1 = (x) => x.charAt(0).toLowerCase() + x.slice(1);\nconst rulesHtml = INV.length ? `<details class=\"rules\" open><summary>Rules this flow is checked against (${INV.length})</summary><ol>${INV.map((v) => { const j = invAt(v); return `<li><div class=\"ir\">${termify(v.rule)}</div>${ruleTag(v)}<div class=\"irf\"><span class=\"loc\">${wherePath(`${v.file}:${v.line}`)}</span>${j >= 0 ? A(j, 'go', `Step ${j + 1}`, open(j)) : ''}</div></li>`; }).join('')}</ol></details>` : '';\nconst pv = rec.provenance;\nconst provenance = pv ? `A recorded run of the real code at commit <code>${esc(pv.commit)}</code>${pv.changed?.length ? ` with uncommitted changes to ${andList(pv.changed.map((f) => `<code>${esc(f)}</code>`))}` : ''}, scenario <code>${esc(rel('file://' + pv.scenario))}</code>, recorded ${esc(pv.date)}, ${pv.pauses} pauses.`\n : `A recorded run of the real code under <code>${esc(ROOT)}</code>, scenario <code>${esc(rel('file://' + (rec.scenario ?? topic.scenario)))}</code>, ${N} pauses. This recording predates provenance, so its commit and date are not stored.`;\nconst startHtml = `<section class=\"start\"><div class=\"sw\">\n<div class=\"story\">${topic.story ? termify(topic.story) : code(topic.summary ?? '')}</div>${baseTag('story')}<p class=\"prov\">${provenance}</p>\n<h2 class=\"vbg-heading-16\">Who takes part</h2><table class=\"${TBL} cast\"><tbody>${topic.actors.map((a) => `<tr><td><b>${esc(a.name)}</b></td><td class=\"dim\">${esc(a.role ?? a.kind)}</td><td>${code(a.about ?? '')}</td></tr>`).join('')}</tbody></table>\n<div class=\"go1\"><a class=\"${buttonVariants()}\" href=\"#step-1\">Start at step 1</a><span class=\"dim\">or pick a chapter below</span></div>\n${rulesHtml}<h2 class=\"vbg-heading-16\">Chapters</h2>${chapterList}<footer class=\"ft\"><span class=\"vbg-logo\" role=\"img\" aria-label=\"Vercel\"></span></footer></div></section>`;\n\n// Inspector panels, in a fixed order.\nconst full = (v) => { if (isPrim(v) || longStr(v)) return fmtPrim(v); const j = JSON.stringify(plain(v), null, 1).replace(/\\[\\s+([^[\\]{}]*?)\\s+\\]/g, (m, a) => `[${a.replace(/\\s*\\n\\s*/g, ' ')}]`), n = j.split('\\n').length;\n return n > 12 ? `<details class=\"more\"><summary>${Array.isArray(plain(v)) ? 'array' : 'object'}, ${n} lines</summary><span class=\"v-o pre\">${esc(j)}</span></details>` : `<span class=\"v-o pre\">${esc(j)}</span>`; };\nconst inScope = (b) => Object.keys(b).filter((n) => b[n]?.t !== 'function' && n !== 'this');\nconst changedPanel = (i) => {\n const b = varsAt(i), j = prevSame[i];\n let vt;\n if (j == null) vt = `<p class=\"dim\">First pause in this call of <code>${esc(topFn(i))}</code>, so nothing here has an earlier value. ${plural(inScope(b).length, 'variable')} in scope, listed under Variables in Panels.</p>`;\n else { const a = varsAt(j), c = inScope(b).filter((n) => !same(a[n], b[n]));\n vt = c.length ? `<p class=\"sub\">Since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call</p><table class=\"${TBL} t sm\"><thead><tr><th>Name</th><th>Before</th><th>After</th></tr></thead><tbody>${c.map((n) => `<tr><td><code>${esc(n)}</code></td><td class=\"val\">${n in a ? (holderOf(a, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(a, n))}</code></span>` : full(a[n])) : '<span class=\"dim\">new</span>'}</td><td class=\"val\">${holderOf(b, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(b, n))}</code></span>` : full(b[n])}</td></tr>`).join('')}</tbody></table>` : `<p class=\"dim\">No variable changed since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call.</p>`; }\n const rw = diffs[i].map((d) => { const parts = [];\n if (d.added.length) parts.push(`<details class=\"more\"><summary>${d.added.length} ${d.added.length > 1 ? 'rows' : 'row'} added</summary><table class=\"${TBL} t sm kvt\"><tbody>${d.added.map((r) => `<tr><th>${esc(pk(d.t, r))}</th><td class=\"pre\">${esc(Object.entries(r).filter(([, v]) => v !== null).map(([k, v]) => `${k} = ${sv(v)}`).join(', '))}</td></tr>`).join('')}</tbody></table></details>`);\n for (const ch of d.changed) parts.push(`row <code>${esc(ch.id)}</code> ${ch.cols.map((y) => `<code>${esc(y.c)}</code> from <span class=\"pre\">${esc(sv(y.from))}</span> to <span class=\"pre\">${esc(sv(y.to))}</span>`).join(', ')}`);\n if (d.removed.length) parts.push(`${d.removed.length} ${d.removed.length > 1 ? 'rows' : 'row'} deleted (${d.removed.map(esc).join(', ')})`);\n return `<li><code>${esc(d.t)}</code> ${parts.join('; ')}</li>`; });\n if (batch && i === batch.done) for (const [t, c] of batchRows) if (!diffs[i].some((d) => d.t === t)) rw.push(`<li><code>${esc(t)}</code> ${c} ${c > 1 ? 'rows' : 'row'} written. The recording doesn't snapshot this table, so its rows aren't shown.</li>`);\n return `${vt}${rw.length ? `<p class=\"sub\">Store rows written since the previous step${batch && i === batch.done ? `, ${rowsChanged} in all, the same count as the batch answer` : ''}</p><ul class=\"rowl\">${rw.join('')}</ul>` : ''}`;\n};\nconst stackPanel = (i) => { const s = stacks[i], showA = new Set(s.map((f) => actorOfUrl(f.url) ?? '')).size > 1;\n const tbl = (fs, head) => `<table class=\"${TBL} t sm\">${head ? `<thead><tr><th>Function</th><th>File and line</th>${showA ? '<th>Actor</th>' : ''}</tr></thead>` : ''}<tbody>${fs.map((f) => `<tr><td><code>${esc(f.fn)}</code>${f.async ? ' <span class=\"dim\">awaited</span>' : ''}</td><td class=\"loc\">${esc(rel(f.url))}:${f.line}</td>${showA ? `<td>${esc(actorOfUrl(f.url) ?? '')}</td>` : ''}</tr>`).join('')}</tbody></table>`;\n return `${tbl(s.slice(0, 3), true)}${s.length > 3 ? `<details class=\"more\"><summary>${s.length - 3} more frames</summary>${tbl(s.slice(3), false)}</details>` : ''}${showA ? '' : `<p class=\"dim\">Every frame is in <b>${esc(actorOfUrl(s[0].url) ?? 'one place')}</b>.</p>`}`; };\nconst longStr = (v) => v?.t === 'string' && String(v.v).split('\\n').length > 3;\nconst holderOf = (b, n) => longStr(b[n]) ? inScope(b).find((m) => m !== n && b[m]?.t === 'string' && String(b[m].v).length > String(b[n].v).length && String(b[m].v).includes(String(b[n].v))) : undefined;\nconst varsPanel = (i) => { const b = varsAt(i), a = prevSame[i] != null ? varsAt(prevSame[i]) : null;\n return `<table class=\"${TBL} t sm\"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>${inScope(b).map((n) => { const ch = a && !same(a[n], b[n]), h = holderOf(b, n);\n const v = h ? `<span class=\"dim\">Included in <code>${esc(h)}</code>, shown in that row.</span>` : valCell(b[n]);\n return `<tr${ch ? ' class=\"dupv\"' : ''}><td><code>${esc(n)}</code></td><td class=\"val\">${ch ? '<span class=\"seewc dim\">Changed, see What changed.</span><span class=\"vfull\">' + v + '</span>' : v}</td></tr>`; }).join('')}</tbody></table>`; };\nconst crossAt = (j) => reqs.some((r) => r.start === j) ? 'request' : reqs.some((r) => r.end === j) ? 'response' : batch && j === batch.at ? 'SQL batch' : batch && j === batch.done ? 'SQL answer' : null;\nconst wirePanel = (i) => { const w = wireTables(i); if (w) return w; let j = i + 1; while (j < N && !crossAt(j)) j++;\n return `<p class=\"dim\">Nothing crosses a boundary here. ${j < N ? `The next ${crossAt(j)} is at ${A(j, 'go', `step ${j + 1}`, open(j))}.` : 'No later step crosses one.'}</p>`; };\nconst rulesAt = (i) => INV.map((v, k) => ({ v, k })).filter(({ v }) => invAt(v) === i);\nconst invPanel = (i) => { const vs = rulesAt(i); return vs.length ? `<ul class=\"invl\">${vs.map(({ v, k }) => `<li>Rule ${k + 1}. ${termify(v.rule)} <span class=\"loc\">${esc(v.file)}:${v.line}</span>${ruleTag(v)}</li>`).join('')}</ul>` : `<p class=\"dim\">This step shows none of the ${INV.length} rules.</p>`; };\n// On a head page, a note at a step whose source changed was carried from the base run: its values are this run's, but its reasoning may not be.\nconst notePanel = (i) => `<div class=\"note\">${termify(noteOf[i].text)}</div>${DIFF && DIFF.steps[i].kinds.includes('source') ? `<div class=\"btag\">Written for the base recording. The source at <code>${esc(rel(P[i].frames[0].url))}:${DIFF.steps[i].at}</code> changed, so check this note against the head code.</div>` : ''}`;\nconst SHOW = [['note', 'Note', 'what this step shows, in one sentence', notePanel], ['source', 'Source', 'the code around the paused line', linesHtml], ['changed', 'What changed', 'the values that changed since the last pause in the same call, before and after', changedPanel], ['stack', 'Call stack', 'the functions that led here, innermost first', stackPanel], ['vars', 'Variables', 'every variable in scope at this line', varsPanel], ['wire', 'SQL, wire and recorded expressions', 'requests, responses and SQL that cross a boundary here, and the recorded expressions', wirePanel], ['inv', 'Rules', 'the rules from the start page that this step shows', invPanel]];\nconst LEVELS = [['ch', 'Chapters', chapterList + rulesHtml, 'Pick a chapter to go to its first step.'], ['seq', 'Sequence diagram', seqHtml, 'Click a numbered circle or an arrow to open that step.'], ['steps', 'Steps', stepsHtml, 'Click a row to open that step.']];\nconst defs = Object.fromEntries((process.env.VIEWER_DEFAULTS ?? '').split(';').filter(Boolean).map((kv) => kv.split('=')));\nconst lvOn = defs.level ?? 'ch', inspOn = (defs.inspector ?? 'on') !== 'off', showOn = new Set((defs.show ?? 'note,changed').split(','));\nif (!LEVELS.some(([k]) => k === lvOn) || [...showOn].some((k) => !SHOW.some(([s]) => s === k))) { console.error(`bad VIEWER_DEFAULTS ${process.env.VIEWER_DEFAULTS}`); process.exit(2); }\nconst diffBlock = (i) => { if (!differs(i)) return ''; const d = DIFF.steps[i];\n return `<div class=\"dfl\">${dfTag(i)}${d.was != null ? `<div class=\"srcdiff\"><div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(d.was.trim())}</del></span></div><div class=\"ln now\"><span class=\"no\">head</span><span class=\"src\">${esc(d.now.trim())}</span></div><div class=\"dim\">${esc(rel(P[i].frames[0].url))}:${d.at}</div></div>` : ''}${d.detail.length ? `<div class=\"dd2\">${d.detail.map(esc).join('; ')}</div>` : ''}</div>`; };\nconst badge = (i) => rulesAt(i).map(({ v, k }) => `<div class=\"badge\">This step shows rule ${k + 1}: ${termify(lower1(v.rule))}${ruleTag(v)}</div>`).join('');\nconst article = (i) => `<article class=\"art art${i}\">${diffBlock(i)}<p class=\"emp\">No panel is on. Pick some under Panels above.</p>${SHOW.map(([k, n, , f]) => `<section class=\"pn p-${k}\"><h3 class=\"vbg-heading-16\">${n}</h3>${f(i)}</section>${k === 'note' ? badge(i) : ''}`).join('')}</article>`;\nconst PRIMARY = buttonVariants(), OUTLINE = buttonVariants({ variant: 'outline' });\nconst CHEVRON_SVG = '<svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\"><path fill-rule=\"evenodd\" d=\"M14.06 5.5l-.53.53-4.82 4.82a1 1 0 0 1-1.42 0L2.47 6.03 1.94 5.5 3 4.44l.53.53L8 9.44l4.47-4.47.53-.53 1.06 1.06z\"/></svg>';\n// A menu is a button that opens a popover element: Enter opens it, Escape and a click outside close it, with no script.\nconst menu = (id, button, content, cls = popoverContent) => `<button type=\"button\" class=\"${OUTLINE}\" popovertarget=\"pp-${id}\" style=\"anchor-name:--pp-${id}\">${button}${CHEVRON_SVG}</button><div id=\"pp-${id}\" popover class=\"${cn(cls, 'pop')}\" style=\"position-anchor:--pp-${id}\">${content}</div>`;\nconst bar = P.map((_, i) => { const c = CH[chapOf[i]]; return `<div class=\"stp s${i}\"><span class=\"cnt\">Step ${i + 1} of ${N}</span><span class=\"chn\">Chapter ${c.k + 1} of ${CH.length}, ${c.title ? code(c.title) : whoOf(c)}</span><span class=\"btns\"><a class=\"${OUTLINE}\" href=\"#\" title=\"Back to the summary, actors and rules\">Overview</a>${i ? A(i - 1, OUTLINE, 'Previous', open(i - 1)) : `<span class=\"${OUTLINE}\" aria-disabled=\"true\">Previous</span>`}${i < N - 1 ? A(i + 1, PRIMARY, 'Next', open(i + 1)) : `<span class=\"${PRIMARY}\" aria-disabled=\"true\">Next</span>`}</span></div>`; }).join('');\nconst legend = menu('read', 'How to read this', `<ul class=\"lgd\">\n<li><i class=\"lg arr1\"></i>A solid arrow is a call or a request from one actor to another.${reqs.length || batch ? ` Its label names the ${andList([reqs.length && 'HTTP request', batch && 'SQL statement'].filter(Boolean))}.` : ''}</li>\n<li><i class=\"lg arr2\"></i>A dashed arrow is the response to a request.</li>\n${TABLES.length ? '<li><i class=\"lg sq\"></i>A square is a write to a store the run made between two steps, with the tables and rows written.</li>' : ''}\n<li><span class=\"${SB} sd\">7</span>A numbered circle is a step. Click it to open that step.</li>\n${DIFF ? `<li><span class=\"${SB} sd df\">7</span><span class=\"${DK} dk\">Differs</span>A red ring or a red tag means the step differs from the base recording.</li>` : ''}\n<li><i class=\"lg hl\"></i>A highlighted row is the current step.</li>\n<li><span class=\"term-demo\">term</span>A dotted underline is a term. Click it to read its definition.</li>\n<li>A step is one pause of the recorded run at a line the page's author chose, in the order the run reached them. The page replays the recording; nothing runs live.</li>\n<li>A recorded expression is a value the page's author asked the recorder to evaluate at that pause, shown in What changed and in SQL, wire and recorded expressions.</li>\n</ul>`);\nconst glossary = GL.length ? menu('glossary', 'Glossary', `<dl class=\"gl\">${[...GL].sort((x, y) => x.term.localeCompare(y.term, 'en', { sensitivity: 'base' })).map((g) => `<dt>${/^[a-z][a-z ]*$/.test(g.term) ? esc(g.term) : `<code>${esc(g.term)}</code>`}</dt><dd>${esc(g.def)}</dd>`).join('')}</dl>`) : '';\nconst sel = (i) => `body:has(#step-${i + 1}:target)`;\nconst masks = Array.from({ length: 1 << SHOW.length }, (_, m) => `.ictl${SHOW.map(([k], b) => (m >> b) & 1 ? `:has(#sh-${k}:checked)` : `:not(:has(#sh-${k}:checked))`).join('')} .pc${[...m.toString(2)].filter((x) => x === '1').length}{display:inline}`);\n// Vercel's published Geist foundation, inlined with its logo images embedded as data URLs so the page opens offline: its tokens, type and controls.\nconst VBG_URL = 'https://vercel.com/geist/vercel-brand.css';\nconst vbgRes = await fetch(VBG_URL, { signal: AbortSignal.timeout(30_000) });\nif (!vbgRes.ok || !/text\\/css/.test(vbgRes.headers.get('content-type') ?? '')) throw new Error(`${VBG_URL}: HTTP ${vbgRes.status} ${vbgRes.headers.get('content-type')}`);\nconst vbgRemote = await vbgRes.text();\nconst vbgAssets = new Map(await Promise.all([...new Set(vbgRemote.match(/https:\\/\\/[^\"')\\s]+/g) ?? [])].map(async (url) => {\n const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });\n const type = res.headers.get('content-type')?.split(';')[0] ?? '';\n if (!res.ok || !type.startsWith('image/')) throw new Error(`${url}: HTTP ${res.status} ${type}`);\n return [url, `data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`];\n})));\nconst vbgCss = vbgRemote.replace(/https:\\/\\/[^\"')\\s]+/g, (url) => vbgAssets.get(url));\nconst mix = (token, pct) => `color-mix(in oklch,var(${token}) ${pct}%,var(--vbg-surface-primary))`;\nconst SEL = mix('--vbg-text-primary', 10), ADD = mix('--vbg-color-success', 14), REM = mix('--vbg-color-error', 12), REM_ROW = mix('--vbg-color-error', 6), CHG = mix('--vbg-color-warning', 16);\nconst CHEVRON = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M14.06 5.5l-.53.53-4.82 4.82a1 1 0 0 1-1.42 0L2.47 6.03 1.94 5.5 3 4.44l.53.53L8 9.44l4.47-4.47.53-.53 1.06 1.06z'/%3E%3C/svg%3E\")`;\nconst rules = [\n 'body:has(.step:target) .start{display:none}body:not(:has(.step:target)) :is(.cols,.stepbar,.help){display:none}',\n ...LEVELS.map(([k]) => `body:has(#lv-${k}:checked) .h-${k}{display:block}body:has(#lv-${k}:checked) .lv-${k}{visibility:visible;z-index:1}`),\n ...SHOW.map(([k]) => `body:not(:has(#sh-${k}:checked)) .p-${k}{display:none}`),\n 'body:not(:has(.show:checked)) .emp{display:block}',\n 'body:has(#sh-changed:checked) .dupv .vfull{display:none}body:has(#sh-changed:checked) .dupv .seewc{display:inline}',\n 'body:has(#lv-steps:checked) .allsteps{display:inline-flex}body:has(#sh-all:checked) .chg .sr{display:grid}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .chg${c.k} .sr{display:grid}`),\n ...masks,\n 'body:has(#sh-inv:checked) .art .badge{display:none}body:not(:has(#sh-insp:checked)) .cols{grid-template-columns:minmax(0,1fr)}body:not(:has(#sh-insp:checked)) .col.insp{display:none}body:not(:has(#sh-insp:checked)) .col.nav{border-right:0}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .cols .chr${c.k}{background:${SEL}}`),\n ...P.map((_, i) => { const S = sel(i); return `${S} .stp.s${i}{display:flex}${S} .art${i}{display:block}${S} .sr${i}{background:${SEL}}${S} .sr${i} .sn,${S} .sd${i}{background:var(--vbg-text-primary);color:var(--vbg-surface-primary);border-color:var(--vbg-text-primary)}${S} .seq .h${i},${sel(i)} .h${i} .lab{background:${SEL}}`; }),\n].join('\\n');\nconst colourCss = [...colours].map(([k, c]) => { const [l] = k.split('|'); return `.${c}{color:${l}}`; }).join('') + '@media (prefers-color-scheme:dark){' + [...colours].map(([k, c]) => `.${c}{color:${k.split('|')[1]}}`).join('') + '}';\nconst fontDir = `${dirname(require.resolve('geist/font'))}/fonts`;\nconst b64 = (f) => readFileSync(`${fontDir}/${f}`).toString('base64');\nconst page = `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>${esc(topic.title)}</title><style>@layer properties, vbg, theme, base, components, utilities;@layer vbg{${vbgCss}}</style><style>/*tailwind*/</style><style>${themeCss}\n\n@font-face{font-family:Geist;src:url(data:font/woff2;base64,${b64('geist-sans/Geist-Variable.woff2')}) format(\"woff2\");font-weight:100 900}\n@font-face{font-family:\"Geist Mono\";src:url(data:font/woff2;base64,${b64('geist-mono/GeistMono-Regular.woff2')}) format(\"woff2\")}\nhtml,body{margin:0;height:100%}body{overflow:hidden;font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact)}\n@layer base{a{color:inherit;text-decoration:none}}\ncode{overflow-wrap:anywhere}.loc,.code,.pre{font-family:\"Geist Mono\",ui-monospace,monospace;font-size:12px}\n.dim{color:var(--vbg-text-secondary);font-weight:var(--vbg-weight-regular)}.loc{color:var(--vbg-text-secondary);overflow-wrap:anywhere}\n.app{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr);grid-template-columns:minmax(0,1fr)}\n.top{border-bottom:1px solid var(--vbg-border-subtle);padding:var(--vbg-space-3) var(--vbg-space-6);background:var(--vbg-surface-primary);z-index:5;position:relative}\n.trow{display:flex;align-items:center;gap:var(--vbg-space-4)}.top h1{flex:1;min-width:0;margin:0;overflow-wrap:anywhere}\n.tools{display:flex;align-items:center;gap:var(--vbg-space-2);flex:none}\n.term,.stq,.clip,.more,.rules{border-top:0;padding-block:0}.term>summary,.stq>summary,.clip>summary,.more>summary{font-weight:inherit}\n.more>summary,.rules>summary{list-style:none;display:flex;white-space:nowrap;align-items:center;gap:var(--vbg-space-1)}.more>summary::-webkit-details-marker,.rules>summary::-webkit-details-marker{display:none}\n.more>summary::before,.rules>summary::before{transform:rotate(-90deg)}.more[open]>summary::before,.rules[open]>summary::before{transform:none}.more>summary::before{width:12px;height:12px}\n.stq>summary>span:first-child::before,.more>summary::before,.rules>summary::before{content:\"\";display:inline-block;flex:none;width:16px;height:16px;background:currentColor;-webkit-mask:${CHEVRON} center/contain no-repeat;mask:${CHEVRON} center/contain no-repeat}\n.pop{width:440px;max-height:70vh;overflow:auto;background:linear-gradient(var(--popover) 30%,transparent) top/100% 40px no-repeat local,linear-gradient(transparent,var(--popover) 70%) bottom/100% 40px no-repeat local,radial-gradient(farthest-side at 50% 0,color-mix(in oklab,var(--foreground) 16%,transparent),transparent) top/100% 12px no-repeat scroll,radial-gradient(farthest-side at 50% 100%,color-mix(in oklab,var(--foreground) 16%,transparent),transparent) bottom/100% 12px no-repeat scroll var(--popover)}.navshow .hint{margin-left:var(--vbg-space-2);font-weight:400;color:var(--vbg-text-secondary)}.pop.pnl{width:400px}@supports (position-anchor:--a){.pop{margin:0;inset:auto;top:calc(anchor(bottom) + var(--vbg-space-2));right:anchor(right)}}\n.lgd{list-style:none;margin:0;padding:0}.lgd li{display:flex;gap:var(--vbg-space-3);align-items:center;padding:var(--vbg-space-1) 0}.lgd li>:first-child{flex:none}\n.lg{display:inline-block;width:32px;height:12px;position:relative;color:var(--vbg-text-secondary)}.lg.arr1{border-bottom:1.5px solid currentColor;height:7px}.lg.arr1::after,.lg.arr2::after{content:\"\";position:absolute;right:-2px;bottom:-5px;border:4px solid transparent;border-left:7px solid currentColor;border-right:0}\n.lg.arr2{border-bottom:1.5px dashed currentColor;height:7px}.lg.sq{width:12px;height:12px;border-radius:2px;background:currentColor;margin:0 10px}.lg.hl{background:${SEL};height:16px}\n.term-demo{text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}\n.gl{margin:0}.gl dt{font-weight:var(--vbg-weight-semibold);margin-top:var(--vbg-space-2)}.gl dt:first-child{margin-top:0}.gl dd{margin:var(--vbg-space-1) 0 0;color:var(--vbg-text-secondary)}\n.stepbar{margin-top:var(--vbg-space-2)}.stp{display:none;align-items:center;gap:var(--vbg-space-4);min-width:0}.cnt{flex:none;font-weight:var(--vbg-weight-semibold);font-variant-numeric:tabular-nums}\n.chn{flex:1;min-width:0;color:var(--vbg-text-secondary);font-size:var(--vbg-type-label)}.chn code{font-size:12px}\n.btns{flex:none;display:flex;gap:var(--vbg-space-2)}\n.help{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.help>span{display:none}.help>span.hn{display:inline}\n.banner{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.banner b{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-medium)}.banner .bl{color:var(--vbg-text-primary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px}\n.start{overflow:auto;min-height:0}.sw{max-width:760px;margin:0 auto;padding:var(--vbg-space-8) var(--vbg-space-6) var(--vbg-space-10)}\n.story{font-size:var(--vbg-type-lede);line-height:var(--vbg-leading-lede)}.prov{font-size:var(--vbg-type-metadata);line-height:var(--vbg-leading-caption);color:var(--vbg-text-secondary);margin:var(--vbg-space-3) 0 0}.prov code{background:none;border:0;padding:0}\n.start h2{margin:var(--vbg-space-8) 0 var(--vbg-space-2)}\n.cast td:first-child{white-space:nowrap;width:1%}.cast td:nth-child(2){width:1%}\n.go1{display:flex;align-items:center;gap:var(--vbg-space-4);margin:var(--vbg-space-6) 0 var(--vbg-space-1)}\n.chs{border-top:1px solid var(--vbg-border-default)}\n.chr{position:relative;display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:0 var(--vbg-space-3);align-items:baseline;padding:var(--vbg-space-3);border-bottom:1px solid var(--vbg-border-subtle)}.chr:hover{background:var(--vbg-surface-secondary)}\n.cnum{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);text-align:right}.ct{font-weight:var(--vbg-weight-medium);overflow-wrap:anywhere}.rng2{display:block;font-weight:var(--vbg-weight-regular);font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}\n.go2{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);white-space:nowrap}.go2::after{content:\"\";position:absolute;inset:0}.chr:hover .go2{color:var(--vbg-text-primary);text-decoration:underline;text-underline-offset:3px}\n.chr .term,.ir .term,.note .term,.story .term,.badge .term,.invl .term{position:relative;z-index:2}\n.chr.out .ct{font-weight:var(--vbg-weight-regular)}.chr.out b{margin-right:var(--vbg-space-2)}\n.term{display:inline-block}.term[open]{display:block}.term>summary{display:inline;list-style:none;cursor:help;text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}.term>summary::-webkit-details-marker{display:none}.term>summary:hover,.term[open]>summary{text-decoration-color:currentColor}\n.term .def{display:block;margin:var(--vbg-space-1) 0 var(--vbg-space-2);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact);font-weight:var(--vbg-weight-regular);color:var(--vbg-text-primary);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}.term .def b{margin-right:var(--vbg-space-2)}\n.rules{margin:var(--vbg-space-6) 0 0;border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius);padding:0 var(--vbg-space-4)}.rules>summary{cursor:pointer;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) 0}.rules ol{margin:0 0 var(--vbg-space-3);padding-left:var(--vbg-space-6)}.rules li{margin:0 0 var(--vbg-space-3)}.irf{display:flex;gap:var(--vbg-space-3);align-items:baseline;margin-top:var(--vbg-space-1)}\n.lv .chs{border-top:0}.lv .rules{margin:var(--vbg-space-3)}\n.cols{display:grid;grid-template-columns:minmax(0,45fr) minmax(0,55fr);min-height:0}\n.col{display:flex;flex-direction:column;min-height:0;min-width:0}.col.nav{border-right:1px solid var(--vbg-border-subtle)}\n.ctl{flex:none;display:flex;flex-wrap:wrap;align-items:center;gap:var(--vbg-space-2) var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-4);border-bottom:1px solid var(--vbg-border-subtle)}\n.clab{font-size:var(--vbg-type-label);font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary)}\n.spec{display:flex;flex-direction:column;gap:var(--vbg-space-1)}\n.navshow{margin-left:auto}.allsteps{display:none}.chg .sr{display:none}.role{font-size:12px;color:var(--vbg-text-secondary)}\n.seewc{display:none}.btag{margin:var(--vbg-space-1) 0 2px;padding:2px var(--vbg-space-2);font-size:12px;line-height:16px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;display:block}\n.stl{font-size:12px;border-top:1px solid var(--vbg-border-default)}.sth,.stq>summary{display:grid;grid-template-columns:minmax(76px,22%) minmax(0,1fr) 92px;gap:0 var(--vbg-space-3);padding:var(--vbg-space-1) var(--vbg-space-2)}.sth{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-secondary);border-bottom:1px solid var(--vbg-border-default)}.sth span:last-child{text-align:right}\n.stq{border-bottom:1px solid var(--vbg-border-subtle)}.stq>summary{list-style:none;cursor:pointer;font-family:\"Geist Mono\",monospace}.stq>summary::-webkit-details-marker{display:none}.stq>summary:hover{background:var(--vbg-surface-secondary)}.stq>summary>span:first-child::before{width:12px;height:12px;margin-right:var(--vbg-space-1);vertical-align:-2px;color:var(--vbg-text-secondary);transform:rotate(-90deg)}.stq[open]>summary>span:first-child::before{transform:none}\n.stq>summary code{background:none;border:0;padding:0;overflow-wrap:normal;word-break:keep-all;white-space:nowrap}.stq .num{text-align:right}.stb{padding:var(--vbg-space-1) var(--vbg-space-2) var(--vbg-space-3) var(--vbg-space-6)}.stb .pre{background:var(--vbg-surface-secondary);border-radius:4px;padding:var(--vbg-space-1) var(--vbg-space-2)}\n.stsplit{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:var(--vbg-space-2) var(--vbg-space-2) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default)}\n.ictl{justify-content:space-between}.pc{display:none}\n.pnl label>span{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);white-space:normal}.pnl b{display:block;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-primary)}\n.scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain}\n/* Each detail level scrolls on its own and stays laid out while hidden, so switching levels keeps each one's scroll position. */\n.nav .scroll{position:relative;overflow:hidden}.lv{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;visibility:hidden}\n.art,.emp{display:none}\n.chh{display:block;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) var(--vbg-space-3) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default);cursor:pointer;overflow-wrap:anywhere}.chh:hover{text-decoration:underline;text-underline-offset:3px}.chh .dim{font-size:12px;margin-left:var(--vbg-space-1)}.rng{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);margin-right:var(--vbg-space-2)}\n.steps{font-size:var(--vbg-type-label)}.sh,.sr{display:grid;grid-template-columns:36px minmax(min-content,34%) minmax(0,1fr);gap:0 var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-3)}.sh{font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-primary);border-bottom:1px solid var(--vbg-border-default);position:sticky;top:0;z-index:2}\n.sr{border-bottom:1px solid var(--vbg-border-subtle);cursor:pointer;scroll-margin-top:120px}.sr:hover{background:var(--vbg-surface-secondary)}.sr>span{min-width:0;overflow-wrap:anywhere}.sr>span.wh{overflow-wrap:normal}.sr .wh b{font-weight:var(--vbg-weight-medium)}.sr .wh .loc{display:block;margin-top:2px;overflow-wrap:normal;word-break:normal}\n.sn{align-self:start;min-width:28px;font-variant-numeric:tabular-nums}.sr:hover .sn{border-color:var(--vbg-text-primary)}\n.seq{font-size:12px}.seq .row{display:flex;cursor:pointer}.seq .row:hover{background:var(--vbg-surface-secondary)}.seq .gut{display:flex;flex-wrap:wrap;gap:var(--vbg-space-1);align-content:flex-start;justify-content:flex-end;flex:0 0 60px;padding:var(--vbg-space-1) var(--vbg-space-2) 0 0}\n.seq .area{position:relative;flex:1;min-width:0;display:flex;flex-direction:column;padding:2px 0;min-height:24px;margin-right:var(--vbg-space-3)}.seq a.area{color:inherit}\n.sd{min-width:24px;padding-inline:var(--vbg-space-1);font-variant-numeric:tabular-nums;background:var(--background);cursor:pointer}.sd:hover{border-color:var(--vbg-text-primary)}\n.ll{position:absolute;top:0;bottom:0;border-left:1px dashed var(--vbg-border-default)}.heads{position:sticky;top:0;z-index:3;background:var(--vbg-surface-primary);cursor:default!important;border-bottom:1px solid var(--vbg-border-subtle)}.heads:hover{background:var(--vbg-surface-primary)!important}.heads .area{min-height:40px}\n.seq .heads .area{flex-direction:row;align-items:flex-start}.lh{flex:1 1 0;min-width:0;text-align:center;padding:var(--vbg-space-1) var(--vbg-space-1) var(--vbg-space-2)}.lh b{display:block;font-size:12px;line-height:16px;font-weight:var(--vbg-weight-semibold);overflow-wrap:anywhere}.lh code{font-size:12px}.lh span{display:block;font-size:12px;line-height:16px;color:var(--vbg-text-secondary)}\n.lab{position:relative;background:var(--vbg-surface-primary);overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace;font-size:12px;padding:0 var(--vbg-space-1);border-radius:4px}.row:hover .lab,.lab code{background:none;border:0;padding:0;font-size:12px;font-weight:var(--vbg-weight-semibold)}\n.alab{display:block;padding:0 var(--vbg-space-2)}\n.arr{position:relative;display:block;height:0;border-bottom:1.5px solid var(--vbg-text-secondary);margin:1px 0 var(--vbg-space-1);color:var(--vbg-text-secondary)}.arr.ret{border-bottom-style:dashed}\n.arr::after{content:\"\";position:absolute;bottom:-5px;border:4px solid transparent}.arr.ltr::after{right:-2px;border-left:7px solid currentColor;border-right:0}.arr.rtl::after{left:-2px;border-right:7px solid currentColor;border-left:0}\n.self{display:block;border-left:2px solid var(--vbg-border-default);padding-left:var(--vbg-space-2);color:var(--vbg-text-secondary)}.self .lab{font-family:Geist,sans-serif}.self .lab code{font-family:\"Geist Mono\",monospace;font-weight:var(--vbg-weight-medium)}\n.mk{display:block;position:relative;padding-left:var(--vbg-space-3)}.mk::before{content:\"\";position:absolute;left:-5px;top:4px;width:10px;height:10px;border-radius:2px;background:var(--vbg-text-secondary)}.mk.rt{padding-left:0;padding-right:var(--vbg-space-3);text-align:right}.mk.rt::before{left:auto;right:-5px}\n.dk{margin-left:var(--vbg-space-2);vertical-align:1px}\n.what .dk{display:table;margin:var(--vbg-space-1) 0 0}.sd.df{border-color:var(--vbg-color-error);box-shadow:0 0 0 1px var(--vbg-color-error);color:var(--vbg-color-error)}.seq .row.rdf{background:${REM_ROW}}.lgd .dk{margin:0}\n.insp .scroll{padding:0 var(--vbg-space-6) var(--vbg-space-10)}\n.emp{color:var(--vbg-text-secondary);margin:var(--vbg-space-6) 0}\n.pn{padding:var(--vbg-space-3) 0;border-bottom:1px solid var(--vbg-border-subtle)}.pn h3{margin:0 0 var(--vbg-space-2)}.sub{font-size:12px;color:var(--vbg-text-secondary);margin:var(--vbg-space-2) 0 var(--vbg-space-1)}\n.note{font-size:var(--vbg-type-body);line-height:var(--vbg-leading-body)}\n.badge{margin:var(--vbg-space-2) 0 var(--vbg-space-1);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-label);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}\n.dfl{margin:var(--vbg-space-3) 0 0;padding:var(--vbg-space-2) var(--vbg-space-3);border:1px solid var(--vbg-color-error);border-radius:var(--vbg-radius-small)}.dfl>.dk{margin:0 0 var(--vbg-space-2)}.dd2{font:12px/18px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);overflow-wrap:anywhere;margin-top:var(--vbg-space-1)}\n.srcdiff{font:12px/18px \"Geist Mono\",monospace;margin-top:var(--vbg-space-2)}.srcdiff .dim{margin-top:2px}\n.ln.was{background:${REM}}.ln.was del{text-decoration:line-through;text-decoration-color:var(--vbg-color-error);color:var(--vbg-text-secondary)}.ln.now{background:${ADD}}.ln.was .no,.ln.now .no{font-family:Geist,sans-serif}\n.sm{font-size:12px}.sm td{overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace}.sm td code{background:none;border:0;padding:0}.sm td:first-child code{white-space:nowrap}.kvt th{font:var(--vbg-weight-medium) 12px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);width:1%;overflow-wrap:break-word;word-break:normal}\n.tagc{font-family:Geist,sans-serif;font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;padding:0 var(--vbg-space-1)}\n.stmts tr.split td{border-top:2px solid var(--vbg-border-strong)}.num{text-align:right}.stmts td:nth-child(-n+2){white-space:nowrap}.stmts .sql{min-width:18ch}\n.pre{white-space:pre-wrap;overflow-wrap:anywhere}.val{overflow-wrap:anywhere}\n.rowl{margin:0;padding-left:var(--vbg-space-5);font-size:12px}.rowl li{margin:2px 0}\n.more summary,.clip summary{cursor:pointer;color:var(--vbg-text-secondary);font-size:12px;padding:var(--vbg-space-1) 0}.more summary:hover,.clip summary:hover{color:var(--vbg-text-primary)}.clip{display:inline}.clip[open] summary{display:none}.full{white-space:pre-wrap}\n.go{display:inline-block;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px;white-space:nowrap}.go:hover{color:var(--vbg-text-primary);text-decoration-color:currentColor}\n.invl{margin:0;padding-left:var(--vbg-space-5)}.invl li{margin:0 0 var(--vbg-space-2)}\n.file{margin:0 0 var(--vbg-space-2);font-size:var(--vbg-type-label);overflow-wrap:anywhere}.file code{background:none;border:0;padding:0}.code{border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small);padding:var(--vbg-space-1) 0;overflow:hidden}\n.ln{display:flex;white-space:pre-wrap;overflow-wrap:anywhere;line-height:18px}.src{min-width:0;flex:1;padding-right:var(--vbg-space-2)}.ln.cur{background:${SEL}}.no{width:40px;flex:none;text-align:right;padding-right:var(--vbg-space-3);color:var(--vbg-text-secondary)}.ln.cur .no{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-semibold)}\n.kv{border-collapse:collapse;font:12px/18px \"Geist Mono\",monospace;width:100%}.kv th{text-align:left;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:1px var(--vbg-space-3) 1px 0;vertical-align:baseline;overflow-wrap:break-word;word-break:normal;border:0}.kv td{overflow-wrap:anywhere;padding:1px 0;border:0}\n.iv{margin-left:var(--vbg-space-3);padding:0 var(--vbg-space-1);border-radius:4px;background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);font-size:12px;color:var(--vbg-text-secondary);white-space:normal;overflow-wrap:anywhere;display:inline-block;max-width:100%;vertical-align:top}.iv.new,.iv.chg{background:${CHG}}\n.v-s,.v-n,.v-b{color:var(--vbg-text-primary)}.v-u,.v-o,.v-f{color:var(--vbg-text-secondary)}\n.ft{display:flex;align-items:center;gap:var(--vbg-space-3);margin-top:var(--vbg-space-10);color:var(--vbg-text-secondary);font-size:var(--vbg-type-metadata)}\n@media (max-width:900px){body{overflow:auto}.app{height:auto;display:block}.top{position:sticky;top:0;padding:var(--vbg-space-3) var(--vbg-space-4)}.trow .vbg-identity{display:none}.trow,.tools{flex-wrap:wrap}.trow h1{flex:1 1 14rem}.spec{max-width:100%}.ctl .ui-tabs-list{flex-wrap:wrap;height:auto;max-width:100%}.tools{flex:0 1 auto;max-width:100%}.cols{grid-template-columns:minmax(0,1fr)}.col.nav{border-right:0;border-bottom:1px solid var(--vbg-border-subtle)}.col.nav .scroll{flex:none;height:60vh}.insp .scroll{overflow:visible}.sr{scroll-margin-top:160px}.stp{flex-wrap:wrap}.btns{flex:0 1 auto;flex-wrap:wrap;max-width:100%}}\n@media (max-width:480px){.top{position:relative}}\n${colourCss}\n${rules}\n</style></head><body class=\"vbg-report\"><div class=\"app\">\n<header class=\"top\"><div class=\"trow\"><span class=\"vbg-identity\"><span class=\"vbg-wordmark\" role=\"img\" aria-label=\"Vercel\"></span></span><h1 class=\"vbg-heading-16\">${code(topic.title)}</h1><div class=\"tools\">${legend}${glossary}</div></div>${banner}<div class=\"stepbar\">${bar}</div><p class=\"help\">${LEVELS.map(([k, , , h]) => `<span class=\"h-${k}\">${h}</span>`).join('')} <span class=\"hn\">Use Previous and Next to step through.</span></p></header>\n${startHtml}\n<div class=\"cols\">\n<section class=\"col nav\" aria-label=\"Navigator\"><div class=\"ctl\"><span class=\"clab\">Detail</span><div class=\"spec\"><div class=\"${tabsList}\" role=\"radiogroup\" aria-label=\"Detail\">${LEVELS.map(([k, n]) => `<span class=\"contents\"><input type=\"radio\" class=\"peer sr-only\" name=\"level\" id=\"lv-${k}\"${k === lvOn ? ' checked' : ''}><label class=\"${tabsTrigger}\" for=\"lv-${k}\">${n}</label></span>`).join('')}</div></div><label class=\"${LABEL} allsteps\"><input type=\"checkbox\" class=\"${checkbox}\" id=\"sh-all\">Show steps from every chapter</label><label class=\"${LABEL} navshow\"><input type=\"checkbox\" class=\"${checkbox}\" id=\"sh-insp\"${inspOn ? ' checked' : ''}>Show inspector<span class=\"hint\">Turn off to widen this pane</span></label></div>\n<div class=\"scroll\">${LEVELS.map(([k, , h]) => `<div class=\"lv lv-${k}\">${h}</div>`).join('')}</div></section>\n<section class=\"col insp\" aria-label=\"Inspector\"><div class=\"ctl ictl\"><span class=\"clab\">Inspector, the current step in detail</span>${menu('panels', `${SHOW.map((_, k) => `<span class=\"pc pc${k}\">Panels, ${k} of ${SHOW.length} shown</span>`).join('')}<span class=\"pc pc${SHOW.length}\">Panels, ${SHOW.length} of ${SHOW.length} shown</span>`, SHOW.map(([k, n, d]) => `<label class=\"${menuCheckboxItem}\"><input type=\"checkbox\" class=\"${cn(checkbox, 'show mt-0.5')}\" id=\"sh-${k}\"${showOn.has(k) ? ' checked' : ''}><span><b>${n}</b>${d}</span></label>`).join(''), `${menuContent} pnl`)}</div>\n<div class=\"scroll\">${P.map((_, i) => article(i)).join('')}</div></section>\n</div></div></body></html>`;\nconst html = page.replace('/*tailwind*/', await tailwindCss(page));\nwriteFileSync(out, html);\nconsole.log(out, Buffer.byteLength(html));\n","check.mjs":"// Fails (exit 1) when a note or the topic's authored text says something the recording does not show.\n// Usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]\n// --base: for the head page of a two-recording pair. Authored values that hold in the base but not here are reported as\n// \"written for the base recording\" (the page tags them) instead of failing.\nimport { readFileSync } from 'node:fs';\nimport { readJson, loadTopic, rootOf, relTo, pointPath, urlOf, stackOf, hitsOf, stepAt, leavesOf, nameMatches, CITE, sameFile, WINDOW, invStep, chaptersOf, authoredOf, sourceOf } from './lib.mjs';\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile] = argv;\nif (!notesFile) { console.error('usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, root = rootOf(topic, rec), rel = relTo(root, topic.dir), hits = hitsOf(P);\nconst fail = [], warn = [];\nconst points = new Map(topic.points.map((p) => [p.id, p]));\nconst hitPoints = new Set(P.map((p) => p.point));\n// A breakpoint position counts as recorded when its point was hit; so does the line V8 actually paused on.\nconst bpLines = new Set(topic.points.filter((p) => hitPoints.has(p.id)).map((p) => `${rel(urlOf(pointPath(topic, root, p)))}:${p.line}`));\nfor (const p of P) bpLines.add(`${rel(p.frames[0].url)}:${p.frames[0].line}`);\nconst covered = new Set();\nlet refs = 0, values = 0;\n// Register: no em dash and no ≠ in anything the page prints from the author.\nconst register = (s, at) => { for (const [ch, name] of [['\\u2014', 'an em dash'], ['\\u2260', 'the \\u2260 glyph']]) if (String(s).includes(ch)) fail.push(`${at}: uses ${name}; write a sentence instead`); };\nnotes.forEach((n, k) => {\n const at = `note ${k} (${n.point}#${n.hit ?? 0})`;\n register(n.text, at);\n if (!points.has(n.point)) return fail.push(`${at}: point ${n.point} is not in topic.points`);\n const i = stepAt(P, hits, n.point, n.hit ?? 0);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${n.hit ?? 0} at ${n.point}`);\n if (covered.has(i)) fail.push(`${at}: step ${i + 1} already has a note`);\n covered.add(i);\n const frames = new Set(stackOf(P[i]).map((f) => `${rel(f.url)}:${f.line}`));\n for (const r of n.refs ?? []) {\n refs++;\n const key = `${r.file}:${r.line}`;\n if (!frames.has(key) && !bpLines.has(key)) fail.push(`${at}: ref ${key} is neither a frame of step ${i + 1} nor a breakpoint that was hit`);\n }\n for (const m of String(n.text).matchAll(CITE)) if (!(n.refs ?? []).some((r) => sameFile(r.file, m[1]) && r.line === +m[2])) fail.push(`${at}: text cites ${m[1]}:${m[2]}, which is not in its refs`);\n const leaves = leavesOf(P[i]);\n for (const v of n.values ?? []) {\n values++;\n if (!leaves.some((l) => nameMatches(l.path, v.name) && l.str === String(v.value))) {\n const near = leaves.filter((l) => nameMatches(l.path, v.name)).slice(0, 3).map((l) => `${l.path}=${l.str.slice(0, 60)}`);\n fail.push(`${at}: value ${v.name} = ${JSON.stringify(v.value)} is not in step ${i + 1}'s scopes, watch or rows${near.length ? ` (it has ${near.join('; ')})` : ''}`);\n }\n }\n});\nP.forEach((p, i) => { if (!covered.has(i)) fail.push(`step ${i + 1} (${p.point}#${hits[i]}, ${rel(p.frames[0].url)}:${p.frames[0].line}) has no note`); });\n// Invariants: a rule, a cited line that exists, and a linked step (point and hit) whose source window shows that line.\n(topic.invariants ?? []).forEach((v, k) => {\n const at = `invariant ${k}`;\n if (!v.rule) fail.push(`${at}: no rule`);\n register(v.rule ?? '', at);\n if (/^\\s*[A-Z]\\w*:/.test(v.rule ?? '')) fail.push(`${at}: opens with a label (\"${v.rule.split(':')[0]}:\"); state the rule the code keeps, or the rule it breaks`);\n if (!v.file || !v.line) return fail.push(`${at}: no file or line`);\n const src = sourceOf(rec, urlOf(`${root}/${v.file}`));\n if (src == null) fail.push(`${at}: ${v.file} is not in the recording's sources, or does not exist under ${root}`);\n else { const lines = src.split('\\n'); if (v.line > lines.length || !lines[v.line - 1].trim()) fail.push(`${at}: ${v.file}:${v.line} is past the end or blank`); }\n if (!v.point || !points.has(v.point)) return fail.push(`${at}: point ${v.point} is not in topic.points`);\n const i = invStep(P, hits, v);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${v.hit ?? 0} at ${v.point}`);\n const top = P[i].frames[0], file = rel(top.url);\n if (file !== v.file || Math.abs(v.line - top.line) > WINDOW) fail.push(`${at}: cites ${v.file}:${v.line}, but its step ${i + 1} shows ${file}:${Math.max(1, top.line - WINDOW)}-${top.line + WINDOW}; link a step whose source window holds the line, or add a point there`);\n});\nconst CH = chaptersOf(topic, P);\nif (!CH) fail.push('chapters: a chapter\\'s point is not hit at or after the previous chapter\\'s start');\n(topic.chapters ?? []).forEach((c, k) => { register(c.title, `chapter ${k}`); if (!hitPoints.has(c.from)) fail.push(`chapter ${k} \"${c.title}\": point ${c.from} was never hit`); });\n// Authored fields the viewer shows: story, outcome, actors[].about and role, and a glossary that covers every code name in chapter titles and invariants.\nconst fnNames = new Set(P.map((p) => p.frames[0].fn));\nfor (const k of ['story', 'outcome']) { if (typeof topic[k] !== 'string' || !topic[k].trim()) fail.push(`topic: no ${k}`); else register(topic[k], k); }\n(topic.actors ?? []).forEach((a) => { for (const k of ['about', 'role']) if (typeof a[k] !== 'string' || !a[k].trim()) fail.push(`actor ${a.name}: no ${k}`); });\nconst terms = new Set();\nif (!Array.isArray(topic.glossary) || !topic.glossary.length) fail.push('topic: no glossary');\nelse topic.glossary.forEach((g, k) => { if (!g?.term || !g?.def) fail.push(`glossary ${k}: needs term and def`); else if (terms.has(g.term)) fail.push(`glossary: ${g.term} is listed twice`); else { terms.add(g.term); register(g.def, `glossary ${g.term}`); } });\nconst needTerms = (s, at) => { for (const m of String(s).matchAll(/`([^`]+)`/g)) if (!terms.has(m[1]) && !fnNames.has(m[1])) fail.push(`${at}: \\`${m[1]}\\` has no glossary entry and is not a step's function name`); };\n(topic.chapters ?? []).forEach((c, k) => needTerms(c.title, `chapter ${k} \"${c.title}\"`));\n(topic.invariants ?? []).forEach((v, k) => needTerms(v.rule, `invariant ${k}`));\n// Authored values: story, outcome, chapter titles and invariants that state run values must match this recording.\nconst B = baseFile ? readJson(baseFile).pauses : null;\nconst baseAuth = B && authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B));\nlet authored = 0;\nfor (const [k, a] of (CH ? authoredOf(topic, P, hits, CH) : []).entries()) {\n authored += a.values.length;\n for (const v of a.missing) {\n const msg = `${a.at}: says ${v.name} = ${JSON.stringify(v.value)}, which no step it speaks for has`;\n if (baseAuth && !baseAuth[k].missing.includes(v)) warn.push(`${msg}; it holds in the base recording, so the page tags it \"Written for the base recording\"`); else fail.push(msg);\n }\n}\nif (!rec.provenance) fail.push('recording: no provenance; record it again with record.mjs');\nelse if (rec.provenance.pauses !== P.length) fail.push(`provenance: pauses ${rec.provenance.pauses} but the recording has ${P.length}`);\nif (warn.length) console.log(warn.join('\\n'));\nif (fail.length) { console.error(fail.join('\\n')); console.error(`check failed: ${fail.length} problems`); process.exit(1); }\nconsole.log(`check ok: ${P.length} steps, ${notes.length} notes, ${refs} refs, ${values} values, ${(topic.invariants ?? []).length} invariants, ${authored} authored values, ${terms.size} glossary terms${warn.length ? `, ${warn.length} written for the base` : ''}`);\n","carry-notes.mjs":"// Carries notes written against one recording to another recording of the same topic (a re-record, or a head run for a two-recording page):\n// each value takes the new run's value at the same path, and the note text takes the same substitution wherever the value is in backticks.\n// Usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json\nimport { readJson, leavesOf, hitsOf, nameMatches } from './lib.mjs';\nconst [oldFile, notesFile, newFile] = process.argv.slice(2);\nif (!newFile) { console.error('usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json'); process.exit(2); }\nconst B = readJson(oldFile), H = readJson(newFile), notes = readJson(notesFile);\nconst at = (R, n) => { const h = hitsOf(R.pauses); return R.pauses.find((p, i) => p.point === n.point && h[i] === (n.hit ?? 0)); };\nconst out = notes.map((n) => {\n const pb = at(B, n), ph = at(H, n);\n if (!pb || !ph) { console.error(`note ${n.point}#${n.hit ?? 0}: step missing in ${pb ? 'new' : 'old'} recording, kept as is`); return n; }\n const lb = leavesOf(pb), lh = leavesOf(ph);\n let text = n.text;\n const values = (n.values ?? []).map((v) => {\n const b = lb.find((l) => nameMatches(l.path, v.name) && l.str === String(v.value)), h = b && lh.find((l) => l.path === b.path);\n if (h && h.str !== String(v.value)) { text = text.split('`' + v.value + '`').join('`' + h.str + '`'); return { ...v, value: h.str }; }\n return v;\n });\n return { ...n, text, values };\n});\nconsole.log(JSON.stringify(out, null, 1));\n","shot.mjs":"// Screenshots a rendered page with JavaScript off. Usage: node shot.mjs page.html out.png [width=1440] [light|dark] [#step-N] [level ch|seq|steps] [full]\nimport { chromium } from 'playwright';\nconst [page, out, w = '1440', scheme = 'light', hash = '', level = '', full = ''] = process.argv.slice(2);\nconst b = await chromium.launch();\nconst ctx = await b.newContext({ javaScriptEnabled: false, viewport: { width: +w, height: 1000 }, colorScheme: scheme });\nconst p = await ctx.newPage();\nawait p.goto('file://' + new URL(page, 'file://' + process.cwd() + '/').pathname + hash);\nif (level) await p.click(`label[for=lv-${level}]`);\nawait p.evaluate(() => Promise.all(document.getAnimations().map((a) => a.finished)));\nif (hash && level) await p.evaluate((h) => document.querySelector(h).scrollIntoView(), hash);\nawait p.screenshot({ path: out, fullPage: full === 'full' });\nconsole.log(out, 'scrollWidth', await p.evaluate(() => document.documentElement.scrollWidth));\nawait b.close();\n","controls.mjs":"// Checks a rendered page's controls by keyboard and by click, in Chromium with JavaScript off, and the narrow layout in Chromium and WebKit. Usage: node controls.mjs page.html\n// Prints one line per check and exits 1 when any fails.\nimport { chromium, webkit } from 'playwright';\nconst [page] = process.argv.slice(2);\nconst url = 'file://' + new URL(page, 'file://' + process.cwd() + '/').pathname;\nconst b = await chromium.launch();\nconst p = await (await b.newContext({ javaScriptEnabled: false, viewport: { width: 1040, height: 1000 } })).newPage();\nconst results = [];\nconst check = (name, pass, detail) => results.push({ name, pass, detail });\nconst state = (fn, arg) => p.evaluate(fn, arg);\n\n// A control has a focus ring when its outline, box-shadow or border differs focused from unfocused; a static shadow alone is not one.\nconst look = (e) => { const s = e.classList.contains('sr-only') ? e.nextElementSibling : e, cs = getComputedStyle(s); return [cs.outlineStyle === 'none' ? 'none' : `${cs.outlineStyle} ${cs.outlineWidth} ${cs.outlineColor}`, cs.boxShadow, cs.borderColor].join('|'); };\nconst settle = () => p.waitForTimeout(400);\nconst unfocusedLooks = async () => { await state(() => { const a = document.activeElement; a?.blur(); }); await settle(); return state(() => window.__els.map((e) => window.__look(e))); };\n\nawait p.goto(`${url}#step-1`);\nawait p.evaluate(`window.__look = ${look}`);\nawait p.click('label[for=lv-steps]');\nawait p.mouse.click(1, 990);\nawait state(() => { window.__els = []; });\nconst tabbed = [];\nfor (let k = 0; k < 1000; k++) {\n await p.keyboard.press('Tab');\n await settle();\n const r = await state(() => {\n const e = document.activeElement;\n if (!e || e === document.body) return null;\n const shown = e.classList.contains('sr-only') ? e.nextElementSibling : e;\n const prev = window.__prev, first = (window.__first ??= e);\n window.__prev = e;\n if (prev && e === first) return { wrapped: true };\n window.__els.push(e);\n return { control: `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''} ${(shown.textContent || '').trim().slice(0, 30)}`, focused: window.__look(e), inOrder: !prev || !!(prev.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING) };\n });\n if (!r || r.wrapped) break;\n tabbed.push(r);\n}\nconst plain = await unfocusedLooks();\nconst noRing = tabbed.filter((r, i) => r.focused === plain[i]).map((r) => r.control), outOfOrder = tabbed.filter((r) => !r.inOrder).map((r) => r.control);\ncheck(`Tab reaches ${tabbed.length} controls in reading order, each with a focus ring`, tabbed.length > 0 && !noRing.length && !outOfOrder.length, { noRing, outOfOrder });\n\nawait p.focus('#lv-ch');\nawait p.keyboard.press('Space');\nawait p.keyboard.press('ArrowRight');\nconst seq = await state(() => document.querySelector('#lv-seq').checked);\nawait p.keyboard.press('ArrowRight');\nconst steps = await state(() => document.querySelector('#lv-steps').checked);\ncheck('ArrowRight moves from Chapters to Sequence diagram to Steps', seq && steps, { seq, steps });\nconst boxes = await state(() => [...document.querySelectorAll('input[type=checkbox]')].filter((x) => x.offsetParent || x.closest('[popover]')).map((x) => x.id));\nconst unToggled = [];\nfor (const id of boxes) {\n const inMenu = await state((id) => !!document.getElementById(id).closest('[popover]'), id);\n if (inMenu) await state(() => document.getElementById('pp-panels').showPopover());\n const before = await state((id) => document.getElementById(id).checked, id);\n await p.focus(`#${id}`);\n await p.keyboard.press('Space');\n if ((await state((id) => document.getElementById(id).checked, id)) === before) unToggled.push(id);\n await p.keyboard.press('Space');\n if (inMenu) await p.keyboard.press('Escape');\n}\ncheck(`Space toggles each of ${boxes.length} checkboxes`, boxes.length > 0 && !unToggled.length, { unToggled });\n\nconst menus = await state(() => [...document.querySelectorAll('[popovertarget]')].map((x) => x.getAttribute('popovertarget')));\nconst badMenus = [];\nfor (const id of menus) {\n await p.focus(`[popovertarget=${id}]`);\n await p.keyboard.press('Enter');\n const opened = await state((id) => document.getElementById(id).matches(':popover-open'), id);\n await p.keyboard.press('Tab');\n await settle();\n const inside = await state((id) => { const e = document.activeElement; if (!document.getElementById(id).contains(e)) return null; window.__els = [e]; return { control: `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}`, focused: window.__look(e) }; }, id);\n await p.keyboard.press('Shift+Tab');\n await settle();\n const ring = !inside || inside.focused !== (await state(() => window.__look(window.__els[0])));\n await p.keyboard.press('Escape');\n const closed = await state((id) => !document.getElementById(id).matches(':popover-open'), id);\n const back = await state((id) => document.activeElement?.getAttribute('popovertarget') === id, id);\n if (!(opened && closed && back && ring)) badMenus.push({ id, opened, closed, focusBack: back, firstTabInside: inside?.control, ring });\n}\ncheck(`Enter opens each of ${menus.length} menus, Tab inside shows a focus ring, and Escape closes it with focus back on its button`, menus.length > 0 && !badMenus.length, { badMenus });\n\nawait p.goto(`${url}#step-1`);\nawait p.focus('.stp.s0 .btns > :last-child');\nawait p.keyboard.press('Enter');\nconst hash = await state(() => location.hash);\ncheck('Enter on Next at step 1 opens step 2', hash === '#step-2', { hash });\n\nawait p.click('label[for=lv-seq]');\nconst lv = await state(() => getComputedStyle(document.querySelector('.lv-seq')).visibility);\nawait p.click('[popovertarget=pp-panels]');\nconst panelsOpen = await state(() => document.getElementById('pp-panels').matches(':popover-open'));\nconst off = await state(() => [...document.querySelectorAll('#pp-panels input.show')].find((x) => !x.checked)?.id);\nconst shownLabel = () => state(() => [...document.querySelectorAll('[popovertarget=pp-panels] .pc')].find((x) => getComputedStyle(x).display !== 'none')?.textContent);\nconst label0 = await shownLabel();\nconst panel = (id) => state((k) => getComputedStyle(document.querySelector(`.art1 .p-${k}`)).display, id.replace(/^sh-/, ''));\nconst before = off ? await panel(off) : null;\nif (off) await p.click(`label:has(#${off})`);\nconst after = off ? await panel(off) : null, label1 = await shownLabel();\nawait p.keyboard.press('Escape');\nawait p.click('label:has(#sh-insp)');\nconst insp = await state(() => getComputedStyle(document.querySelector('.col.insp')).display);\nawait p.click('label:has(#sh-insp)');\ncheck('A click shows the level, opens Panels, shows a panel and updates its count, and hides the inspector',\n lv === 'visible' && panelsOpen && (!off || (before === 'none' && after !== 'none' && label0 !== label1)) && insp === 'none',\n { sequenceVisibility: lv, panelsOpen, panel: off && `${off} ${before} to ${after}`, label: `${label0} to ${label1}`, inspector: insp });\n\nawait b.close();\n\n// The Hub Mac app's doc pane is WebKit and opens at 280 px, where the navigator stacks above the inspector.\nconst empty = [], cramped = [];\nfor (const engine of [chromium, webkit]) {\n const eb = await engine.launch();\n for (const width of [280, 600, 900]) {\n const ep = await (await eb.newContext({ javaScriptEnabled: false, viewport: { width, height: 800 } })).newPage();\n await ep.goto(`${url}#step-1`);\n const fit = await ep.evaluate(() => { const t = document.querySelector('.top'); return { header: t.getBoundingClientRect().height, sticky: getComputedStyle(t).position === 'sticky', scrollWidth: document.documentElement.scrollWidth }; });\n if ((fit.sticky && fit.header > 400) || fit.scrollWidth > width) cramped.push(`${engine.name()} ${width}px header ${fit.header}px, page ${fit.scrollWidth}px wide`);\n for (const k of ['ch', 'seq', 'steps']) {\n await ep.evaluate((k) => { document.getElementById(`lv-${k}`).checked = true; }, k);\n const h = await ep.evaluate((k) => document.querySelector(`.lv-${k}`).getBoundingClientRect().height, k);\n if (!(h > 0)) empty.push(`${engine.name()} ${width}px ${k} ${h}px`);\n }\n }\n await eb.close();\n}\ncheck('At 280, 600 and 900 px in Chromium and WebKit, each Detail level shows a list taller than 0 px', !empty.length, { empty });\ncheck('At 280, 600 and 900 px in Chromium and WebKit, a sticky header is at most half the 800 px viewport and the page does not scroll sideways', !cramped.length, { cramped });\n\nfor (const r of results) console.log(`${r.pass ? 'ok ' : 'FAIL'} ${r.name}${r.pass ? '' : ' ' + JSON.stringify(r.detail)}`);\nprocess.exit(results.every((r) => r.pass) ? 0 : 1);\n","lib.mjs":"// Shared by record.mjs, render.mjs and check.mjs: how a topic's paths resolve and how a recording's frames, steps and values are named.\nimport { existsSync, readFileSync } from 'node:fs';\nimport { dirname, isAbsolute, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nexport const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));\n// topic.json with `root`, `scenario`, `stores` and `import` resolved against the topic file's directory, so a topic can sit next to its scenario.\nexport const loadTopic = (file, rootOverride) => {\n const t = readJson(file), dir = dirname(resolve(file)), at = (p) => p == null ? p : isAbsolute(p) ? p : resolve(dir, p);\n return { ...t, dir, root: (rootOverride ? resolve(rootOverride) : at(t.root)).replace(/\\/$/, ''), scenario: at(t.scenario), stores: at(t.stores), import: at(t.import) };\n};\n// Source text of a file the recording paused in or an invariant cites, from the copy the recorder stored, so check and render need no checkout.\n// A recording made before sources were stored falls back to the file on disk.\nexport const sourceOf = (rec, url) => { if (rec.sources) return rec.sources[url] ?? null; const f = url.replace(/^file:\\/\\//, ''); return existsSync(f) ? readFileSync(f, 'utf8') : null; };\nexport const rootOf = (topic, rec) => (rec?.root ?? topic.root).replace(/\\/$/, '');\n// A point's file is relative to topic.root; one that starts with ./ is relative to the topic's directory (fakes and stubs that ship with the topic).\nexport const pointPath = (topic, root, p) => isAbsolute(p.file) ? p.file : p.file.startsWith('./') ? resolve(topic.dir, p.file) : resolve(root, p.file);\n// How a frame url is shown: relative to the recorded root when under it, else relative to the topic's directory when under that, else the path.\nexport const relTo = (root, dir) => (url) => {\n const u = url || '', r = `file://${root}/`, d = dir && `file://${dir}/`;\n return u.startsWith(r) ? u.slice(r.length) : d && u.startsWith(d) ? u.slice(d.length) : u.replace(/^file:\\/\\//, '');\n};\nexport const urlOf = (path) => pathToFileURL(path).href;\nexport const stackOf = (p) => { const all = p.frames.map((f) => ({ ...f, async: false })); for (const a of p.asyncFrames) a.frames.forEach((f, i) => all.push({ ...f, async: i === 0 })); return all; };\n// Step k is the hit-th (0-based) pause at its point.\nexport const hitsOf = (P) => { const n = new Map(); return P.map((p) => { const h = n.get(p.point) ?? 0; n.set(p.point, h + 1); return h; }); };\nexport const stepAt = (P, hits, point, hit = 0) => P.findIndex((p, i) => p.point === point && hits[i] === hit);\n// A recorded value (the recorder's {t, v, n} preview) as plain JSON.\nexport const plain = (x) => {\n if (!x) return undefined;\n if (x.t === 'undefined') return undefined;\n if (x.t === 'null') return null;\n if (x.v === null || typeof x.v !== 'object') return x.t === 'number' && typeof x.v === 'string' && !isNaN(+x.v) ? +x.v : x.v;\n if (x.t === 'array' || x.t === 'set') return Object.values(x.v).map(plain);\n if (x.t === 'map') return Object.values(x.v).map(plain);\n return Object.fromEntries(Object.entries(x.v).map(([k, y]) => [k, plain(y)]));\n};\nconst str = (v) => v === undefined ? 'undefined' : typeof v === 'string' ? v : JSON.stringify(v);\n// Every value a step shows, at every depth: scope variables (name.path), watch values (watch.path for the first watch, watch#k.path for watch k,\n// and the watch expression itself, e.g. queue.length) and row cells (table.column).\nexport const leavesOf = (p) => {\n const out = [];\n const walk = (v, path, src) => {\n out.push({ path, str: str(v), val: v, src });\n if (v && typeof v === 'object') for (const [k, y] of Object.entries(v)) walk(y, `${path}.${k}`, src);\n else if (typeof v === 'string' && /^[[{]/.test(v)) { try { const j = JSON.parse(v); if (j && typeof j === 'object') for (const [k, y] of Object.entries(j)) walk(y, `${path}.${k}`, src); } catch {} }\n };\n for (const s of p.scopes) for (const [n, v] of Object.entries(s.vars)) walk(plain(v), n, 'scope');\n p.watch.forEach((w, k) => { walk(w.value, k ? `watch#${k}` : 'watch', 'watch'); if (w.expression) walk(w.value, w.expression, 'watch'); });\n for (const [t, rows] of Object.entries(p.rows ?? {})) if (Array.isArray(rows)) for (const r of rows) for (const [c, v] of Object.entries(r)) walk(v, `${t}.${c}`, 'row');\n return out;\n};\nexport const nameMatches = (path, name) => !name || path === name || path.endsWith('.' + name);\nexport const hasValue = (p, v) => leavesOf(p).some((l) => nameMatches(l.path, v.name) && l.str === String(v.value));\n// file:line citations in note text.\nexport const CITE = /(?<![\\w/.-])((?:[\\w.-]+\\/)*[\\w.-]+\\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|rs|sql)):(\\d+)(?!\\d)/g;\nexport const sameFile = (a, b) => a === b || a.endsWith('/' + b) || b.endsWith('/' + a);\n// The source window a step shows: this many lines either side of the paused line.\nexport const WINDOW = 4;\n// The step an invariant links to: the hit-th (0-based, default 0) pause at its point.\nexport const invStep = (P, hits, v) => stepAt(P, hits, v.point, v.hit ?? 0);\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start. `src` is the topic.chapters index\n// (null for the untitled chapter added when the first one starts after step 1). Returns null when a chapter's point is not hit in order.\nexport const chaptersOf = (topic, P) => {\n const N = P.length, starts = []; let from = 0;\n for (const [src, c] of (topic.chapters ?? []).entries()) { const i = P.findIndex((p, j) => j >= from && p.point === c.from); if (i < 0) return null; starts.push({ at: i, title: c.title, src }); from = i; }\n if (!starts.length || starts[0].at > 0) starts.unshift({ at: 0, title: null, src: null });\n return starts.map((s, k) => { const to = (starts[k + 1]?.at ?? N) - 1; return { k, src: s.src, from: s.at, to, title: s.title, idx: Array.from({ length: to - s.at + 1 }, (_, n) => s.at + n) }; });\n};\n// Authored text that states run values (story, outcome, a chapter title, an invariant rule) lists them in `values` (story and outcome in\n// `storyValues` and `outcomeValues`), and each must be in the recording at a step the text speaks for: a chapter's steps, an invariant's\n// linked step, any step for story and outcome. `missing` is what no such step has; `base` (with a base recording) is what the base has instead.\nexport const authoredOf = (topic, P, hits, CH) => {\n const all = P.map((_, i) => i), out = [];\n if (topic.story) out.push({ at: 'story', key: 'story', text: topic.story, values: topic.storyValues ?? [], steps: all });\n if (topic.outcome) out.push({ at: 'outcome', key: 'outcome', text: topic.outcome, values: topic.outcomeValues ?? [], steps: all });\n (topic.chapters ?? []).forEach((c, k) => out.push({ at: `chapter ${k} \"${c.title}\"`, key: `ch${k}`, text: c.title, values: c.values ?? [], steps: CH?.find((x) => x.src === k)?.idx ?? [] }));\n (topic.invariants ?? []).forEach((v, k) => { const i = invStep(P, hits, v); out.push({ at: `invariant ${k}`, key: `inv${k}`, text: v.rule, values: v.values ?? [], steps: i < 0 ? [] : [i] }); });\n return out.map((a) => ({ ...a, missing: a.values.filter((v) => !a.steps.some((i) => hasValue(P[i], v))) }));\n};\nexport const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;\n// The last earlier pause in the same call. The recorder counts each function's entries and exits around its points: an earlier pause is in\n// this call when the function was entered no more times since and exactly one call of it was live then. Anything less shows no Before.\nexport const previousInCall = (P) => P.map((p, i) => {\n for (let j = i - 1; j >= 0; j--) if (P[j].call?.fn === p.call?.fn && p.call) return P[j].call.entries === p.call.entries && P[j].call.live === 1 ? j : null;\n return null;\n});\n// Diff against a base recording: the same step is the same point at the same hit, and values compare exactly. A topic records with `pin` so ids\n// and times repeat; whatever still differs per run (a temp directory, say) the topic names in `generated`, regexes whose matches compare equal.\nclass Container { constructor(container) { this.container = container; } }\nexport const diffOf = (topic, rec, B, file) => {\n const P = rec.pauses, hits = hitsOf(P), rel = relTo(rootOf(topic, rec), topic.dir), BR = relTo(rootOf(topic, B), topic.dir), bh = hitsOf(B.pauses);\n const GEN = (topic.generated ?? []).map((r) => new RegExp(r, 'g'));\n const norm = (v) => GEN.reduce((t, r) => t.replace(r, '(generated)'), v);\n const normDeep = (v) => typeof v === 'string' ? norm(v) : Array.isArray(v) ? v.map(normDeep) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, y]) => [k, normDeep(y)])) : v;\n const same = (x, y) => typeof x === typeof y && (x === y || JSON.stringify(normDeep(x)) === JSON.stringify(normDeep(y)));\n const show = (v) => v instanceof Container ? `(${v.container})` : v === undefined ? 'undefined' : JSON.stringify(v).slice(0, 80);\n const srcLine = (R, url, line) => { const s = sourceOf(R, url); return s == null ? null : s.split('\\n')[line - 1] ?? ''; };\n const rowWrites = (Q, i) => { if (i === 0) return []; const ts = Object.keys(Q[i].rows ?? {}).filter((t) => Array.isArray(Q[i].rows[t]));\n return ts.flatMap((t) => { const a = new Set((Q[i - 1].rows[t] ?? []).map((r) => JSON.stringify(r))); return Q[i].rows[t].filter((r) => !a.has(JSON.stringify(r))).map((r) => ({ t, r })); }); };\n // A container leaf compares by its kind (array, object, or a string holding JSON) and its children carry the content, so a type change shows.\n const jsonText = (v) => { try { const j = JSON.parse(v); return j !== null && typeof j === 'object'; } catch { return false; } };\n const valueLeaves = (p) => leavesOf({ ...p, rows: {} }).map((l) => ({ path: l.path, val: Array.isArray(l.val) ? new Container('array') : l.val && typeof l.val === 'object' ? new Container('object') : typeof l.val === 'string' && jsonText(l.val) ? new Container('JSON string') : l.val }));\n const rowDelta = (x, y) => `${y.t} ${[...new Set([...Object.keys(x.r), ...Object.keys(y.r)])].filter((c) => !same(x.r[c], y.r[c])).map((c) => `${c} ${show(x.r[c])} → ${show(y.r[c])}`).join(', ')}`;\n const out = P.map((p, i) => {\n const j = B.pauses.findIndex((q, k) => q.point === p.point && bh[k] === hits[i]);\n if (j < 0) return { i, kinds: ['step'], detail: ['no such step in base'], names: [] };\n const q = B.pauses[j], kinds = [], detail = []; let was = null, now = null;\n // Source: the first line of the step's source window (the paused line plus or minus WINDOW) that the two checkouts differ on.\n let at = null;\n for (let d = -WINDOW; d <= WINDOW && at == null; d++) {\n const ls = srcLine(rec, p.frames[0].url, p.frames[0].line + d), lb = srcLine(B, q.frames[0].url, q.frames[0].line + d);\n if (ls !== null && lb !== null && ls.trim() !== lb.trim()) { kinds.push('source'); was = lb; now = ls; at = p.frames[0].line + d; }\n }\n const fs = (s, R) => s.map((f) => `${f.fn} ${R(f.url)}:${f.line}`).join('\\n');\n if (fs(stackOf(p), rel) !== fs(stackOf(q), BR)) kinds.push('calls');\n const mh = new Map(valueLeaves(p).map((l) => [l.path, l.val])), mb = new Map(valueLeaves(q).map((l) => [l.path, l.val]));\n const vd = [...new Set([...mb.keys(), ...mh.keys()])].filter((k) => !(mh.has(k) && mb.has(k) && same(mb.get(k), mh.get(k))));\n if (vd.length) { kinds.push('values'); for (const k of vd.slice(0, 4)) detail.push(`${k} ${mb.has(k) ? show(mb.get(k)) : 'absent'} → ${mh.has(k) ? show(mh.get(k)) : 'absent'}`); if (vd.length > 4) detail.push(`+${vd.length - 4} more values`); }\n const rh = rowWrites(P, i), rb = rowWrites(B.pauses, j);\n const rk = rh.length !== rb.length ? -1 : rh.findIndex((x, k) => x.t !== rb[k].t || !same(rb[k].r, x.r));\n if (rh.length !== rb.length || rk >= 0) { kinds.push('rows'); detail.push(rk >= 0 ? `row ${rowDelta(rb[rk], rh[rk])}` : `row writes ${rb.length} → ${rh.length}`); }\n return { i, kinds, detail, was, now, at, names: [...new Set(vd.map((k) => k.split('.')[0]))] };\n });\n return { file, steps: out, missingBase: B.pauses.map((q, k) => k).filter((k) => !P.some((p, i) => p.point === B.pauses[k].point && hits[i] === bh[k])) };\n};\n","pin.mjs":"// Makes a recording reproducible: with `\"pin\": true` in the topic, record.mjs imports this before the scenario. Random bytes and UUIDs come from\n// a seeded generator, and the clock starts at a fixed instant and moves 1 ms per read, so two recordings of the same code write the same ids\n// and times and a base/head diff compares values exactly.\nimport crypto from 'node:crypto';\nimport { syncBuiltinESMExports } from 'node:module';\n\nlet state = 0x9e3779b9;\nconst next = () => {\n state = (state + 0x6d2b79f5) >>> 0;\n let t = state;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return (t ^ (t >>> 14)) >>> 0;\n};\nconst fill = (view) => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n for (let k = 0; k < bytes.length; k++) bytes[k] = next() & 255;\n return view;\n};\nconst uuid = () => {\n const b = fill(new Uint8Array(16));\n b[6] = (b[6] & 0x0f) | 0x40;\n b[8] = (b[8] & 0x3f) | 0x80;\n const h = Buffer.from(b).toString('hex');\n return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;\n};\n\ncrypto.randomBytes = (n, cb) => {\n const out = fill(Buffer.alloc(n));\n if (cb) { process.nextTick(cb, null, out); return undefined; }\n return out;\n};\ncrypto.randomUUID = uuid;\nObject.defineProperty(globalThis.crypto, 'getRandomValues', { value: fill, configurable: true, writable: true });\nObject.defineProperty(globalThis.crypto, 'randomUUID', { value: uuid, configurable: true, writable: true });\nsyncBuiltinESMExports();\nMath.random = () => next() / 2 ** 32;\n\nconst RealDate = Date, start = RealDate.UTC(2026, 0, 1);\nlet clock = start;\nconst now = () => ++clock;\nglobalThis.Date = class Date extends RealDate {\n constructor(...a) { if (a.length) super(...a); else super(now()); }\n static now() { return now(); }\n};\nperformance.now = () => clock - start;\n","ui.mjs":"// shadcn/ui (new-york-v4) class recipes on native HTML, and the Tailwind build that compiles the classes a page uses.\n// The page runs no JavaScript, so Radix state selectors map to native ones: data-[state=active] on a tab is the checked radio\n// just before its label (peer-checked), data-[state=checked] on a checkbox is :checked, and a menu is a popover element.\n// Tabs use peer, not has-[:checked] or has-[:focus-visible]: a :has(:focus-visible) rule doubled the time to switch level.\nimport { readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname } from 'node:path';\nimport { cva } from 'class-variance-authority';\nimport { twMerge } from 'tailwind-merge';\nimport { compile } from 'tailwindcss';\n\nexport const cn = (...c) => twMerge(c.filter(Boolean).join(' '));\n\nconst buttonCva = cva(\n \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',\n ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n },\n size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5' },\n },\n defaultVariants: { variant: 'default', size: 'default' },\n },\n);\n\nconst badgeCva = cva(\n 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',\n destructive: 'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',\n outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',\n },\n },\n defaultVariants: { variant: 'default' },\n },\n);\n\n// Each recipe becomes one component class through @apply, so the markup carries a short name instead of the whole recipe.\n// With the recipes inline on every element the hub page grew 13% and switched level 13% slower; as classes it grows 2%.\nconst RECIPES = {\n 'ui-button': buttonCva(),\n 'ui-button-outline': buttonCva({ variant: 'outline' }),\n 'ui-badge-outline': badgeCva({ variant: 'outline' }),\n 'ui-badge-destructive': badgeCva({ variant: 'destructive' }),\n 'ui-tabs-list': 'inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground',\n // Each tab is <span class=\"contents\"><input class=\"peer sr-only\"><label>, so a radio's peer is only its own label.\n 'ui-tabs-trigger': cn(\n 'relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground',\n 'peer-focus-visible:border-ring peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-focus-visible:outline-1 peer-focus-visible:outline-ring',\n 'peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-sm dark:peer-checked:border-input dark:peer-checked:bg-input/30 dark:peer-checked:text-foreground',\n ),\n 'ui-checkbox': 'size-4 shrink-0 cursor-pointer appearance-none rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 checked:border-primary checked:bg-primary dark:bg-input/30 dark:checked:bg-primary',\n 'ui-label': 'flex items-center gap-2 text-sm leading-none font-medium select-none',\n // PopoverContent and DropdownMenuContent, without Radix's positioning and open/close animation classes.\n 'ui-popover': 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50',\n 'ui-menu': 'z-50 min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50',\n 'ui-menu-checkbox-item': 'relative flex cursor-default items-start gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground',\n // Table, TableHeader, TableRow, TableHead and TableCell on one element, without whitespace-nowrap so the page fits 1040 px.\n 'ui-table': 'w-full caption-bottom text-sm [&_thead_tr]:border-b [&_tbody_tr:last-child]:border-0 [&_tr]:border-b [&_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/50 [&_thead_th]:h-10 [&_th]:px-2 [&_th]:text-left [&_th]:align-middle [&_th]:font-medium [&_th]:text-foreground [&_td]:p-2 [&_td]:align-middle',\n};\nexport const buttonVariants = ({ variant = 'default' } = {}) => variant === 'default' ? 'ui-button' : `ui-button-${variant}`;\nexport const badgeVariants = ({ variant }) => `ui-badge-${variant}`;\nexport const tabsList = 'ui-tabs-list', tabsTrigger = 'ui-tabs-trigger', checkbox = 'ui-checkbox', label = 'ui-label';\nexport const popoverContent = 'ui-popover', menuContent = 'ui-menu', menuCheckboxItem = 'ui-menu-checkbox-item', table = 'ui-table';\n\n// shadcn's theme variables, set from the Geist foundation's tokens so the page keeps Geist's type and colour.\nexport const themeCss = `.vbg-report{--background:var(--vbg-surface-primary);--foreground:var(--vbg-text-primary);--popover:var(--vbg-surface-primary);--popover-foreground:var(--vbg-text-primary);--primary:var(--vbg-text-primary);--primary-foreground:var(--vbg-surface-primary);--secondary:var(--vbg-surface-secondary);--secondary-foreground:var(--vbg-text-primary);--muted:var(--vbg-surface-secondary);--muted-foreground:var(--vbg-text-secondary);--accent:var(--vbg-surface-secondary);--accent-foreground:var(--vbg-text-primary);--destructive:var(--vbg-color-error);--border:var(--vbg-border-default);--input:var(--vbg-border-default);--ring:var(--vbg-focus);--radius:var(--vbg-radius)}\n.ui-checkbox:checked{background:var(--primary) center/12px no-repeat url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\")}\n@media (prefers-color-scheme:dark){.ui-checkbox:checked{background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\")}}`;\n\nconst twInput = `@layer vbg, theme, base, components, utilities;\n@import \"tailwindcss/theme.css\" layer(theme);\n@import \"tailwindcss/utilities.css\" layer(utilities);\n@theme inline{--color-background:var(--background);--color-foreground:var(--foreground);--color-popover:var(--popover);--color-popover-foreground:var(--popover-foreground);--color-primary:var(--primary);--color-primary-foreground:var(--primary-foreground);--color-secondary:var(--secondary);--color-secondary-foreground:var(--secondary-foreground);--color-muted:var(--muted);--color-muted-foreground:var(--muted-foreground);--color-accent:var(--accent);--color-accent-foreground:var(--accent-foreground);--color-destructive:var(--destructive);--color-border:var(--border);--color-input:var(--input);--color-ring:var(--ring);--radius-sm:calc(var(--radius) - 4px);--radius-md:calc(var(--radius) - 2px);--radius-lg:var(--radius);--font-sans:Geist,sans-serif;--font-mono:\"Geist Mono\",ui-monospace,monospace}\n@custom-variant dark (@media (prefers-color-scheme:dark));\n@layer components{${Object.entries(RECIPES).map(([k, v]) => `.${k}{@apply ${cn(v)};}`).join('\\n')}}`;\n\nconst twDir = dirname(createRequire(import.meta.url).resolve('tailwindcss/package.json'));\n\n// Compiles the utilities for every class the page's markup uses. Tailwind's layers sit above the foundation's `vbg` layer.\nexport async function tailwindCss(html) {\n const { build } = await compile(twInput, {\n base: twDir,\n loadStylesheet: async (id) => {\n const path = `${twDir}/${id.replace(/^tailwindcss\\//, '')}`;\n return { path, base: twDir, content: readFileSync(path, 'utf8') };\n },\n });\n return build([...new Set([...html.matchAll(/class=\"([^\"]*)\"/g)].flatMap((m) => m[1].split(/\\s+/)).filter(Boolean))]);\n}\n","package.json":"{\n \"name\": \"deep-dive-skill\",\n \"private\": true,\n \"description\": \"Render and screenshot dependencies of the deep-dive skill: shiki, geist, tailwindcss, class-variance-authority and tailwind-merge for render.mjs, playwright for shot.mjs and controls.mjs.\",\n \"dependencies\": {\n \"class-variance-authority\": \"0.7.1\",\n \"geist\": \"1.9.2\",\n \"playwright\": \"1.65.0\",\n \"shiki\": \"4.6.0\",\n \"tailwind-merge\": \"3.7.0\",\n \"tailwindcss\": \"4.3.5\"\n }\n}\n"}};
@@ -131,7 +131,7 @@ function decode(path) {
131
131
  }
132
132
  export const artifactInput = z.object({
133
133
  kind: z.enum(["walkthrough", "sitrep", "plan", "review", "pr", "decision", "doc"]).describe("What the artifact is."),
134
- title: z.string().min(1).max(80).describe("Names the thing, at most 80 characters, e.g. 'First-sync walk-through'. The same kind and title replaces the older one."),
134
+ title: z.string().min(1).max(80).describe("Names the thing, at most 80 characters, e.g. 'First-sync walk-through'. A newer post under the same kind and title is marked as the latest version; older versions stay listed."),
135
135
  });
136
136
  /** An artifact's link: the one given (a sandbox path uploaded), else the post's first link, else its PR. */
137
137
  export async function artifactUrl(given, text, pr, open) {
@@ -176,7 +176,7 @@ export default defineTool({
176
176
  artifact: artifactInput
177
177
  .extend({ url: z.string().optional().describe("What a tap opens: a sandbox path (uploaded), hub://upload link or https URL. Default: the post's first link, else metadata.pr.") })
178
178
  .optional()
179
- .describe("Tags this post as one of the thread's artifacts, shown in the thread's artifacts drawer: a walk-through, sitrep, plan, review, PR, decision or doc. The drawer keeps the newest per kind and title, so post a new version under the same title to replace the old one."),
179
+ .describe("Tags this post as one of the thread's artifacts, shown in the thread's Artifacts pane: a walk-through, sitrep, plan, review, PR, decision or doc. The pane lists every version and marks the newest per kind and title as latest, so post a new version under the same title to supersede the old one."),
180
180
  }),
181
181
  async execute({ threadId, agent, text, images, policy, status, metadata, verdict, decisions, tested, artifact }, ctx) {
182
182
  const bad = textError(text);
@@ -3,7 +3,7 @@
3
3
  // registry keeps the rename history and posts an inline "<bot> renamed this
4
4
  // thread: old → new" marker; tags go on thread.tags (lib/tags.mjs) and the
5
5
  // summary is the one `set_summary` writes (lib/paths.mjs setSummary). `artifacts`
6
- // adds drawer artifacts with no post: the hub writes one silent event row each.
6
+ // adds Artifacts pane entries with no post: the hub writes one silent event row each.
7
7
  import { defineTool } from "eve/tools";
8
8
  import { z } from "zod";
9
9
  import { registryFetch } from "../registry-fetch.mjs";
@@ -25,7 +25,7 @@ export default defineTool({
25
25
  .min(1)
26
26
  .max(10)
27
27
  .optional()
28
- .describe("Adds artifacts to the thread's artifacts drawer without posting a message; each shows as a silent line in the thread that wakes nobody. When the artifact comes with a post, use post_message's `artifact` instead."),
28
+ .describe("Adds artifacts to the thread's Artifacts pane without posting a message; each shows as a silent line in the thread that wakes nobody. When the artifact comes with a post, use post_message's `artifact` instead."),
29
29
  }),
30
30
  async execute({ threadId, artifacts, ...fields }, ctx) {
31
31
  let sandbox;