diff --git a/docs/plans/0026-graph-render-viewer.md b/docs/plans/0026-graph-render-viewer.md
new file mode 100644
index 0000000..d29cd3e
--- /dev/null
+++ b/docs/plans/0026-graph-render-viewer.md
@@ -0,0 +1,847 @@
+# Graph Render Viewer (cycle 0026, iteration 2) — Implementation Plan
+
+> **Parent spec:** `docs/specs/0026-graph-render-redesign.md`
+>
+> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
+> this plan. Steps use `- [ ]` checkboxes for tracking.
+
+**Goal:** Replace `aura graph`'s ascii-dag output with a single self-contained
+HTML page — the ported prototype viewer (pin-graph via Graphviz-WASM) driven by
+the deterministic model serializer that shipped in iteration 1 — and retire
+`ascii-dag` from the workspace. Closes Gitea #51.
+
+**Architecture:** `aura graph` calls a new read-only `render_html(&Composite)`
+(crate `aura-cli`, module `render`) that concatenates the prototype's `
`
+shell, the inlined vendored Graphviz-WASM + pan-zoom blobs, the inlined model
+JSON (`aura_engine::model_to_json`), and the inlined ported viewer JS into one
+`.html` on stdout. The viewer JS is the prototype's `genDot`/chrome **verbatim**,
+prefixed with a `normalizeModel` adapter that maps aura's real model tuple shape
+(post-0027 named inputs: `ins = [kind, firing, name]`, index node keys, `comp`
+references, `src_` sources) into the viewer's native shape. The old
+ascii-dag adapter (`graph.rs`), its dependency, the `--compiled`/`--macd` flag
+plumbing, and all 12 ascii-dag-asserting tests are deleted in one
+compile-coherent task. No `eval`/build on the render path (C9); no `serde` (C14);
+the four-colour palette stays the four scalar base types (C4); the viewer carries
+no authoring logic (C10).
+
+**Tech Stack:** `aura-cli` (`render.rs`, `main.rs`, `Cargo.toml`, `assets/`,
+`tests/cli_run.rs`), `aura-engine::model_to_json` (consumed, unchanged),
+`aura-core::node.rs` (stale-prose tidy), `docs/design/INDEX.md` (ledger note).
+Vendored JS/WASM assets are **checked in** (see Task 1 rationale).
+
+---
+
+## Files this plan creates or modifies
+
+- Create: `crates/aura-cli/assets/graph-viewer.js` — ported viewer (normalizeModel + verbatim genDot/chrome)
+- Create: `crates/aura-cli/assets/viz-standalone.js` — vendored `@viz-js/viz@3.7.0` (Graphviz-WASM, ~1.38 MB, checked in)
+- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js` — vendored `svg-pan-zoom@3.6.1` (~30 KB, checked in)
+- Create: `crates/aura-cli/assets/refresh-assets.sh` — pinned-version refresh script (provenance; not run at build time)
+- Create: `crates/aura-cli/src/render.rs` — `render_html(&Composite) -> String` + smoke test
+- Modify: `crates/aura-cli/src/main.rs` — `mod render;`; flip `["graph"]` arm to `render_html`; delete `--compiled`/`--macd` graph arms, `render_compiled`, the `color` plumbing, `USAGE`; delete 12 ascii-dag tests + `swapped_point`/`sample_point` helpers; drop `mod graph;`
+- Modify: `crates/aura-cli/Cargo.toml:16` — remove `ascii-dag = "0.9.1"`
+- Delete: `crates/aura-cli/src/graph.rs` — the 504-line ascii-dag adapter (incl. the stale #43 comment at :324)
+- Modify: `crates/aura-cli/tests/cli_run.rs:164-185` — replace the ascii-dag fan-in test with the HTML-contract test
+- Modify: `crates/aura-core/src/node.rs:150-157,196-199` — re-justify two stale ascii-dag prose references
+- Modify: `docs/design/INDEX.md:694` — add the cycle-0026 render-redesign realization note
+
+---
+
+## Task 1: Vendor the assets + port the viewer JS
+
+**Vendoring decision (orchestrator call, deferred by the spec):** the blobs are
+**checked into the repo**, not git-ignored + fetched at build time. Substance:
+(a) `render_html` inlines the assets via `include_str!`, which requires them
+present at **compile time** — a build-time `unpkg` fetch makes the build
+non-hermetic and non-offline; (b) C8 freezes deploy artifacts and C1 demands
+determinism — a build that pulls a remote URL drifts with the registry and the
+network; (c) the shipped `.html` must be byte-deterministic for a given commit.
+A checked-in blob is the only shape satisfying all three. `refresh-assets.sh`
+records the pinned provenance for a future deliberate refresh; it is **not** part
+of the build. The ~1.4 MB cost is the price of a hermetic, frozen render asset.
+
+The validated blobs already exist on disk in the prototype dir — copy them (no
+network fetch in this task).
+
+**Files:**
+- Create: `crates/aura-cli/assets/viz-standalone.js`
+- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js`
+- Create: `crates/aura-cli/assets/refresh-assets.sh`
+- Create: `crates/aura-cli/assets/graph-viewer.js`
+
+- [ ] **Step 1: Create the assets dir and copy the two vendored blobs from the prototype**
+
+Run:
+```sh
+mkdir -p crates/aura-cli/assets
+cp docs/design/prototypes/graph-render/viz-standalone.js crates/aura-cli/assets/viz-standalone.js
+cp docs/design/prototypes/graph-render/svg-pan-zoom.min.js crates/aura-cli/assets/svg-pan-zoom.min.js
+wc -c crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js
+```
+Expected: `viz-standalone.js` ≈ `1379750` bytes, `svg-pan-zoom.min.js` ≈ `29768` bytes (both non-empty, copied).
+
+If the prototype blobs are absent (a clean checkout that never ran the
+prototype's `fetch-deps.sh`), fetch them instead with the pinned URLs from Step 3
+below, then re-run the `wc -c` check.
+
+- [ ] **Step 2: Verify the assets are not git-ignored (they are checked in)**
+
+Run:
+```sh
+git check-ignore crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js; echo "exit=$?"
+```
+Expected: `exit=1` (no path printed) — neither file is ignored, so both will be
+committed. If any path is printed, a parent `.gitignore` rule is catching them;
+do **not** add a crate-level `.gitignore` for these (unlike the prototype, the
+engine checks them in).
+
+- [ ] **Step 3: Create the provenance refresh script**
+
+Create `crates/aura-cli/assets/refresh-assets.sh`:
+```sh
+#!/usr/bin/env bash
+# Refresh the vendored graph-viewer assets. NOT run at build time — the blobs are
+# checked into the repo so `include_str!` and the build stay hermetic/offline
+# (C1/C8). Run this only to deliberately bump a pinned version, then commit the
+# updated blobs. Mirrors the prototype's fetch-deps.sh.
+set -euo pipefail
+cd "$(dirname "$0")"
+curl -fsSL "https://unpkg.com/@viz-js/viz@3.7.0/lib/viz-standalone.js" -o viz-standalone.js
+curl -fsSL "https://unpkg.com/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js" -o svg-pan-zoom.min.js
+echo "refreshed: viz-standalone.js (@viz-js/viz@3.7.0), svg-pan-zoom.min.js (svg-pan-zoom@3.6.1)"
+```
+Run:
+```sh
+chmod +x crates/aura-cli/assets/refresh-assets.sh
+head -1 crates/aura-cli/assets/refresh-assets.sh
+```
+Expected: `#!/usr/bin/env bash`.
+
+- [ ] **Step 4: Create the ported viewer `crates/aura-cli/assets/graph-viewer.js`**
+
+This is the prototype's `index.html` script (lines 43-256) **verbatim**, with the
+hardcoded model (prototype lines 52-100, the `F`/`I`/`P`/`C`/`COMP`/`ROOT`
+constants) replaced by `normalizeModel(window.AURA_MODEL)`. `genDot`, `cellLabel`,
+`addInfo`, the tooltip/interaction chrome, and `chrome()`/`show()` are unchanged.
+
+Create `crates/aura-cli/assets/graph-viewer.js`:
+```js
+// aura graph viewer — ported from docs/design/prototypes/graph-render/index.html.
+// A render asset (C10): no node/strategy/experiment logic, no DSL. Reads aura's
+// emitted model (window.AURA_MODEL, injected by render_html) and draws a
+// homogeneous pin-graph via Graphviz-WASM. genDot/cellLabel/chrome are the
+// prototype verbatim; normalizeModel adapts aura's real tuple shape (post-0027
+// named inputs) to the viewer's native shape.
+const TYPE_COLOR = { i64: "#f9e2af", f64: "#89b4fa", bool: "#a6e3a1", timestamp: "#cba6f7" };
+const COLOR_TYPE = Object.fromEntries(Object.entries(TYPE_COLOR).map(([k, v]) => [v.toLowerCase(), k]));
+const PURPLE = "#5b4b8a", TEAL = "#2c5a5a", PLUS = "#7c6ba8";
+const LEAF = "#45475a", SOURCE = "#2e5a3a", SINK = "#5a2e3e";
+const bodyBgFor = (role) => role === "source" ? SOURCE : role === "sink" ? SINK : LEAF;
+const HEAD = `bgcolor="#16161a"; rankdir=TB; nodesep=0.5; ranksep=0.65;
+ node [shape=plaintext, fontname="Courier", fontsize=12];
+ edge [penwidth=1.7, arrowsize=0.8];`;
+const col = (k) => TYPE_COLOR[k] || "#cdd6f4";
+
+// === adapt aura's emitted model to the viewer's native shape =================
+// aura input tuples are [kind, firing, name]; the viewer wants [name, kind, meta?]
+// (non-"any" firing surfaces in the tooltip). outs/params are already [name, kind].
+// aura composite inputs are [kind, firing, name]; the viewer wants [name, kind].
+// node keys (indices / "src_") and edge endpoints pass through unchanged.
+function adaptIns(ins) {
+ return ins.map(([kind, firing, name]) =>
+ firing && firing !== "any" ? [name, kind, { firing }] : [name, kind]);
+}
+function adaptNodes(nodes) {
+ const out = {};
+ for (const key in nodes) {
+ const n = nodes[key];
+ if (n.prim) {
+ const p = n.prim;
+ out[key] = { prim: { type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
+ } else {
+ out[key] = n; // composite reference { comp }
+ }
+ }
+ return out;
+}
+function normalizeModel(model) {
+ const composites = {};
+ for (const cname in (model.composites || {})) {
+ const c = model.composites[cname];
+ composites[cname] = {
+ inputs: c.inputs.map(([kind, firing, name]) => [name, kind]),
+ outputs: c.outputs,
+ nodes: adaptNodes(c.nodes),
+ edges: c.edges,
+ };
+ }
+ return {
+ root: { inputs: [], outputs: [], nodes: adaptNodes(model.root.nodes), edges: model.root.edges },
+ composites,
+ };
+}
+const MODEL = normalizeModel(window.AURA_MODEL);
+const COMP = MODEL.composites;
+const ROOT = MODEL.root;
+
+// === ONE homogeneous, per-cell-addressable node template =====================
+function cellLabel(id, o, showTypes) {
+ const cols = Math.max(o.ins.length, o.outs.length, 1);
+ const span = (n) => Math.max(1, Math.floor(cols / n));
+ const txt = (n, k) => (showTypes && k) ? `${n} : ${k}` : n;
+ const cell = (io, arr) => ([n, k], i) =>
+ `${txt(n, k)} | `;
+ const sp = o.params.map(([n, k]) => `${showTypes && k ? `${n}:${k}` : n}`);
+ const sig = o.params.length ? `(${sp.join(', ')})` : "";
+ const name = `${o.type}${sig}`;
+ let bodyRow;
+ if (o.composite) {
+ bodyRow = ` |
`;
+ } else {
+ bodyRow = `| ${name} |
`;
+ }
+ let rows = "";
+ if (o.ins.length) rows += `${o.ins.map(cell("i", o.ins)).join("")}
`;
+ rows += bodyRow;
+ if (o.outs.length) rows += `${o.outs.map(cell("o", o.outs)).join("")}
`;
+ return `<>`;
+}
+function addInfo(INFO, id, o) {
+ const ps = o.params && o.params.length ? o.params.map(([n, k]) => `\`${n}:${k}\``).join(", ") : "none";
+ INFO.B[id] = `**${o.type}** · ${o.role || "node"}\nparams: ${ps}`;
+ (o.ins || []).forEach(([n, k, m], i) => INFO.P[`P_${id}_i${i}`] = `**${n}** · \`${k}\`` + (m && m.firing ? `\nfiring: \`${m.firing}\`` : ""));
+ (o.outs || []).forEach(([n, k], i) => INFO.P[`P_${id}_o${i}`] = `**${n}** · \`${k}\``);
+}
+
+// === recursive DOT generator (the mini-inliner) ==============================
+function genDot(def, role, expandedSet, showTypes) {
+ const INFO = { B: {}, P: {}, C: {} }, drill = {}, expandable = [], clusters = [], edges = [], topRank = [], bottomRank = [];
+ function build(path, def, brole) {
+ const childRes = {}; let block = "";
+ for (const key in def.nodes) {
+ const inst = def.nodes[key], cpath = [...path, key], cid = cpath.join("__");
+ if (inst.prim) {
+ const s = inst.prim;
+ block += `${cid} [label=${cellLabel(cid, { type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
+ addInfo(INFO, cid, s);
+ if (s.role === "source") topRank.push(cid); // sinks are placed freely (not rank-pinned)
+ childRes[key] = { type: "leaf", id: cid, spec: s };
+ } else {
+ const cname = inst.comp, cdef = COMP[cname];
+ if (expandedSet.has(cid)) {
+ const sub = build(cpath, cdef, "cluster"); clusters.push(cid);
+ INFO.C["clust_" + cid] = `**${cname}** · composite · click frame to collapse`;
+ block += `subgraph cluster_${cid} { id="clust_${cid}"; style=filled; color="#5b4b8a"; fillcolor="#211c30"; penwidth=2;\n${sub.block}}\n`;
+ childRes[key] = { type: "exp", resolveIn: sub.resolveIn, resolveOut: sub.resolveOut };
+ } else {
+ block += `${cid} [label=${cellLabel(cid, { type: cname, params: [], ins: cdef.inputs, outs: cdef.outputs, bodyBg: PURPLE, composite: true }, showTypes)}];\n`;
+ addInfo(INFO, cid, { type: cname, role: "composite", params: [], ins: cdef.inputs, outs: cdef.outputs });
+ drill[cid] = cname; expandable.push(cid);
+ childRes[key] = { type: "opaque", id: cid, cdef };
+ }
+ }
+ }
+ const pk = (r, io, idx) => r.type === "leaf" ? (io === "i" ? r.spec.ins : r.spec.outs)[idx][1] : (io === "i" ? r.cdef.inputs : r.cdef.outputs)[idx][1];
+ const resTarget = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveIn(idx) : [{ id: r.id, port: "i" + idx, kind: pk(r, "i", idx) }]; };
+ const resSource = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveOut(idx) : { id: r.id, port: "o" + idx, kind: pk(r, "o", idx) }; };
+ const resolveIn = (i) => { const rn = "@" + def.inputs[i][0]; return def.edges.filter(e => e[0] === rn).flatMap(e => resTarget(e[1])); };
+ const resolveOut = (i) => { const e = def.edges.find(e => e[1] === "#" + i); return resSource(e[0]); };
+ for (const [f, t] of def.edges) { if (f[0] === "@" || t[0] === "#") continue; const s = resSource(f); for (const d of resTarget(t)) edges.push([s, d]); }
+ if (brole === "drill") {
+ def.inputs.forEach(([rn, rk], i) => { const bid = `bin_${i}`;
+ block += `${bid} [label=${cellLabel(bid, { type: rn, params: [], ins: [], outs: [[rn, rk]], bodyBg: SOURCE, composite: false }, showTypes)}];\n`;
+ addInfo(INFO, bid, { type: rn, role: "input", params: [], ins: [], outs: [[rn, rk]] });
+ topRank.push(bid);
+ resolveIn(i).forEach(d => edges.push([{ id: bid, port: "o0", kind: rk }, d])); });
+ def.outputs.forEach(([on, ok], i) => { const bid = `bout_${i}`;
+ block += `${bid} [label=${cellLabel(bid, { type: on, params: [], ins: [[on, ok]], outs: [], bodyBg: SINK, composite: false }, showTypes)}];\n`;
+ addInfo(INFO, bid, { type: on, role: "output", params: [], ins: [[on, ok]], outs: [] });
+ bottomRank.push(bid);
+ edges.push([resolveOut(i), { id: bid, port: "i0", kind: ok }]); });
+ }
+ return { block, resolveIn, resolveOut };
+ }
+ const top = build([], def, role);
+ let dot = `digraph g { ${HEAD}\n${top.block}`;
+ // annotation by position: sources/inputs share the top rank, outputs the bottom rank.
+ const rankRow = (ids, r, chain) => ids.length
+ ? `{rank=${r};${ids.map(id => " " + id + ";").join("")}${chain && ids.length > 1 ? " " + ids.join(" -> ") + " [style=invis];" : ""}}\n`
+ : "";
+ dot += rankRow(topRank, "min", true);
+ dot += rankRow(bottomRank, "max", false);
+ for (const [s, d] of edges) dot += `${s.id}:${s.port}:s -> ${d.id}:${d.port}:n [color="${TYPE_COLOR[s.kind] || "#7f849c"}"];\n`;
+ return { dot: dot + "}", info: INFO, drill, expandable, clusters };
+}
+
+// === styled tooltip + interaction ============================================
+const tip = document.getElementById("tip");
+const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "$1").replace(/`(.+?)`/g, "$1").replace(/\n/g, "
");
+function showTip(text, x, y) {
+ tip.innerHTML = md(text); tip.style.display = "block";
+ const w = tip.offsetWidth, h = tip.offsetHeight;
+ let l = x + 14, t = y + 16;
+ if (l + w > innerWidth - 8) l = x - w - 14;
+ if (t + h > innerHeight - 8) t = y - h - 16;
+ tip.style.left = l + "px"; tip.style.top = t + "px";
+}
+const hideTip = () => { tip.style.display = "none"; };
+
+const stage = document.getElementById("stage"), crumbEl = document.getElementById("crumb");
+let viz, viewStack = [{ name: "root", def: ROOT }], expanded = new Set(), SHOW_TYPES = false, INFO = { B: {}, P: {}, C: {} };
+
+function chrome() {
+ crumbEl.innerHTML = "";
+ viewStack.forEach((v, i) => {
+ if (i) { const s = document.createElement("span"); s.className = "sep"; s.textContent = " › "; crumbEl.appendChild(s); }
+ const a = document.createElement("a"); a.textContent = v.name;
+ a.onclick = () => { viewStack = viewStack.slice(0, i + 1); expanded = new Set(); show(); };
+ crumbEl.appendChild(a);
+ });
+}
+
+function show() {
+ const v = viewStack[viewStack.length - 1];
+ const G = genDot(v.def, v.name === "root" ? "root" : "drill", expanded, SHOW_TYPES);
+ INFO = G.info;
+ stage.innerHTML = ""; hideTip();
+ const svg = viz.renderSVGElement(G.dot);
+ stage.appendChild(svg);
+ const pz = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, minZoom: 0.1, maxZoom: 12, dblClickZoomEnabled: false });
+ addEventListener("resize", () => { pz.resize(); pz.fit(); pz.center(); });
+ svg.querySelectorAll("title").forEach(t => t.remove());
+ svg.querySelectorAll("a").forEach(a => a.removeAttributeNS("http://www.w3.org/1999/xlink", "title"));
+
+ const idOf = (el) => { const a = el.closest && el.closest("a[id], g[id]"); return a ? a.id.replace(/^a_/, "") : ""; };
+ svg.addEventListener("mousemove", (e) => {
+ const id = idOf(e.target);
+ if (id.startsWith("P_") && INFO.P[id]) return showTip(INFO.P[id], e.clientX, e.clientY);
+ if (id.startsWith("B_")) { const k = id.slice(2); if (INFO.B[k]) return showTip(INFO.B[k], e.clientX, e.clientY); }
+ if (id.startsWith("plus_")) return showTip("**expand** inline", e.clientX, e.clientY);
+ if (id.startsWith("clust_") && INFO.C[id]) return showTip(INFO.C[id], e.clientX, e.clientY);
+ const eg = e.target.closest && e.target.closest("g.edge");
+ if (eg) { const p = eg.querySelector("path"); const c = p && p.getAttribute("stroke") ? p.getAttribute("stroke").toLowerCase() : ""; return showTip("`" + (COLOR_TYPE[c] || "?") + "`", e.clientX, e.clientY); }
+ hideTip();
+ });
+ svg.addEventListener("mouseleave", hideTip);
+ svg.addEventListener("click", (e) => { const a = e.target.closest && e.target.closest("a"); if (a) e.preventDefault(); });
+
+ const click = (id, fn) => { const el = svg.getElementById(id) || svg.getElementById("a_" + id); if (el) { el.style.cursor = "pointer"; el.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(); }); } };
+ for (const id in G.drill) click("B_" + id, () => { viewStack.push({ name: G.drill[id], def: COMP[G.drill[id]] }); expanded = new Set(); show(); });
+ for (const id of G.expandable) click("plus_" + id, () => { expanded.add(id); show(); });
+ for (const id of G.clusters) click("clust_" + id, () => { expanded.delete(id); show(); });
+ chrome();
+}
+
+Viz.instance().then(v => { viz = v; show(); }).catch(e => { document.getElementById("err").textContent = String(e && e.stack || e); });
+```
+
+- [ ] **Step 5: Sanity-check the viewer asset for the load-bearing markers**
+
+Run:
+```sh
+grep -c "Viz.instance" crates/aura-cli/assets/graph-viewer.js
+grep -c "normalizeModel(window.AURA_MODEL)" crates/aura-cli/assets/graph-viewer.js
+grep -F 'i64: "#f9e2af"' crates/aura-cli/assets/graph-viewer.js | head -1
+```
+Expected: first two print `1`; the third prints the palette line (C4 four-colour
+table present). These substrings are asserted by the Task 2 / Task 3 tests, so
+they must be present contiguously.
+
+---
+
+## Task 2: `render_html` module + smoke test (coexists with graph.rs)
+
+`render.rs` is a new module that compiles **alongside** the still-present
+`graph.rs` — no retirement yet, so the workspace stays green. `render_html` is
+read-only (C9): it calls `aura_engine::model_to_json` (a pure `&Composite ->
+String` walk) and string-concatenates the inlined assets. No `eval`, no build, no
+`serde`.
+
+**Files:**
+- Create: `crates/aura-cli/src/render.rs`
+- Modify: `crates/aura-cli/src/main.rs:9` (add `mod render;`)
+- Test: `crates/aura-cli/src/render.rs` (the `#[cfg(test)]` smoke test)
+
+- [ ] **Step 1: Create `crates/aura-cli/src/render.rs`**
+
+Create `crates/aura-cli/src/render.rs`:
+```rust
+//! Self-contained HTML graph viewer assembly (C9: read-only render path).
+//!
+//! `render_html` walks the blueprint into the deterministic JSON model
+//! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the
+//! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The
+//! emitted page fetches nothing at view time. No `eval`/build is on this path
+//! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`).
+
+use aura_core::Composite;
+
+/// The prototype's `` shell + body skeleton (header, stage, tooltip, error
+/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines
+/// 1-38 — minus the two `\n\n\n\n\n\n");
+ html
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::sample_blueprint;
+
+ /// `aura graph`'s page is self-contained (no remote fetch), embeds the
+ /// deterministic model as the viewer's data source, carries the Graphviz-WASM
+ /// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel:
+ /// the DOT/SVG are Graphviz-version-dependent and are exercised by hand
+ /// against the prototype (spec Testing strategy).
+ #[test]
+ fn render_html_is_self_contained_and_embeds_the_model() {
+ let html = render_html(&sample_blueprint());
+ // a complete HTML document
+ assert!(html.starts_with(""), "not an HTML doc");
+ assert!(html.trim_end().ends_with("