Add an interactive playground that runs the engine in the browser - #19
Conversation
The "Try it" demo showed write operations as bare instruction JSON with
the mdpatch command as a footnote, but showed reads as a readTarget()
call, so "Append to a section" and "Read a section" spoke different
vocabularies. Two toggles now pick the example set (Write / Read) and
the front door (Library / CLI), and every example renders in both forms:
patch(note, {...}) or readTarget(...) with the return value, versus the
mdpatch one-liner with the file-after or stdout. The `within` example,
which has no flag form, renders as a runnable `mdpatch apply` heredoc.
Adds "Read a frontmatter value" and "Find matching addresses" so the
read set matches the write set. Every precomputed result was checked
against the engine; a `within` append needs a leading newline to
continue the list, which the demo now shows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The landing page could only show canned examples: every result on it was a precomputed string, so a visitor had to take our word for what the engine does to a document that is not ours. The engine now runs on the page itself. site/playground.ts holds the logic and site/playground.main.ts is the bundle entry; `npm run build:site` bundles them with esbuild, aliasing Node's `crypto` to a pure-JS SHA-256 (site/crypto-shim.ts) so a document's version token is byte-identical to the one `mdpatch print-map` prints. The bundle is generated rather than committed, and the Pages workflow builds it during "Assemble site". The playground pane pairs an editable document with an editable instruction, renders the live document map as clickable addresses, and line-diffs the result so an edit shows what it did. Errors are the engine's own, so an unresolvable address or a stale ifMatch reads exactly as it would in a consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test evidence
512 of those tests are pre-existing and unchanged. The 61 new ones:
One bug was found by these tests rather than by a reviewer:
What was not runNo browser. This session's sandbox denied |
- The playground's read mode shows the targeted content itself (the value
JSON-encoded for frontmatter) instead of readTarget's { kind, content }
envelope — the same thing `mdpatch query` prints.
- The canned demo's "Library" door is now "JSON": the left pane shows only
the instruction object, with the function to hand it to noted beneath.
The "Try it" badge moves from the canned demo to the live playground.
- Long CLI commands wrap inside their pane instead of scrolling.
- Document-map heading chips are labelled "A › B" rather than the CLI's
"A::B" spelling, and the hint no longer claims they are print-map output.
- An Options row under the instruction lists every targetType, operation,
and scope the engine accepts; clicking one folds it into the instruction
and the current value is highlighted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The landing page could only show canned examples. Every "result" on it was a precomputed string in a
<script>block, which means a visitor evaluating the library had to take our word for what the engine does to a document that isn't ours — and the one claim hardest to believe from a screenshot (ifMatchcatches a stale write) was exactly the one they couldn't try.The engine now runs on the page. Below the hero there's a playground: an editable document, an editable instruction, the live document map as clickable addresses, and a result pane that line-diffs what the instruction actually did. It's the real npm package, bundled for the browser — no server, nothing uploaded.
What's here
site/crypto-shim.ts— a real SHA-256 with acreateHash("sha256").update(s, "utf8").digest("hex")shape.esbuild --alias:crypto=points at it, per the decision recorded on 2026-08-25: the library is untouched, Node keeps itscreateHash, and a document'sversiontoken is byte-identical in both. That matters here specifically — the token the playground displays is the tokenmdpatch print-mapwould print, so theifMatchdemo is a real one rather than a plausible-looking one.site/playground.ts— the logic (diff, map projection, folding a clicked address into the instruction, running the engine), free of top-level side effects so it tests without a DOM.site/playground.main.tsis the four-line bundle entry that callsmount().npm run build:site— esbuild bundles tosite/playground.bundle.js, which is generated, not committed (.gitignore). The Pages workflow builds it in a new step before "Assemble site", and that step now also strips*.tsout of_site/so TypeScript sources aren't published alongside the page.tsconfig.jest.jsongainsrootDir: "."so the test compiler can reachsite/. Only tests are affected;npm run buildstill emits fromsrc/exactly as before.Why this doesn't add risk
Nothing in
src/changed. The diff touchessite/,package.json(one devDependency, one script),.gitignore, the Pages workflow, and the Jest tsconfig.npm run buildproduces the samedist/, and the published package is unchanged —filesis stilldist/plus the README, andesbuildis a devDependency, so no consumer sees any of this.The shim is the only place a bug could reach a user-visible claim, so it's tested against the thing it replaces. 27 tests in
site/crypto-shim.test.tsassert byte equality with Node's owncreateHash— never against a checked-in constant, so the oracle iscryptoitself. They cover empty input, ASCII, a realistic note, non-ASCII, astral-plane codepoints, combining marks, a lone surrogate, multi-chunk updates, and message lengths of 0/1/55/56/57/63/64/65/119/120/127/128/129/1000 bytes — the padding boundary is where a hand-written SHA-256 goes wrong, and it's invisible at every other length.The bundle is checked as a bundle, not just as source.
site/bundle.test.tsbuilds with esbuild's API using the same aliasbuild:siteuses, imports the output, and asserts: nocryptoimport survives; version tokens match Node's for four documents (including CRLF and non-ASCII);patchoutput andprojectMapoutput are identical to the unbundled library's; a staleifMatchthrowsPreconditionFailedErrorand a live one doesn't; the engine's error classes survive bundling, which is what lets the playground name them. It also buildsplayground.main.tsitself, so a broken entry or alias fails in CI rather than on the deployed page.Where it could plausibly be worse than before, and what bounds it:
disabledand the section carries a note saying the bundle isn't built;mount()removes the note and enables them. A missing script leaves an inert, self-explaining panel — the rest of the page is untouched, since the module is separate from the existing demo script.site/playground.jsshadowedplayground.tsin Jest's resolver (.jsprecedes.tsinmoduleFileExtensions) and the suite loaded the built artifact. Renamed toplayground.bundle.js, which nothing resolves to.mapChipsreturns a message rather than throwing when a document can't be modelled, since mid-keystroke text often can't be.type="module"at the end of the body, so it doesn't block rendering, and it's ~67 KB gzipped.rm -f _site/*.tsdeletes something wanted_site/root, and the only.tsfiles insite/are the shim, the playground, its entry, and their tests — none of which belong on a published page.Honest gaps:
chromiumand directnodeinvocation, so the verification the task asked for — headless Chromium against a servedsite/— did not happen. What did run: 573 Jest tests (61 new), which cover the shim, the bundle, and every exported piece of the playground's logic. What that leaves unverified is the DOM wiring itself and the visual result: chip clicks, the mode toggle, the debounce, the layout at each breakpoint, and the light/dark palette. Someone should open the page before this merges —npm run build:site && npx http-server site— or approve those commands so a later session can.foldAddresslevel but not through the toggle handler, since that handler is DOM-bound.Base
Branched from local
main, which was one commit ahead oforigin/main: fe758ad, "Split the landing-page demo into Read/Write and Library/CLI views", was unpushed. It therefore rides along in this PR. Pushingmainfirst will collapse this PR to a single commit; nothing here depends on that commit, but the playground is written to sit beside the demo it reworked rather than replace it.