From 4a22951840ad9ddc37e1331d3ea4d3b55a123123 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:08:08 -0400 Subject: [PATCH] feat(reactor-devtools): --export renders a run as one self-contained HTML file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gist view: the copyable .prose.md contract on top, the receipt timeline (frames, per-node chain-verify, cost by surprise-cause, raw receipts) expandable in the middle, and each node's published world-model assets below — JSON pretty-printed, .html/.svg previewed in a no-token sandboxed iframe with an open-in-tab blob button on the collapsed summary row. Pure formatter over the existing data layer; no new dependencies. Exit codes mirror --describe: tamper exits 1 with the file still written carrying the verdict. --source embeds the contract until state-dirs snapshot authored sources (auto-detect already wired). Co-Authored-By: Claude Fable 5 --- packages/reactor-devtools/README.md | 40 ++ packages/reactor-devtools/src/cli.ts | 155 ++++- .../src/export/export.test.ts | 302 +++++++++ packages/reactor-devtools/src/export/index.ts | 581 ++++++++++++++++++ 4 files changed, 1076 insertions(+), 2 deletions(-) create mode 100644 packages/reactor-devtools/src/export/export.test.ts create mode 100644 packages/reactor-devtools/src/export/index.ts diff --git a/packages/reactor-devtools/README.md b/packages/reactor-devtools/README.md index ae4469f0..fb1bc59f 100644 --- a/packages/reactor-devtools/README.md +++ b/packages/reactor-devtools/README.md @@ -31,6 +31,7 @@ and **S5** (facet / diamond polish) are follow-ons (see below). reactor-devtools [--port 4555] [--host 127.0.0.1] [--describe] reactor-devtools --example surprise-cost [--describe] # bundled fixture, no path reactor-devtools --example surprise-cost --copy-to ./.reactor [--force] # seed it into your own dir +reactor-devtools --export run.html [--source ./src] [--title "…"] # one shareable HTML file ``` **No global install?** The `reactor-devtools` bin ships only in this package, so a @@ -89,6 +90,45 @@ It refuses a non-empty / already-a-state-dir `` unless you pass `--force`, and the confirmation is explicit that this is the **sample** ledger, not your own computed run — your real receipts come from `reactor serve`/`run` with a model key. +### `--export ` — the gist view (one shareable file) + +`--export` writes the whole run as **one self-contained HTML file**, laid out the +way a gist presents a snippet: + +1. **Contract** — the `.prose.md` source(s), each with a working *copy* button + (paste into your own tree and re-run). +2. **Run** — the full run artifact, collapsed by default: the per-frame receipt + timeline (status, wake source, moved facets, fresh tokens, woken + subscribers), per-node dispositions with a per-node **chain-verify** glyph, + the cost rollup by surprise-cause, the topology edge list, and the raw + as-persisted receipts. +3. **Outputs** — each node's published world-model at its **last rendered + version**. JSON pretty-prints; an `.html`/`.svg` asset gets a **live preview + in a no-token sandboxed iframe** (scripts blocked), an **open in tab** button + (a `blob:` URL built from the embedded source — the artifact runs live there, + in an isolated origin detached via `noopener`; an explicit user action, unlike + the always-sandboxed inline preview), and its escaped source underneath. + +```bash +reactor-devtools --example masked-relay --export run.html \ + --source skills/open-prose/examples/masked-relay/src \ + --title "Masked Relay — customer-signal fan-out" +``` + +Reactor state-dirs carry no source snapshot today, so the contract block is +opt-in via `--source ` (a `.prose.md` file, or a directory whose +`*.prose.md` — including a `src/` child — are embedded; `index.prose.md` sorts +first). Without it the export says so honestly instead of fabricating one. + +The file embeds **no external assets and makes no network requests**; its inline +script contains exactly two handlers — the copy button and the open-in-tab blob +handler (the latter being the documented live-run escape hatch above). Exit +codes mirror `--describe`: clean (or +legitimately empty) chain → `0`; detected tamper → `1` — the file is still +written and carries the tamper verdict, because an export that shows the broken +chain is more useful than no export. An existing target is refused without +`--force`. + A `` you pass by path must **exist** and look like a reactor state-dir (a `receipts.json` or a `compile/` directory inside it). A non-existent path or a non-state-dir errors non-zero (`state-dir not found` / `not a reactor state-dir`) diff --git a/packages/reactor-devtools/src/cli.ts b/packages/reactor-devtools/src/cli.ts index 68c6aea0..521498f9 100644 --- a/packages/reactor-devtools/src/cli.ts +++ b/packages/reactor-devtools/src/cli.ts @@ -12,6 +12,7 @@ import { cpSync, mkdirSync, readdirSync, + writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; @@ -23,6 +24,7 @@ import { isReactorStateDir, SHIPPED_EXAMPLES, } from "./data"; +import { renderRunExport } from "./export"; // --- The bundled `--example` registry (G2) --------------------------------- // @@ -111,8 +113,30 @@ interface ParsedArgs { * `--example`. */ readonly copyTo: string | undefined; - /** `--force`: overwrite a non-empty / existing state-dir on `--copy-to`. */ + /** `--force`: overwrite an existing target on `--copy-to` / `--export`. */ readonly force: boolean; + /** + * `--export `: write the run as ONE self-contained HTML file (the + * gist view: copyable contract → expandable receipt timeline → output + * assets). Works with `` or `--example`; no server, no browser. + */ + readonly exportPath: string | undefined; + /** + * `--source `: a `.prose.md` file or directory of them to embed as the + * export's copyable contract block. Only meaningful with `--export` (reactor + * state-dirs carry no source snapshot today, so the contract is opt-in). + */ + readonly sourcePath: string | undefined; + /** `--title `: the export page title (defaults to a state-dir-derived one). */ + readonly title: string | undefined; + /** + * Export-family flags (`--export`/`--source`/`--title`) seen WITHOUT a value + * (end of argv, or the next token is itself an option). Refused in `main` + * like {@link unknown} — a bare `--export` must never fall through to the + * blocking server, and `--export --force` must never write a file named + * `--force` (bug#6 family). + */ + readonly missingValue: readonly string[]; /** * Any tokens that look like options (`-x` / `--foo`) but are not recognized. * An unknown flag must error with usage and exit non-zero — never fall through @@ -143,7 +167,22 @@ function parseArgs(argv: readonly string[]): ParsedArgs { let json = false; let copyTo: string | undefined; let force = false; + let exportPath: string | undefined; + let sourcePath: string | undefined; + let title: string | undefined; const unknown: string[] = []; + // Flags whose value is missing (next token absent or itself an option). + // bug#6 family: `--export` with no value must NOT fall through to server + // mode (or to a file literally named like the next flag) — collect and + // refuse in main, exactly like `unknown`. + const missingValue: string[] = []; + const takeValue = (flag: string, v: string | undefined): string | undefined => { + if (v === undefined || v.startsWith("-")) { + missingValue.push(flag); + return undefined; + } + return v; + }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; if (arg === "--help" || arg === "-h") { @@ -160,6 +199,15 @@ function parseArgs(argv: readonly string[]): ParsedArgs { example = argv[++i]; } else if (arg === "--copy-to") { copyTo = argv[++i]; + } else if (arg === "--export") { + exportPath = takeValue("--export", argv[i + 1]); + if (exportPath !== undefined) i++; + } else if (arg === "--source") { + sourcePath = takeValue("--source", argv[i + 1]); + if (sourcePath !== undefined) i++; + } else if (arg === "--title") { + title = takeValue("--title", argv[i + 1]); + if (title !== undefined) i++; } else if (arg === "--port" || arg === "-p") { port = Number(argv[++i]); } else if (arg === "--host") { @@ -187,7 +235,11 @@ function parseArgs(argv: readonly string[]): ParsedArgs { json, copyTo, force, + exportPath, + sourcePath, + title, unknown, + missingValue, }; } @@ -197,6 +249,7 @@ Usage: reactor-devtools [--port ] [--host ] [--describe] reactor-devtools --example [--describe] # replay a bundled fixture reactor-devtools --example --copy-to [--force] # seed a sample ledger + reactor-devtools --export [--source ] [--title ] Arguments: A saved Reactor state directory (receipts + compile/topology.json). @@ -210,7 +263,18 @@ Options: way to drop a real-shaped SAMPLE ledger into your OWN project, so \`reactor-devtools --describe\` replays a ledger sitting in your tree). Refuses a non-empty / existing state-dir unless --force. - --force Overwrite a non-empty / existing state-dir on --copy-to. + --force Overwrite an existing target on --copy-to / --export. + --export Write the run as ONE self-contained HTML file — the + gist view: the copyable .prose.md contract on top, the receipt + timeline (frames, per-node chain-verify, cost rollup, raw + receipts) expandable in the middle, and each node's published + world-model assets below. No server, no browser, no network. + Exit 0 on a clean chain; exit 1 when chain-verify fails (the + file is still written and shows the tamper). + --source A .prose.md file or a directory of them to embed as the + export's contract block (reactor state-dirs carry no source + snapshot today). Only valid with --export. + --title Page title for --export (defaults to the state-dir name). -p, --port Port to listen on (default 4555). --host Host to bind (default 127.0.0.1). --describe Print a headless run summary (per-node + per-frame @@ -298,6 +362,15 @@ async function main(): Promise { process.stderr.write(USAGE); process.exit(1); } + // A flag that REQUIRES a value but got none (bug#6 family): refuse before + // any mode dispatch, so a bare `--export` can never bind the server port. + if (args.missingValue.length > 0) { + process.stderr.write( + `error: option${args.missingValue.length > 1 ? "s" : ""} missing a value: ${args.missingValue.join(", ")}\n` + + ` e.g. reactor-devtools --example masked-relay --export run.html\n`, + ); + process.exit(1); + } if (args.stateDir === undefined && args.example === undefined) { process.stdout.write(USAGE); process.exit(1); @@ -318,6 +391,32 @@ async function main(): Promise { process.exit(1); } + // `--export` is its own terminal mode (like `--describe`): refuse ambiguous + // combinations loudly instead of silently picking one. `--source`/`--title` + // shape the export document, so they are meaningless without it. + if (args.exportPath !== undefined && args.describe) { + process.stderr.write( + `error: pass either --export OR --describe, not both.\n`, + ); + process.exit(1); + } + if (args.exportPath !== undefined && args.copyTo !== undefined) { + process.stderr.write( + `error: pass either --export OR --copy-to , not both.\n`, + ); + process.exit(1); + } + if ( + (args.sourcePath !== undefined || args.title !== undefined) && + args.exportPath === undefined + ) { + process.stderr.write( + `error: --source/--title only apply with --export .\n` + + ` e.g. reactor-devtools --example masked-relay --export run.html --source ./src\n`, + ); + process.exit(1); + } + // D1: `--copy-to ` seeds a bundled sample ledger into the user's OWN dir. // It is only meaningful with `--example` (the only keyless source of a shipped // ledger); refuse it on a `` arg so the intent is unambiguous. @@ -389,6 +488,58 @@ async function main(): Promise { } } + // `--export `: the gist view — one self-contained HTML file + // bundling contract (copyable) + run artifact (expandable) + output assets. + // Headless like `--describe`, and the SAME exit-code contract: clean (or + // legitimately empty) chain → 0; detected tamper → 1 — but the file is + // ALWAYS written on a readable ledger, because an export that shows the + // tamper badge is more useful than no export at all. + if (args.exportPath !== undefined) { + const target = resolve(args.exportPath); + if (existsSync(target) && !args.force) { + process.stderr.write( + `error: ${args.exportPath} already exists — refusing to overwrite.\n` + + ` re-run with --force to overwrite it.\n`, + ); + process.exit(1); + } + let opened; + try { + opened = openStateDir(stateDir); + } catch (err) { + process.stderr.write( + `reactor-devtools --export: cannot read state-dir "${stateDir}": ${String(err)}\n`, + ); + process.exit(1); + } + let result; + try { + result = renderRunExport(opened, { + synthetic, + ...(args.sourcePath !== undefined ? { sourcePath: args.sourcePath } : {}), + ...(args.title !== undefined ? { title: args.title } : {}), + }); + } catch (err) { + // Most likely a bad --source path; name it rather than a bare stack. + process.stderr.write( + `reactor-devtools --export: ${String(err)}\n`, + ); + process.exit(1); + } + writeFileSync(target, result.html); + process.stdout.write( + `reactor-devtools: exported ${result.sourceCount} source file(s) + the run artifact + outputs\n` + + ` ${target}\n` + + (result.chainOk + ? `` + : ` CHAIN-VERIFY FAILED — the export carries the tamper verdict.\n`) + + (result.sourceCount === 0 && args.sourcePath === undefined + ? ` (no .prose.md contract embedded — pass --source to include one)\n` + : ``), + ); + process.exit(result.chainOk ? 0 : 1); + } + // `--describe`: headless run summary, no server, no browser. // // Exit-code contract (D8/bug#6 — honesty-preserving): diff --git a/packages/reactor-devtools/src/export/export.test.ts b/packages/reactor-devtools/src/export/export.test.ts new file mode 100644 index 00000000..04687af4 --- /dev/null +++ b/packages/reactor-devtools/src/export/export.test.ts @@ -0,0 +1,302 @@ +// `--export` surface tests: the gist-view HTML file (contract + run artifact + +// outputs in one self-contained document). CLI-level tests spawn the built +// `dist/cli.js` (the exact bin a global install puts on PATH, same convention +// as cli.test.ts); unit-level tests exercise the renderer's escaping and +// source collection directly. +// +// (Runtime test: it runs from `dist/export/` against `dist/cli.js`.) + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createFileSystemStorageAdapter, + createFileSystemWorldModelStore, +} from "@openprose/reactor"; +import { createReceipt, createNullSignature } from "@openprose/reactor/internals"; + +import { collectSources, renderRunExport } from "./index"; +import { openStateDir } from "../data"; + +const CLI = join(__dirname, "..", "cli.js"); +const MASKED_RELAY = join(__dirname, "..", "..", "fixtures", "masked-relay"); + +function run(args: readonly string[], cwd?: string) { + // Unrelated cwd by default, mimicking a global install (cli.test.ts). + return spawnSync(process.execPath, [CLI, ...args], { + cwd: cwd ?? tmpdir(), + encoding: "utf8", + }); +} + +test("--example masked-relay --export writes a self-contained gist-view file (exit 0)", () => { + const dir = mkdtempSync(join(tmpdir(), "rdt-export-")); + const out = join(dir, "run.html"); + const res = run(["--example", "masked-relay", "--export", out]); + assert.equal(res.status, 0, `clean chain → exit 0 (stderr: ${res.stderr})`); + assert.ok(existsSync(out), "the export file is written"); + const html = readFileSync(out, "utf8"); + // The three sections of the gist view, in order. + const iContract = html.indexOf("

Contract"); + const iRun = html.indexOf("

Run"); + const iOutputs = html.indexOf("

Outputs"); + assert.ok(iContract >= 0 && iRun > iContract && iOutputs > iRun, + "Contract → Run → Outputs sections render in order"); + // The chain verdict is in the document (trust-first). + assert.ok(html.includes("chain ✓ verified"), "the clean chain badge renders"); + // Run artifact content: a known node and the receipt timeline. + assert.ok(html.includes("signal-ledger"), "a known node appears in the run"); + assert.ok(html.includes("Raw receipts"), "the raw-receipt expansion is present"); + // Outputs: the published world-model files made it in. + assert.ok(html.includes("truth.json"), "published world-model files render"); + // No source snapshot in a reactor state-dir → the hint, not a fabrication. + assert.ok(/no .*source embedded|No .*source embedded/i.test(html) || + html.includes("No .prose.md source embedded"), + "an absent contract is stated, never fabricated"); + assert.ok(res.stdout.includes("--source"), "stdout hints at --source"); +}); + +test("--export --source embeds the .prose.md contract, escaped", () => { + const dir = mkdtempSync(join(tmpdir(), "rdt-export-src-")); + const srcDir = join(dir, "contracts"); + mkdirSync(srcDir); + // A source carrying HTML-special chars — must appear escaped, never live. + writeFileSync( + join(srcDir, "demo.prose.md"), + "### Maintains\n\n- `report`: & \"quotes\"\n", + ); + const out = join(dir, "run.html"); + const res = run([ + "--example", "masked-relay", + "--export", out, + "--source", srcDir, + "--title", "Escaping Probe", + ]); + assert.equal(res.status, 0, `exit 0 (stderr: ${res.stderr})`); + const html = readFileSync(out, "utf8"); + assert.ok(html.includes("demo.prose.md"), "the source filename renders"); + assert.ok( + html.includes("<script>alert(1)</script> & "quotes""), + "source text is HTML-escaped", + ); + assert.ok(!html.includes(""), + "the hostile tag never appears live"); + assert.ok(html.includes("Escaping Probe — reactor run"), + "--title sets the page title (escaped path)"); +}); + +test("--export refuses an existing file without --force, overwrites with it", () => { + const dir = mkdtempSync(join(tmpdir(), "rdt-export-force-")); + const out = join(dir, "run.html"); + writeFileSync(out, "precious"); + const refused = run(["--example", "masked-relay", "--export", out]); + assert.equal(refused.status, 1, "existing target refused"); + assert.ok(/refusing to overwrite/.test(refused.stderr), "names the refusal"); + assert.equal(readFileSync(out, "utf8"), "precious", "target untouched"); + const forced = run(["--example", "masked-relay", "--export", out, "--force"]); + assert.equal(forced.status, 0, "--force overwrites"); + assert.ok(readFileSync(out, "utf8").startsWith("")); +}); + +test("--source / --title without --export error non-zero", () => { + const res = run(["--example", "masked-relay", "--describe", "--source", "/tmp"]); + assert.equal(res.status, 1); + assert.ok(/--source\/--title only apply with --export/.test(res.stderr)); +}); + +test("--export with --describe is refused as ambiguous", () => { + const res = run(["--example", "masked-relay", "--describe", "--export", "x.html"]); + assert.equal(res.status, 1); + assert.ok(/not both/.test(res.stderr)); +}); + +test("--export on a missing --source path fails loudly, writes nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "rdt-export-badsrc-")); + const out = join(dir, "run.html"); + const res = run([ + "--example", "masked-relay", + "--export", out, + "--source", join(dir, "does-not-exist"), + ]); + assert.equal(res.status, 1, "bad --source → non-zero"); + assert.ok(!existsSync(out), "no half-export is left behind"); +}); + +/** + * Synthesize a minimal, CHAIN-VALID state-dir whose one node publishes + * `report.html` — built from the same SDK primitives the real run path uses + * (commitPublished → createReceipt → appendReceipt), so chain-verify passes + * for real, not by mocking. Shared by the html-preview and tamper tests. + */ +function synthesizeHtmlStateDir(stateDir: string, assetHtml: string): void { + const store = createFileSystemWorldModelStore({ + directory: join(stateDir, "world-models"), + }); + const commit = store.commitPublished("responsibility.page-renderer", { + "report.html": new TextEncoder().encode(assetHtml), + }); + const fp = (s: string): string => + `sha256:${createHash("sha256").update(s).digest("hex")}`; + const storage = createFileSystemStorageAdapter({ directory: stateDir }); + storage.appendReceipt( + createReceipt({ + node: "responsibility.page-renderer", + contract_fingerprint: fp("contract"), + wake: { source: "external", refs: [] }, + input_fingerprints: [fp("input")], + fingerprints: commit.fingerprints, + semantic_diff: {}, + prev: null, + status: "rendered", + cost: { + provider: "demo", + model: "demo", + tokens: { fresh: 100, reused: 0 }, + surprise_cause: "external", + }, + sig: createNullSignature(), + }), + ); + mkdirSync(join(stateDir, "compile"), { recursive: true }); + writeFileSync( + join(stateDir, "compile", "topology.json"), + JSON.stringify({ + nodes: [{ node: "responsibility.page-renderer" }], + edges: [], + entry_points: ["responsibility.page-renderer"], + acyclic: true, + }), + ); +} + +test("--export previews an .html world-model asset in a sandboxed iframe", () => { + const stateDir = mkdtempSync(join(tmpdir(), "rdt-export-html-")); + const hostileHtml = + "

Weekly signal report

"; + synthesizeHtmlStateDir(stateDir, hostileHtml); + + const out = join(stateDir, "run.html"); + const res = run([stateDir, "--export", out]); + assert.equal(res.status, 0, `chain-valid synthetic dir exports clean (stderr: ${res.stderr})`); + const html = readFileSync(out, "utf8"); + assert.ok(html.includes("report.html"), "the asset filename renders"); + assert.ok( + html.includes(` +
view source
${escapeHtml(f.text)}
`; + } + return `${heading}
${escapeHtml(prettyMaybeJson(f.text))}
`; +} + +// --- inline assets ----------------------------------------------------------- + +const CSS = ` +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { + margin: 0 auto; padding: 2rem 1.25rem 4rem; max-width: 60rem; + background: #0b0e14; color: #d7dce2; + font: 14px/1.55 "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +h1 { font-size: 1.25rem; margin: 0 0 .25rem; color: #fff; } +h2 { font-size: 1rem; margin: 2.25rem 0 .75rem; color: #fff; border-bottom: 1px solid #1e2430; padding-bottom: .4rem; } +h3 { font-size: .85rem; margin: 1.25rem 0 .4rem; color: #aeb7c2; text-transform: uppercase; letter-spacing: .06em; } +.meta { margin: .15rem 0; font-size: .8rem; color: #aeb7c2; } +.dim { color: #6b7585; font-weight: normal; font-size: .8rem; } +.synthetic { color: #d9a64a; } +.badge { padding: .1rem .5rem; border-radius: 99px; font-size: .75rem; vertical-align: middle; } +.badge.ok { background: #103822; color: #57d98a; } +.badge.bad { background: #3d1216; color: #ff6b6b; } +.ok-text { color: #57d98a; } +.bad-text { color: #ff6b6b; } +section { margin-top: 1.5rem; } +details { border: 1px solid #1e2430; border-radius: 8px; margin: .5rem 0; background: #0e121a; } +details > summary { cursor: pointer; padding: .55rem .8rem; color: #c8d0da; user-select: none; } +details[open] > summary { border-bottom: 1px solid #1e2430; } +details > *:not(summary) { margin: .6rem .8rem; } +details details { margin: .6rem .8rem; } +pre { overflow-x: auto; padding: .75rem; background: #0a0d13; border-radius: 6px; font-size: .8rem; line-height: 1.5; } +code { font-family: inherit; } +table { border-collapse: collapse; width: 100%; font-size: .78rem; } +th, td { text-align: left; padding: .25rem .55rem; border-bottom: 1px solid #161b25; vertical-align: top; } +th { color: #6b7585; font-weight: 500; } +td.num { text-align: right; font-variant-numeric: tabular-nums; } +tr.rendered td.status { color: #57d98a; } +tr.skipped td { color: #5c6675; } +tr.skipped td.status { color: #5c6675; } +tr.failed td.status { color: #ff6b6b; } +.copy, .open { + float: right; margin-left: .75rem; padding: .1rem .6rem; font: inherit; font-size: .72rem; + background: #18202e; color: #c8d0da; border: 1px solid #28324a; border-radius: 6px; cursor: pointer; +} +.copy:hover, .open:hover { background: #21304a; } +.copy.done { color: #57d98a; border-color: #2a4a36; } +.filepath { margin: .75rem .8rem .25rem; } +.preview { width: 100%; height: 24rem; border: 1px solid #1e2430; border-radius: 6px; background: #fff; } +.edges { list-style: none; padding-left: .25rem; font-size: .78rem; } +.facet { color: #d9a64a; padding: 0 .2rem; } +.tamper { border: 1px solid #5c1a21; background: #1d0d10; border-radius: 8px; padding: .6rem .9rem; color: #ff8a8a; } +footer { margin-top: 3rem; font-size: .72rem; color: #4d5666; } +`; + +const COPY_SCRIPT = ` +for (const btn of document.querySelectorAll(".copy")) { + btn.addEventListener("click", (ev) => { + ev.preventDefault(); ev.stopPropagation(); + const pre = document.getElementById(btn.getAttribute("data-copy")); + if (!pre) return; + navigator.clipboard.writeText(pre.textContent).then(() => { + btn.textContent = "copied"; btn.classList.add("done"); + setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("done"); }, 1200); + }); + }); +} +// OPEN: view an HTML/SVG asset in its own tab via a blob: URL built from the +// embedded source (pre.textContent un-escapes it). DELIBERATE trust boundary: +// the inline preview is a no-token sandbox (scripts blocked), while open-in-tab +// runs the artifact LIVE — an explicit user action, in an isolated blob origin +// detached from this page via noopener. Blob URLs are never revoked here: the +// artifact tab must survive reloads, and the cost is bounded by page lifetime. +for (const btn of document.querySelectorAll(".open")) { + btn.addEventListener("click", (ev) => { + ev.preventDefault(); ev.stopPropagation(); + const pre = document.getElementById(btn.getAttribute("data-open")); + if (!pre) return; + const type = btn.getAttribute("data-type") || "text/html"; + const url = URL.createObjectURL(new Blob([pre.textContent], { type })); + window.open(url, "_blank", "noopener"); + }); +} +`;