diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd0f230..855acc6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,8 +9,8 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
diff --git a/.prettierignore b/.prettierignore
index f9c3594..5ef7a31 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,4 @@ website
package-lock.json
*.min.js
icons
+vendor
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 724b09b..b993319 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ This project follows [Semantic Versioning](https://semver.org/) and groups chang
by theme. Dates are when the release landed on `main` — 1.1.0 through 1.6.0 shipped
the same day, as a rapid burst of improvements, so they share a date.
+## [3.1.0] — 2026-06-16 · _Interactive Canvas (Blueprint · Guided Tour · Corkboard · Stack Studio)_
+
+### Added
+
+- **Canvas tab (Blueprint).** A new **Canvas** tab in the Lenses group turns any repo's Deep Dive into an interactive, zoomable, pannable map of the repo's atoms — modules, subsystems, and their lineage — colour-coded by kind with a live node legend. Drag nodes to rearrange; positions persist across sessions.
+- **Guided Tour.** Spotlights the architecture node-by-node in dependency order with plain-English narration drawn from the Deep Dive. Navigate with **Back / Next**, auto-play, or keyboard **← → Esc**; fully reduced-motion safe.
+- **Export to `.excalidraw` and SVG.** Download the canvas as an `.excalidraw` file (opens hand-drawn in excalidraw.com, Obsidian, or VS Code) or as a clean SVG for docs and slides.
+- **Persistent arrangements.** Node positions and canvas state are stored in a new `scenes` IndexedDB store and round-trip through the library backup/export envelope, so layouts travel with your library.
+- **Corkboard (Library-wide canvas).** A toggle in the Library page switches your whole collection into a red-string board: every scanned repo is a draggable manila card, and related repos are joined by colored string keyed to relationship type (alternatives, synergies, head-to-heads, combined ideas) and shaded by fit score. Filter by Collection to focus a board, and the arrangement is saved so it's exactly where you left it next session. Reuses the same canvas engine as Blueprint — zero new dependencies, theme-aware, reduced-motion safe.
+- **Stack Studio (canvas view of a tech-stack).** The Tech-Stack Builder result gains a **View on canvas** toggle: the repos you wired together render as layer-coloured cards in adoption order, joined by their integrations, with any gaps shown as dashed cards — the same engine, turning "how these fit together" into a living diagram.
+- **Zero-build, zero dependencies.** Plain ES modules only — no bundler, no new npm packages. Theme-aware across all 13 themes and reduced-motion safe throughout.
+
## [3.0.1] — 2026-06-15 · _Audit hardening_
A focused correctness, security, and tooling pass from a full code audit — no
diff --git a/README.md b/README.md
index 784dd8b..66301b0 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ A scan opens to a **verdict landing** and fans out into focused tabs:
| 🔍 | **Discover** | Search GitHub from inside the extension, or get **recommendations** from the repos you've already adopted. |
| 🕸️ | **Connections** | A walkable map centred on the current repo, showing how it relates to the others you've scanned. |
| 🤝 | **Synergies** · **Versus** · **Combinator** | Complements, head-to-heads, and fused project ideas — grounded in *your* library. |
+| 🗺️ | **Canvas** | Turn a repo's Deep Dive into an interactive, draggable **Blueprint** — pan/zoom the architecture map, take a narrated **Guided Tour** in dependency order (keyboard-navigable, reduced-motion safe), and export to **.excalidraw** or SVG. Switch the Library into a **Corkboard** to map your whole collection at once: every scanned repo a draggable card, related repos joined by colored string (alternatives, synergies, head-to-heads, combined ideas), colored by fit, filterable by Collection, arrangement saved. And the Tech-Stack Builder renders its wiring on the same canvas as a **Stack Studio**. |
Plus **SKTPG** (a one-tap State / Known-pitfalls / Trajectory / Proof / Growth read), framework lenses, and capability re-tagging.
diff --git a/backup.js b/backup.js
index fe2990c..353a24a 100644
--- a/backup.js
+++ b/backup.js
@@ -13,7 +13,7 @@ export const BACKUP_VERSION = 2;
// Upper bounds on how much a single import may write, so a hostile or corrupt
// file can't pin the IndexedDB write lock or blow the storage quota. Anything
// past these is dropped with a surfaced warning (never silently).
-export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000 };
+export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000, scenes: 2000 };
// Per-repo snapshot ring-buffer cap (mirrors SNAPSHOT_CAP in snapshots.js); each
// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
@@ -27,10 +27,11 @@ const cacheOk = (c) => !!(c && c.repoId && c.platform);
const collectionOk = (c) => !!(c && c.id != null && c.payload && typeof c.payload.name === 'string');
const decisionOk = (d) => !!(d && d.id != null && d.payload && d.payload.repoId && d.payload.decision);
const snapshotOk = (r) => !!(r && r.id != null && r.repoId && Array.isArray(r.snaps));
+const sceneOk = (s) => !!(s && s.id && s.scope && Array.isArray(s.nodes) && Array.isArray(s.edges));
/** Empty normalized shape — the safe fallback when a file can't be parsed. */
function emptyValue() {
- return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [] };
+ return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [], scenes: [] };
}
/**
@@ -39,14 +40,14 @@ function emptyValue() {
* @param {{ repos?: object[], nodes?: object[], edges?: object[], cache?: object[], exportedAt?: string }} [parts]
* @returns {object}
*/
-export function buildBackup({ repos, nodes, edges, cache, collections, decisions, snapshots, exportedAt } = {}) {
- const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions), snap = arr(snapshots);
+export function buildBackup({ repos, nodes, edges, cache, collections, decisions, snapshots, scenes, exportedAt } = {}) {
+ const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions), snap = arr(snapshots), sc = arr(scenes);
return {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: exportedAt || new Date().toISOString(),
- counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length, snapshots: snap.length },
- repos: r, nodes: n, edges: e, cache: c, collections: col, decisions: dec, snapshots: snap,
+ counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length, snapshots: snap.length, scenes: sc.length },
+ repos: r, nodes: n, edges: e, cache: c, collections: col, decisions: dec, snapshots: snap, scenes: sc,
};
}
@@ -88,6 +89,7 @@ export function validateBackup(obj) {
collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({ ...r, snaps: arr(r.snaps).slice(-SNAP_CAP) }))),
+ scenes: clamp('scenes', arr(obj.scenes).filter(sceneOk)),
};
return { ok: errors.length === 0, errors, warnings, value };
}
@@ -100,7 +102,7 @@ export function validateBackup(obj) {
*/
export function summarizeBackup(obj) {
const { value } = validateBackup(obj);
- return { repos: value.repos.length, nodes: value.nodes.length, edges: value.edges.length, cache: value.cache.length, collections: value.collections.length, decisions: value.decisions.length, snapshots: value.snapshots.length };
+ return { repos: value.repos.length, nodes: value.nodes.length, edges: value.edges.length, cache: value.cache.length, collections: value.collections.length, decisions: value.decisions.length, snapshots: value.snapshots.length, scenes: value.scenes.length };
}
/**
diff --git a/blueprint-adapter.js b/blueprint-adapter.js
new file mode 100644
index 0000000..c43d111
--- /dev/null
+++ b/blueprint-adapter.js
@@ -0,0 +1,40 @@
+// blueprint-adapter.js
+// Deep Dive atoms/lineage → a laid-out Blueprint scene.
+
+import { createScene } from './scene.js';
+import { repairGraph } from './repair-graph.js';
+import { layoutBlueprint } from './canvas-layout.js';
+
+/**
+ * @param {object} args
+ * @param {{atoms:any[], lineage:{links:any[], roots?:string[], leaves?:string[]}}} args.deepDive
+ * @param {string} args.repoId
+ * @param {string} args.title
+ * @param {string|null} [args.scanAt]
+ * @param {(atom:object)=>string} [args.layerOf] defaults to atom.kind
+ * @param {boolean} [args.withIssues] when true, returns { scene, issues }
+ * @returns {object|{scene:object, issues:object[]}}
+ */
+export function buildBlueprintScene({ deepDive, repoId, title, scanAt = null, layerOf = (a) => a.kind, withIssues = false }) {
+ const atoms = (deepDive && deepDive.atoms) || [];
+ const links = (deepDive && deepDive.lineage && deepDive.lineage.links) || [];
+ const roots = new Set((deepDive && deepDive.lineage && deepDive.lineage.roots) || []);
+
+ const layerByAtomId = Object.fromEntries(atoms.map((a) => [a.id, layerOf(a) ?? null]));
+ const { nodes, edges, issues } = repairGraph({
+ nodes: atoms.map((a) => ({ ...a, layer: layerByAtomId[a.id] })),
+ edges: links,
+ });
+
+ // mark lineage roots (load-bearing) so the engine can highlight them (immutably)
+ const marked = nodes.map((n) => ({ ...n, ref: { ...(n.ref || {}), root: roots.has(n.id) } }));
+
+ const placed = layoutBlueprint(marked, edges);
+
+ const scene = createScene({ scope: 'blueprint', repoId, title });
+ scene.nodes = placed;
+ scene.edges = edges;
+ scene.source.scanAt = scanAt;
+
+ return withIssues ? { scene, issues } : scene;
+}
diff --git a/canvas-demo.html b/canvas-demo.html
new file mode 100644
index 0000000..ef21f80
--- /dev/null
+++ b/canvas-demo.html
@@ -0,0 +1,70 @@
+
+
+
+
+ Canvas demo — RepoLens Blueprint
+
+
+
+
+
🔭 RepoLens — Blueprint canvas (live demo)
+
Real pipeline: buildBlueprintScene → layoutBlueprint → mountCanvas → buildTour/startTour. No extension, no API.
+
+
+ output-tab · evanw/esbuild
+
+
+
+
+
+
+
+
+
diff --git a/canvas-engine.js b/canvas-engine.js
new file mode 100644
index 0000000..9f513e6
--- /dev/null
+++ b/canvas-engine.js
@@ -0,0 +1,160 @@
+// canvas-engine.js
+// Vanilla, dependency-free interactive SVG canvas. Pointer Events only.
+// Layout is pure+memoized (positions live in the scene); selection/spotlight is an overlay pass.
+
+const SVGNS = 'http://www.w3.org/2000/svg';
+export const NODE_W = 132, NODE_H = 44;
+// Auto-width card bounds: each card fits its label, clamped to [MIN_W, MAX_W];
+// labels past MAX_W are ellipsised (full text kept in the tooltip).
+const MIN_W = 96, MAX_W = 210, PAD_X = 14, CHAR_W = 7.8;
+const el = (name, attrs = {}) => { const e = document.createElementNS(SVGNS, name); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };
+
+/**
+ * Pure: cubic-bezier from the source node's right-middle to the target's left-middle.
+ * Uses the source node's rendered width (`_w`, set by the engine when it auto-sizes a
+ * card) so the edge still meets the card edge when a long label widens it; falls back
+ * to NODE_W when `_w` is absent.
+ * @param {{ x:number, y:number, _w?:number }} a
+ * @param {{ x:number, y:number }} b
+ * @returns {string}
+ */
+export function edgeBezier(a, b) {
+ const sx = a.x + ((a && a._w) || NODE_W), sy = a.y + NODE_H / 2;
+ const tx = b.x, ty = b.y + NODE_H / 2, mx = (sx + tx) / 2;
+ return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
+}
+
+/** Pure: the class string for a node element (kind + optional root/fit/layer). */
+export function nodeClass(n) {
+ let c = `rl-node rl-kind-${n.kind}`;
+ if (n.ref && n.ref.root) c += ' is-root';
+ if (n.ref && n.ref.fit) c += ` rl-fit-${n.ref.fit}`;
+ if (n.layer) c += ` rl-layer-${n.layer}`;
+ return c;
+}
+
+/**
+ * Mount an interactive canvas into `host`.
+ * @returns {{ moveNode, setSpotlight, clearSpotlight, getScene, destroy }}
+ */
+export function mountCanvas(host, inputScene, { onChange } = {}) {
+ const scene = structuredClone(inputScene);
+ // Leading-edge debounce: the first change saves immediately (so a direct
+ // api.moveNode(...) is observable synchronously), then a 250ms cooldown
+ // coalesces the high-frequency pointer paths into a single trailing save.
+ let saveTimer = null, pendingDuringCooldown = false;
+ const fire = () => { if (onChange) onChange(structuredClone(scene)); };
+ const persist = () => {
+ if (!onChange) return;
+ if (saveTimer) { pendingDuringCooldown = true; return; }
+ fire();
+ saveTimer = setTimeout(() => {
+ saveTimer = null;
+ if (pendingDuringCooldown) { pendingDuringCooldown = false; persist(); }
+ }, 250);
+ };
+
+ host.innerHTML = '';
+ const svg = el('svg', { class: 'rl-canvas', width: '100%', height: '100%' });
+ const root = el('g', { class: 'rl-camera' });
+ const edgeLayer = el('g', { class: 'rl-edges' });
+ const nodeLayer = el('g', { class: 'rl-nodes' });
+ root.append(edgeLayer, nodeLayer);
+ svg.append(root);
+ host.append(svg);
+
+ const cam = scene.camera || (scene.camera = { x: 0, y: 0, zoom: 1 });
+ const applyCamera = () => root.setAttribute('transform', `translate(${cam.x},${cam.y}) scale(${cam.zoom})`);
+
+ const nodeEls = new Map();
+ const edgeEls = new Map();
+
+ const byId = (id) => scene.nodes.find((n) => n.id === id);
+ function edgePath(e) {
+ return (byId(e.from) && byId(e.to)) ? edgeBezier(byId(e.from), byId(e.to)) : '';
+ }
+
+ // Measure a candidate string in the live text node; fall back to a monospace
+ // estimate when getComputedTextLength is unavailable (hidden host / jsdom).
+ function widthOf(textEl, str) {
+ textEl.textContent = str;
+ const m = textEl.getComputedTextLength ? textEl.getComputedTextLength() : 0;
+ return m > 0 ? m : str.length * CHAR_W;
+ }
+ // Fit a card to its label: clamp width to [MIN_W, MAX_W]; truncate with an ellipsis
+ // past MAX_W (the full label lives in the node's ). Records n._w for edges.
+ function sizeNode(n, rect, text) {
+ const maxTextW = MAX_W - PAD_X * 2;
+ let tw = widthOf(text, n.label);
+ if (tw > maxTextW) {
+ let s = n.label;
+ while (s.length > 1 && widthOf(text, s + '…') > maxTextW) s = s.slice(0, -1);
+ text.textContent = s.replace(/\s+$/, '') + '…';
+ tw = widthOf(text, text.textContent);
+ }
+ const w = Math.max(MIN_W, Math.min(MAX_W, Math.round(tw + PAD_X * 2)));
+ rect.setAttribute('width', w);
+ text.setAttribute('x', w / 2);
+ n._w = w;
+ }
+
+ for (const e of scene.edges) {
+ const p = el('path', { class: `rl-edge rl-${e.rel}`, d: edgePath(e), fill: 'none' });
+ p.dataset.edge = e.id;
+ edgeLayer.append(p); edgeEls.set(e.id, p);
+ }
+ for (const n of scene.nodes) {
+ const g = el('g', { class: nodeClass(n), transform: `translate(${n.x},${n.y})`, tabindex: '0' });
+ g.dataset.node = n.id;
+ const rect = el('rect', { width: NODE_W, height: NODE_H, rx: 8 });
+ const text = el('text', { y: NODE_H / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central' });
+ text.textContent = n.label;
+ const title = el('title'); title.textContent = n.label; // full label on hover (survives truncation)
+ g.append(rect, text, title);
+ nodeLayer.append(g); nodeEls.set(n.id, g);
+ sizeNode(n, rect, text); // fit the card to its label
+ wireDrag(g, n);
+ }
+ applyCamera();
+
+ function wireDrag(g, n) {
+ let startX, startY, ox, oy, dragging = false;
+ g.addEventListener('pointerdown', (ev) => {
+ dragging = true; g.setPointerCapture?.(ev.pointerId);
+ startX = ev.clientX; startY = ev.clientY; ox = n.x; oy = n.y; ev.stopPropagation();
+ });
+ g.addEventListener('pointermove', (ev) => {
+ if (!dragging) return;
+ moveNode(n.id, ox + (ev.clientX - startX) / cam.zoom, oy + (ev.clientY - startY) / cam.zoom);
+ });
+ g.addEventListener('pointerup', (ev) => { if (dragging) { dragging = false; g.releasePointerCapture?.(ev.pointerId); persist(); } });
+ }
+
+ let panning = false, px, py, pcx, pcy;
+ svg.addEventListener('pointerdown', (ev) => { if (ev.target === svg || ev.target === root) { panning = true; px = ev.clientX; py = ev.clientY; pcx = cam.x; pcy = cam.y; } });
+ svg.addEventListener('pointermove', (ev) => { if (panning) { cam.x = pcx + (ev.clientX - px); cam.y = pcy + (ev.clientY - py); applyCamera(); } });
+ svg.addEventListener('pointerup', () => { if (panning) { panning = false; persist(); } });
+ svg.addEventListener('wheel', (ev) => {
+ ev.preventDefault();
+ const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1;
+ cam.zoom = Math.max(0.2, Math.min(3, cam.zoom * factor));
+ applyCamera(); persist();
+ }, { passive: false });
+
+ function moveNode(id, x, y) {
+ const n = byId(id); if (!n) return;
+ n.x = x; n.y = y;
+ const g = nodeEls.get(id); if (g) g.setAttribute('transform', `translate(${x},${y})`);
+ for (const e of scene.edges) if (e.from === id || e.to === id) { const p = edgeEls.get(e.id); if (p) p.setAttribute('d', edgePath(e)); }
+ persist();
+ }
+ function setSpotlight(ids) {
+ const set = new Set(ids);
+ for (const [id, g] of nodeEls) { g.classList.toggle('is-spotlight', set.has(id)); g.classList.toggle('is-dim', !set.has(id)); }
+ }
+ function clearSpotlight() { for (const [, g] of nodeEls) g.classList.remove('is-spotlight', 'is-dim'); }
+ function getScene() { return structuredClone(scene); }
+ function destroy() { clearTimeout(saveTimer); host.innerHTML = ''; }
+
+ return { moveNode, setSpotlight, clearSpotlight, getScene, destroy };
+}
diff --git a/canvas-export.js b/canvas-export.js
new file mode 100644
index 0000000..d5f3b1a
--- /dev/null
+++ b/canvas-export.js
@@ -0,0 +1,98 @@
+// canvas-export.js
+// Pure serializers: scene → standalone SVG, and scene → Excalidraw document JSON.
+
+import { escapeHtml as esc } from './safe-html.js';
+
+const NW = 132, NH = 44;
+const seedFrom = (s) => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff; return Math.abs(h) || 1; };
+// Coerce any coordinate to a finite number before it reaches an SVG attribute or
+// Excalidraw field. Guards against non-numeric values (e.g. strings from untrusted
+// JSON) breaking out of an attribute context. Non-finite → 0.
+const num = (v) => (Number.isFinite(+v) ? +v : 0);
+
+/** Standalone, themeable SVG snapshot of the scene. */
+export function toCanvasSvg(scene) {
+ const nodes = scene.nodes || [];
+ const edges = scene.edges || [];
+ const ann = scene.annotations || [];
+ const pos = Object.fromEntries(nodes.map((n) => [n.id, n]));
+ const minX = Math.min(0, ...nodes.map((n) => num(n.x))) - 20;
+ const minY = Math.min(0, ...nodes.map((n) => num(n.y))) - 20;
+ const maxX = Math.max(...nodes.map((n) => num(n.x) + NW), 200) + 20;
+ const maxY = Math.max(...nodes.map((n) => num(n.y) + NH), 200) + 60;
+
+ const edgeSvg = edges.map((e) => {
+ const a = pos[e.from], b = pos[e.to];
+ if (!a || !b) return '';
+ const x1 = num(a.x) + NW, y1 = num(a.y) + NH / 2, x2 = num(b.x), y2 = num(b.y) + NH / 2, mx = (x1 + x2) / 2;
+ return ``;
+ }).join('');
+
+ const nodeSvg = nodes.map((n) => {
+ const x = num(n.x), y = num(n.y);
+ return `` +
+ `${esc(n.label)}`;
+ }).join('');
+
+ const annSvg = ann.map((a) => {
+ const x = num(a.x), y = num(a.y);
+ return `` +
+ `${esc(a.text)}`;
+ }).join('');
+
+ return ``;
+}
+
+/** Scene → Excalidraw document (opens in excalidraw.com, Obsidian, VS Code). */
+export function toExcalidraw(scene) {
+ const elements = [];
+ const base = (id, extra) => ({
+ id, x: 0, y: 0, width: 0, height: 0, angle: 0, strokeColor: '#1e1a14', backgroundColor: 'transparent',
+ fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100,
+ groupIds: [], frameId: null, roundness: { type: 3 }, seed: seedFrom(id), versionNonce: seedFrom('n' + id),
+ version: 1, isDeleted: false, boundElements: [], updated: 1, link: null, locked: false, ...extra,
+ });
+
+ for (const n of scene.nodes || []) {
+ const rid = `rect-${n.id}`, tid = `txt-${n.id}`;
+ const x = num(n.x), y = num(n.y);
+ elements.push(base(rid, {
+ type: 'rectangle', x, y, width: 132, height: 44,
+ backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6',
+ boundElements: [{ type: 'text', id: tid }],
+ }));
+ elements.push(base(tid, {
+ type: 'text', x: x + 8, y: y + 14, width: 116, height: 20, text: String(n.label),
+ fontSize: 16, fontFamily: 1, textAlign: 'center', verticalAlign: 'middle', containerId: rid,
+ originalText: String(n.label), lineHeight: 1.25,
+ }));
+ }
+
+ const pos = Object.fromEntries((scene.nodes || []).map((n) => [n.id, n]));
+ for (const e of scene.edges || []) {
+ const a = pos[e.from], b = pos[e.to];
+ if (!a || !b) continue;
+ const x1 = num(a.x) + 132, y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22;
+ elements.push(base(`arrow-${e.id}`, {
+ type: 'arrow', x: x1, y: y1, width: x2 - x1, height: y2 - y1,
+ points: [[0, 0], [x2 - x1, y2 - y1]],
+ startBinding: { elementId: `rect-${e.from}`, focus: 0, gap: 4 },
+ endBinding: { elementId: `rect-${e.to}`, focus: 0, gap: 4 },
+ strokeColor: e.rel === 'triggers' ? '#3b6ea5' : e.rel === 'enables' ? '#2f7d34' : '#1e1a14',
+ }));
+ }
+
+ for (const a of scene.annotations || []) {
+ const x = num(a.x), y = num(a.y);
+ elements.push(base(`note-${a.id}`, {
+ type: 'text', x, y, width: 150, height: 40, text: String(a.text),
+ fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
+ originalText: String(a.text), lineHeight: 1.25, strokeColor: a.tone === 'warn' ? '#8a480f' : '#1e1a14',
+ }));
+ }
+
+ return JSON.stringify({
+ type: 'excalidraw', version: 2, source: 'https://github.com/RepoLens',
+ elements, appState: { gridSize: null, viewBackgroundColor: '#fbf6ea' }, files: {},
+ }, null, 2);
+}
diff --git a/canvas-layout.js b/canvas-layout.js
new file mode 100644
index 0000000..57affa6
--- /dev/null
+++ b/canvas-layout.js
@@ -0,0 +1,73 @@
+// canvas-layout.js
+// Pure seed-layout for the Blueprint scope. Left→right layered DAG.
+// Ports diagram.js's cycle-safe depth relaxation; emits {x,y} not SVG.
+
+const COL_W = 220, ROW_H = 110, PAD = 40;
+
+/**
+ * @param {object[]} nodes scene nodes (mutated copies returned, inputs untouched)
+ * @param {object[]} edges scene edges
+ * @returns {object[]} new node array with x/y assigned (pinned nodes keep theirs)
+ */
+export function layoutBlueprint(nodes, edges) {
+ const ids = nodes.map((n) => n.id);
+ const idset = new Set(ids);
+ const valid = edges.filter((e) => idset.has(e.from) && idset.has(e.to));
+
+ // depth = longest path from a root; bounded relaxation (cycle-safe)
+ const depth = Object.fromEntries(ids.map((id) => [id, 0]));
+ for (let i = 0; i < ids.length; i++) {
+ let changed = false;
+ for (const e of valid) if (depth[e.to] < depth[e.from] + 1) { depth[e.to] = depth[e.from] + 1; changed = true; }
+ if (!changed) break;
+ }
+
+ const cols = {};
+ ids.forEach((id) => { (cols[depth[id]] ||= []).push(id); });
+
+ const pos = {};
+ Object.keys(cols).forEach((d) => {
+ const col = cols[d];
+ col.forEach((id, i) => { pos[id] = { x: PAD + Number(d) * COL_W, y: PAD + i * ROW_H }; });
+ });
+
+ return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y }));
+}
+
+const CARD_W = 150, CARD_H = 64, GAP_X = 60, GAP_Y = 44, ORIGIN = 40;
+
+/** Simple seed layout for the corkboard: union-find components, grid-place ordered by
+ * (component, id) so related repos start adjacent. Pinned nodes keep their position. Pure. */
+export function layoutCorkboard(nodes, edges) {
+ const parent = Object.fromEntries(nodes.map((n) => [n.id, n.id]));
+ const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
+ const union = (a, b) => { if (parent[a] === undefined || parent[b] === undefined) return; parent[find(a)] = find(b); };
+ for (const e of edges) union(e.from, e.to);
+
+ const ordered = nodes.slice().sort((p, q) => {
+ const rp = find(p.id), rq = find(q.id);
+ return rp < rq ? -1 : rp > rq ? 1 : (p.id < q.id ? -1 : p.id > q.id ? 1 : 0);
+ });
+
+ const cols = Math.max(1, Math.ceil(Math.sqrt(ordered.length)));
+ const pos = {};
+ ordered.forEach((n, i) => {
+ const r = Math.floor(i / cols), c = i % cols;
+ pos[n.id] = { x: ORIGIN + c * (CARD_W + GAP_X), y: ORIGIN + r * (CARD_H + GAP_Y) };
+ });
+
+ return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y }));
+}
+
+/** Stack layout: repos left→right by adoption `order`, gap cards in a row below. Pinned kept. Pure. */
+export function layoutStack(nodes, order = []) {
+ const rank = Object.fromEntries((order || []).map((id, i) => [String(id), i]));
+ const repos = nodes.filter((n) => n.kind !== 'gap');
+ const gaps = nodes.filter((n) => n.kind === 'gap');
+ const sorted = repos.slice().sort((a, b) =>
+ ((rank[a.id] ?? 999) - (rank[b.id] ?? 999)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ const pos = {};
+ sorted.forEach((n, i) => { pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN }; });
+ gaps.forEach((n, i) => { pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN + 2 * (CARD_H + GAP_Y) }; });
+ return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y }));
+}
diff --git a/corkboard-demo.html b/corkboard-demo.html
new file mode 100644
index 0000000..f0bb6a6
--- /dev/null
+++ b/corkboard-demo.html
@@ -0,0 +1,68 @@
+
+
+
+
+ Corkboard demo — RepoLens
+
+
+
+
+
🧭 RepoLens — Corkboard (live demo)
+
Real pipeline: getLibraryGraph-shape → buildLibraryScene → layoutCorkboard → mountCanvas. Edges reference hashed node-ids and are joined back to repos.
+
+
+ alternative
+ synergy
+ head-to-head
+ combined idea
+ · card border = fit (strong / solid / care / risky)
+
+
+
+
+
diff --git a/docs/superpowers/plans/2026-06-15-interactive-canvas.md b/docs/superpowers/plans/2026-06-15-interactive-canvas.md
new file mode 100644
index 0000000..614dfa4
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-15-interactive-canvas.md
@@ -0,0 +1,1570 @@
+# Interactive Canvas — Phase 1 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a zero-build interactive canvas that turns a repo's Deep Dive into a draggable, annotatable, exportable **Blueprint** with a narrated **Guided Tour**.
+
+**Architecture:** One pure scene model + a vanilla Pointer-Events SVG engine. *Layout is pure and memoised; visual state (selection/tour/hover) is a separate overlay pass that never relays out.* Data seeds from Deep Dive `atoms`/`lineage`; scenes persist in a new IndexedDB store (v4→v5) and serialise to `.excalidraw`/SVG.
+
+**Tech Stack:** Vanilla ES modules (no bundler, no deps), IndexedDB via `store/idb.js`, SVG, Vitest (jsdom), `safe-html.js` for escaping. Manifest V3 / existing CSP.
+
+**Spec:** `docs/superpowers/specs/2026-06-15-interactive-canvas-design.md`
+
+**Branch:** `feat/canvas-engine` (already created off `main`; spec already committed).
+
+---
+
+## Shared type shapes (referenced by every task)
+
+```js
+// node: { id, label, kind, layer, x, y, pinned, ref }
+// edge: { id, from, to, rel, note, userDrawn }
+// annotation: { id, x, y, text, tone } // tone: 'note' | 'warn'
+// scene: { id, scope, repoId, title, nodes, edges, annotations,
+// camera:{x,y,zoom}, tour, source, createdAt, updatedAt }
+// tourStep: { order, nodeIds, title, blurb, lesson? }
+// graphIssue: { level:'auto-corrected'|'dropped', code, message }
+```
+
+`KNOWN_KINDS = ['subsystem','module','concept','entrypoint','data']`
+`KNOWN_RELS = ['depends-on','enables','triggers','derives-from']`
+User-drawn corkboard edges later add `'string'`; not used in Phase 1.
+
+## File structure (Phase 1)
+
+| File | Responsibility | New/Modify |
+|---|---|---|
+| `scene.js` | Scene factory, `hashId`, immutable helpers, `validateScene` | New |
+| `repair-graph.js` | Normalize messy LLM nodes/edges → `{nodes,edges,issues}` | New |
+| `canvas-layout.js` | `layoutBlueprint` (depth DAG, ports `diagram.js` math) | New |
+| `blueprint-adapter.js` | Deep Dive `atoms`/`lineage` → seeded scene | New |
+| `tour.js` | `buildTour` — fan-in/out, BFS order, clusters → steps | New |
+| `canvas-export.js` | `toCanvasSvg`, `toExcalidraw` (pure strings) | New |
+| `canvas-engine.js` | Interactive SVG surface (pan/zoom/drag/connect/note + overlay) | New |
+| `tour-runner.js` | Camera + narration overlay through tour steps | New |
+| `store/idb.js` | Add `'scenes'` store, bump `DB_VERSION` 4→5 | Modify |
+| `store.js` | `saveScene/getScene/listScenes/deleteScene` + export/import | Modify |
+| `backup.js` | `scenes` in envelope build/validate + `MAX_ROWS` | Modify |
+| `settings-backup.js` | Allowlist `canvasEnabled`, `canvasTourAutoplay` | Modify |
+| `output-tab.html` | Canvas tab button + `#t27` host + CSS | Modify |
+| `output-tab.js` | `TAB_SLUGS[27]='canvas'` + `renderCanvas(d)` | Modify |
+| `themes.css` | `--canvas-*` tokens + node/edge/tour classes | Modify |
+| `tests/*.test.js` | One per pure module + engine integration | New |
+
+**Engine ownership rule:** pure modules (`scene`, `repair-graph`, `canvas-layout`, `blueprint-adapter`, `tour`, `canvas-export`) never mutate inputs. `canvas-engine` deep-clones the scene on mount, owns that working copy, mutates it on interaction, and persists via a debounced `onChange`. This satisfies immutability at module boundaries without per-frame copies.
+
+---
+
+### Task 1: Scene model (`scene.js`)
+
+**Files:**
+- Create: `scene.js`
+- Test: `tests/scene.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/scene.test.js
+import { describe, it, expect } from 'vitest';
+import { hashId, createScene, withNodePos, validateScene } from '../scene.js';
+
+describe('hashId', () => {
+ it('is deterministic and positive', () => {
+ expect(hashId('core')).toBe(hashId('core'));
+ expect(hashId('core')).toBeGreaterThan(0);
+ });
+ it('differs for different input', () => {
+ expect(hashId('a')).not.toBe(hashId('b'));
+ });
+});
+
+describe('createScene', () => {
+ it('builds a blueprint scene with defaults', () => {
+ const s = createScene({ scope: 'blueprint', repoId: 'evanw/esbuild', title: 'esbuild' });
+ expect(s.id).toBe('repo:' + hashId('evanw/esbuild'));
+ expect(s.scope).toBe('blueprint');
+ expect(s.nodes).toEqual([]);
+ expect(s.edges).toEqual([]);
+ expect(s.annotations).toEqual([]);
+ expect(s.camera).toEqual({ x: 0, y: 0, zoom: 1 });
+ expect(s.tour).toBeNull();
+ expect(typeof s.createdAt).toBe('string');
+ });
+});
+
+describe('withNodePos', () => {
+ it('returns a new scene with one node moved, input untouched', () => {
+ const s = createScene({ scope: 'blueprint', repoId: 'r', title: 't' });
+ s.nodes = [{ id: 'a', label: 'A', kind: 'module', layer: null, x: 0, y: 0, pinned: false, ref: null }];
+ const next = withNodePos(s, 'a', 10, 20);
+ expect(next.nodes[0]).toMatchObject({ x: 10, y: 20 });
+ expect(s.nodes[0]).toMatchObject({ x: 0, y: 0 }); // input not mutated
+ expect(next).not.toBe(s);
+ });
+});
+
+describe('validateScene', () => {
+ it('flags edges referencing unknown nodes', () => {
+ const s = createScene({ scope: 'blueprint', repoId: 'r', title: 't' });
+ s.nodes = [{ id: 'a', label: 'A', kind: 'module', layer: null, x: 0, y: 0, pinned: false, ref: null }];
+ s.edges = [{ id: 'e', from: 'a', to: 'ghost', rel: 'depends-on', note: null, userDrawn: false }];
+ const r = validateScene(s);
+ expect(r.ok).toBe(false);
+ expect(r.errors[0]).toMatch(/unknown node/);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/scene.test.js`
+Expected: FAIL — `Failed to resolve import "../scene.js"`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```js
+// scene.js
+// Pure scene model for the interactive canvas. No DOM, no network.
+
+/** djb2 string hash → positive integer. Deterministic; mirrors store.hashRepoId. */
+export function hashId(str) {
+ let h = 5381;
+ const s = String(str);
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff;
+ return Math.abs(h) || 1;
+}
+
+const nowIso = () => new Date().toISOString();
+
+/** Build an empty scene for a scope. id derives from scope + repoId. */
+export function createScene({ scope, repoId = null, title = '' }) {
+ const id =
+ scope === 'corkboard' ? 'library'
+ : scope === 'stack' ? 'stack:' + hashId(repoId || title)
+ : 'repo:' + hashId(repoId || title);
+ const ts = nowIso();
+ return {
+ id, scope, repoId, title,
+ nodes: [], edges: [], annotations: [],
+ camera: { x: 0, y: 0, zoom: 1 },
+ tour: null,
+ source: { lens: 'deepDive', generatedAt: ts, scanAt: null },
+ createdAt: ts, updatedAt: ts,
+ };
+}
+
+/** Immutable: return a copy of `scene` with node `id` moved to (x,y). */
+export function withNodePos(scene, id, x, y) {
+ return {
+ ...scene,
+ nodes: scene.nodes.map((n) => (n.id === id ? { ...n, x, y } : n)),
+ updatedAt: nowIso(),
+ };
+}
+
+/** Validate referential integrity. Returns { ok, errors }. */
+export function validateScene(scene) {
+ const errors = [];
+ if (!scene || typeof scene !== 'object') return { ok: false, errors: ['not an object'] };
+ const ids = new Set((scene.nodes || []).map((n) => n.id));
+ for (const e of scene.edges || []) {
+ if (!ids.has(e.from) || !ids.has(e.to)) errors.push(`edge ${e.id} references unknown node`);
+ }
+ return { ok: errors.length === 0, errors };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run tests/scene.test.js`
+Expected: PASS (4 files / all assertions green).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scene.js tests/scene.test.js
+git commit -m "feat(canvas): pure scene model (hashId, createScene, withNodePos, validateScene)"
+```
+
+---
+
+### Task 2: Graph repair (`repair-graph.js`)
+
+**Files:**
+- Create: `repair-graph.js`
+- Test: `tests/repair-graph.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/repair-graph.test.js
+import { describe, it, expect } from 'vitest';
+import { repairGraph } from '../repair-graph.js';
+
+describe('repairGraph', () => {
+ it('drops edges whose endpoints are missing', () => {
+ const raw = {
+ nodes: [{ id: 'a', name: 'A' }],
+ edges: [{ from: 'a', to: 'ghost', relation: 'depends-on' }],
+ };
+ const { edges, issues } = repairGraph(raw);
+ expect(edges).toHaveLength(0);
+ expect(issues.some((i) => i.level === 'dropped' && /dangling/.test(i.code))).toBe(true);
+ });
+
+ it('coerces kind and relation aliases to the known set', () => {
+ const raw = {
+ nodes: [{ id: 'a', name: 'A', kind: 'FUNCTION' }, { id: 'b', name: 'B', kind: 'service' }],
+ edges: [{ from: 'a', to: 'b', relation: 'imports' }],
+ };
+ const { nodes, edges } = repairGraph(raw);
+ expect(nodes[0].kind).toBe('module'); // FUNCTION → module
+ expect(nodes[1].kind).toBe('subsystem'); // service → subsystem
+ expect(edges[0].rel).toBe('depends-on'); // imports → depends-on
+ });
+
+ it('dedupes node ids and drops nodes missing an id', () => {
+ const raw = {
+ nodes: [{ id: 'a', name: 'A' }, { id: 'a', name: 'A2' }, { name: 'noid' }],
+ edges: [],
+ };
+ const { nodes, issues } = repairGraph(raw);
+ expect(nodes).toHaveLength(1);
+ expect(issues.some((i) => /dedupe/.test(i.code))).toBe(true);
+ expect(issues.some((i) => /missing-id/.test(i.code))).toBe(true);
+ });
+
+ it('fills missing label/kind defaults', () => {
+ const { nodes } = repairGraph({ nodes: [{ id: 'a' }], edges: [] });
+ expect(nodes[0].label).toBe('a');
+ expect(nodes[0].kind).toBe('module');
+ });
+
+ it('throws in strict mode on a dropped issue', () => {
+ expect(() => repairGraph({ nodes: [], edges: [{ from: 'x', to: 'y' }] }, { strict: true })).toThrow();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/repair-graph.test.js`
+Expected: FAIL — cannot resolve `../repair-graph.js`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```js
+// repair-graph.js
+// Normalize messy LLM-produced graph data into a valid {nodes, edges} set.
+// Never throws unless { strict:true }. Inspired by Understand-Anything's tiered model.
+
+const KNOWN_KINDS = new Set(['subsystem', 'module', 'concept', 'entrypoint', 'data']);
+const KIND_ALIASES = {
+ function: 'module', fn: 'module', method: 'module', file: 'module', class: 'module',
+ service: 'subsystem', package: 'subsystem', pkg: 'subsystem', mod: 'subsystem',
+ config: 'data', table: 'data', schema: 'data', endpoint: 'data',
+ entry: 'entrypoint', main: 'entrypoint', idea: 'concept',
+};
+const KNOWN_RELS = new Set(['depends-on', 'enables', 'triggers', 'derives-from']);
+const REL_ALIASES = {
+ depends_on: 'depends-on', dependson: 'depends-on', imports: 'depends-on', uses: 'depends-on',
+ requires: 'depends-on', calls: 'triggers', invokes: 'triggers', publishes: 'triggers',
+ extends: 'derives-from', inherits: 'derives-from', implements: 'derives-from', enables: 'enables',
+};
+
+const coerceKind = (k) => {
+ const v = String(k || '').trim().toLowerCase();
+ if (KNOWN_KINDS.has(v)) return v;
+ return KIND_ALIASES[v] || 'module';
+};
+const coerceRel = (r) => {
+ const v = String(r || '').trim().toLowerCase().replace(/\s+/g, '-');
+ if (KNOWN_RELS.has(v)) return v;
+ return REL_ALIASES[v] || 'depends-on';
+};
+
+/**
+ * @param {{nodes?:any[], edges?:any[]}} raw
+ * @param {{strict?:boolean}} [opts]
+ * @returns {{nodes:object[], edges:object[], issues:object[]}}
+ */
+export function repairGraph(raw, opts = {}) {
+ const issues = [];
+ const add = (level, code, message) => {
+ issues.push({ level, code, message });
+ if (opts.strict && level === 'dropped') throw new Error(`repairGraph strict: ${code} — ${message}`);
+ };
+
+ const seen = new Set();
+ const nodes = [];
+ for (const n of (raw && raw.nodes) || []) {
+ if (!n || n.id == null || n.id === '') { add('dropped', 'missing-id', 'node without id'); continue; }
+ const id = String(n.id);
+ if (seen.has(id)) { add('auto-corrected', 'dedupe', `duplicate node id ${id}`); continue; }
+ seen.add(id);
+ const kind = coerceKind(n.kind);
+ if (n.kind && coerceKind(n.kind) !== String(n.kind).toLowerCase())
+ add('auto-corrected', 'kind-alias', `kind "${n.kind}" → ${kind}`);
+ nodes.push({
+ id,
+ label: String(n.name ?? n.label ?? id),
+ kind,
+ layer: n.layer != null ? String(n.layer) : null,
+ x: Number.isFinite(n.x) ? n.x : 0,
+ y: Number.isFinite(n.y) ? n.y : 0,
+ pinned: !!n.pinned,
+ ref: { purpose: n.purpose ?? null, files: Array.isArray(n.files) ? n.files : [] },
+ });
+ }
+
+ const ids = new Set(nodes.map((n) => n.id));
+ const edges = [];
+ const edgeSeen = new Set();
+ for (const e of (raw && raw.edges) || []) {
+ const from = String((e && (e.from ?? e.source)) ?? '');
+ const to = String((e && (e.to ?? e.target)) ?? '');
+ if (!ids.has(from) || !ids.has(to)) { add('dropped', 'dangling-edge', `edge ${from}→${to} has a missing endpoint`); continue; }
+ const rel = coerceRel(e.rel ?? e.relation ?? e.type);
+ const key = `${from}|${rel}|${to}`;
+ if (edgeSeen.has(key)) continue;
+ edgeSeen.add(key);
+ edges.push({ id: `e${hash(key)}`, from, to, rel, note: e.note ?? null, userDrawn: !!e.userDrawn });
+ }
+
+ return { nodes, edges, issues };
+}
+
+function hash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff; return Math.abs(h) || 1; }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run tests/repair-graph.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add repair-graph.js tests/repair-graph.test.js
+git commit -m "feat(canvas): repairGraph — tame messy LLM node/edge data"
+```
+
+---
+
+### Task 3: Blueprint layout (`canvas-layout.js`)
+
+**Files:**
+- Create: `canvas-layout.js`
+- Test: `tests/canvas-layout.test.js`
+
+Reuses the cycle-safe depth relaxation from `diagram.js:20-40`, but emits `{x,y}` per node instead of SVG, and leaves `pinned` nodes where they are.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/canvas-layout.test.js
+import { describe, it, expect } from 'vitest';
+import { layoutBlueprint } from '../canvas-layout.js';
+
+const N = (id) => ({ id, label: id, kind: 'module', layer: null, x: 0, y: 0, pinned: false, ref: null });
+
+describe('layoutBlueprint', () => {
+ it('places roots left of their dependents (increasing x by depth)', () => {
+ const nodes = [N('cli'), N('core'), N('out')];
+ const edges = [
+ { id: 'e1', from: 'cli', to: 'core', rel: 'depends-on', note: null, userDrawn: false },
+ { id: 'e2', from: 'core', to: 'out', rel: 'triggers', note: null, userDrawn: false },
+ ];
+ const placed = layoutBlueprint(nodes, edges);
+ const by = Object.fromEntries(placed.map((n) => [n.id, n]));
+ expect(by.cli.x).toBeLessThan(by.core.x);
+ expect(by.core.x).toBeLessThan(by.out.x);
+ });
+
+ it('does not move pinned nodes', () => {
+ const nodes = [{ ...N('a'), x: 999, y: 888, pinned: true }, N('b')];
+ const edges = [{ id: 'e', from: 'a', to: 'b', rel: 'depends-on', note: null, userDrawn: false }];
+ const placed = layoutBlueprint(nodes, edges);
+ const a = placed.find((n) => n.id === 'a');
+ expect(a).toMatchObject({ x: 999, y: 888 });
+ });
+
+ it('handles cycles without infinite loop', () => {
+ const nodes = [N('a'), N('b')];
+ const edges = [
+ { id: 'e1', from: 'a', to: 'b', rel: 'depends-on', note: null, userDrawn: false },
+ { id: 'e2', from: 'b', to: 'a', rel: 'depends-on', note: null, userDrawn: false },
+ ];
+ expect(() => layoutBlueprint(nodes, edges)).not.toThrow();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/canvas-layout.test.js`
+Expected: FAIL — cannot resolve `../canvas-layout.js`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```js
+// canvas-layout.js
+// Pure seed-layout for the Blueprint scope. Left→right layered DAG.
+// Ports diagram.js's cycle-safe depth relaxation; emits {x,y} not SVG.
+
+const COL_W = 220, ROW_H = 110, PAD = 40;
+
+/**
+ * @param {object[]} nodes scene nodes (mutated copies returned, inputs untouched)
+ * @param {object[]} edges scene edges
+ * @returns {object[]} new node array with x/y assigned (pinned nodes keep theirs)
+ */
+export function layoutBlueprint(nodes, edges) {
+ const ids = nodes.map((n) => n.id);
+ const idset = new Set(ids);
+ const valid = edges.filter((e) => idset.has(e.from) && idset.has(e.to));
+
+ // depth = longest path from a root; bounded relaxation (cycle-safe)
+ const depth = Object.fromEntries(ids.map((id) => [id, 0]));
+ for (let i = 0; i < ids.length; i++) {
+ let changed = false;
+ for (const e of valid) if (depth[e.to] < depth[e.from] + 1) { depth[e.to] = depth[e.from] + 1; changed = true; }
+ if (!changed) break;
+ }
+
+ const cols = {};
+ ids.forEach((id) => { (cols[depth[id]] ||= []).push(id); });
+
+ const pos = {};
+ Object.keys(cols).forEach((d) => {
+ const col = cols[d];
+ col.forEach((id, i) => { pos[id] = { x: PAD + Number(d) * COL_W, y: PAD + i * ROW_H }; });
+ });
+
+ return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y }));
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run tests/canvas-layout.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add canvas-layout.js tests/canvas-layout.test.js
+git commit -m "feat(canvas): layoutBlueprint — cycle-safe layered DAG positions"
+```
+
+---
+
+### Task 4: Blueprint adapter (`blueprint-adapter.js`)
+
+**Files:**
+- Create: `blueprint-adapter.js`
+- Test: `tests/blueprint-adapter.test.js`
+
+Turns Deep Dive output into a laid-out, persisted-ready scene. `layerOf` is injected (defaults to kind) so it's testable without `taxonomy.js`.
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/blueprint-adapter.test.js
+import { describe, it, expect } from 'vitest';
+import { buildBlueprintScene } from '../blueprint-adapter.js';
+
+const deepDive = {
+ atoms: [
+ { id: 'cli', name: 'CLI', kind: 'entrypoint', purpose: 'parses argv', files: ['cli.js'] },
+ { id: 'core', name: 'Core', kind: 'subsystem', purpose: 'the engine', files: ['core.js'] },
+ ],
+ lineage: { links: [{ from: 'cli', to: 'core', relation: 'depends-on' }], roots: ['cli'], leaves: ['core'] },
+};
+
+describe('buildBlueprintScene', () => {
+ it('produces a blueprint scene with placed nodes and an edge', () => {
+ const s = buildBlueprintScene({ deepDive, repoId: 'evanw/esbuild', title: 'esbuild', scanAt: '2026-06-15T00:00:00Z' });
+ expect(s.scope).toBe('blueprint');
+ expect(s.nodes).toHaveLength(2);
+ expect(s.edges).toHaveLength(1);
+ expect(s.nodes.find((n) => n.id === 'cli').x).toBeLessThan(s.nodes.find((n) => n.id === 'core').x);
+ expect(s.source.scanAt).toBe('2026-06-15T00:00:00Z');
+ });
+
+ it('uses layerOf when provided', () => {
+ const s = buildBlueprintScene({ deepDive, repoId: 'r', title: 't', layerOf: (a) => 'L:' + a.kind });
+ expect(s.nodes[0].layer).toBe('L:entrypoint');
+ });
+
+ it('returns repair issues alongside the scene', () => {
+ const dd = { atoms: [{ id: 'a', name: 'A' }], lineage: { links: [{ from: 'a', to: 'ghost' }] } };
+ const { scene, issues } = buildBlueprintScene({ deepDive: dd, repoId: 'r', title: 't', withIssues: true });
+ expect(scene.edges).toHaveLength(0);
+ expect(issues.length).toBeGreaterThan(0);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/blueprint-adapter.test.js`
+Expected: FAIL — cannot resolve module.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```js
+// blueprint-adapter.js
+// Deep Dive atoms/lineage → a laid-out Blueprint scene.
+
+import { createScene } from './scene.js';
+import { repairGraph } from './repair-graph.js';
+import { layoutBlueprint } from './canvas-layout.js';
+
+/**
+ * @param {object} args
+ * @param {{atoms:any[], lineage:{links:any[], roots?:string[], leaves?:string[]}}} args.deepDive
+ * @param {string} args.repoId
+ * @param {string} args.title
+ * @param {string|null} [args.scanAt]
+ * @param {(atom:object)=>string} [args.layerOf] defaults to atom.kind
+ * @param {boolean} [args.withIssues] when true, returns { scene, issues }
+ * @returns {object|{scene:object, issues:object[]}}
+ */
+export function buildBlueprintScene({ deepDive, repoId, title, scanAt = null, layerOf = (a) => a.kind, withIssues = false }) {
+ const atoms = (deepDive && deepDive.atoms) || [];
+ const links = (deepDive && deepDive.lineage && deepDive.lineage.links) || [];
+ const roots = new Set((deepDive && deepDive.lineage && deepDive.lineage.roots) || []);
+
+ const layerByAtomId = Object.fromEntries(atoms.map((a) => [a.id, layerOf(a) ?? null]));
+ const { nodes, edges, issues } = repairGraph({
+ nodes: atoms.map((a) => ({ ...a, layer: layerByAtomId[a.id] })),
+ edges: links,
+ });
+
+ // mark lineage roots (load-bearing) so the engine can highlight them
+ for (const n of nodes) n.ref = { ...(n.ref || {}), root: roots.has(n.id) };
+
+ const placed = layoutBlueprint(nodes, edges);
+
+ const scene = createScene({ scope: 'blueprint', repoId, title });
+ scene.nodes = placed;
+ scene.edges = edges;
+ scene.source.scanAt = scanAt;
+
+ return withIssues ? { scene, issues } : scene;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run tests/blueprint-adapter.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add blueprint-adapter.js tests/blueprint-adapter.test.js
+git commit -m "feat(canvas): blueprint-adapter — Deep Dive atoms/lineage → scene"
+```
+
+---
+
+### Task 5: Guided Tour computation (`tour.js`)
+
+**Files:**
+- Create: `tour.js`
+- Test: `tests/tour.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/tour.test.js
+import { describe, it, expect } from 'vitest';
+import { buildTour } from '../tour.js';
+
+const scene = {
+ nodes: [
+ { id: 'cli', label: 'CLI', kind: 'entrypoint', ref: { purpose: 'entry', root: true } },
+ { id: 'core', label: 'Core', kind: 'subsystem', ref: { purpose: 'the engine' } },
+ { id: 'out', label: 'Output', kind: 'module', ref: { purpose: 'writes files' } },
+ ],
+ edges: [
+ { id: 'e1', from: 'cli', to: 'core', rel: 'depends-on' },
+ { id: 'e2', from: 'core', to: 'out', rel: 'triggers' },
+ ],
+};
+
+describe('buildTour', () => {
+ it('returns ordered steps starting at a root, 1..N with no gaps', () => {
+ const steps = buildTour(scene, { roots: ['cli'] });
+ expect(steps.length).toBeGreaterThanOrEqual(1);
+ expect(steps.map((s) => s.order)).toEqual(steps.map((_, i) => i + 1));
+ expect(steps[0].nodeIds).toContain('cli');
+ });
+ it('never emits empty nodeIds and uses purpose as blurb', () => {
+ const steps = buildTour(scene, { roots: ['cli'] });
+ for (const s of steps) expect(s.nodeIds.length).toBeGreaterThan(0);
+ const core = steps.find((s) => s.nodeIds.includes('core'));
+ expect(core.blurb).toMatch(/engine/);
+ });
+ it('falls back to highest fan-in when no roots given', () => {
+ const steps = buildTour(scene, {});
+ expect(steps.length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run tests/tour.test.js`
+Expected: FAIL — cannot resolve `../tour.js`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```js
+// tour.js
+// Compute a guided tour (5–15 steps) from a scene's topology. Pure, deterministic.
+
+const MAX_STEPS = 15;
+
+/**
+ * @param {{nodes:object[], edges:object[]}} scene
+ * @param {{roots?:string[]}} [hints]
+ * @returns {Array<{order:number,nodeIds:string[],title:string,blurb:string,lesson?:string}>}
+ */
+export function buildTour(scene, hints = {}) {
+ const nodes = scene.nodes || [];
+ const edges = scene.edges || [];
+ if (!nodes.length) return [];
+ const byId = Object.fromEntries(nodes.map((n) => [n.id, n]));
+
+ // fan-in / fan-out
+ const fanIn = Object.fromEntries(nodes.map((n) => [n.id, 0]));
+ const fanOut = Object.fromEntries(nodes.map((n) => [n.id, 0]));
+ const adj = Object.fromEntries(nodes.map((n) => [n.id, []]));
+ for (const e of edges) {
+ if (byId[e.from]) { fanOut[e.from]++; adj[e.from].push(e.to); }
+ if (byId[e.to]) fanIn[e.to]++;
+ }
+
+ // start: provided root with highest fan-out, else node with lowest fan-in / highest fan-out
+ const roots = (hints.roots || []).filter((id) => byId[id]);
+ let start = roots.sort((a, b) => fanOut[b] - fanOut[a])[0];
+ if (!start) start = nodes.slice().sort((a, b) => (fanIn[a.id] - fanIn[b.id]) || (fanOut[b.id] - fanOut[a.id]))[0].id;
+
+ // BFS reading order from start
+ const order = [];
+ const seen = new Set();
+ const q = [start];
+ seen.add(start);
+ while (q.length) {
+ const id = q.shift();
+ order.push(id);
+ for (const nb of adj[id] || []) if (!seen.has(nb)) { seen.add(nb); q.push(nb); }
+ }
+ // include any unreached nodes by fan-in importance
+ for (const n of nodes.slice().sort((a, b) => fanIn[b.id] - fanIn[a.id]))
+ if (!seen.has(n.id)) { seen.add(n.id); order.push(n.id); }
+
+ const picked = order.slice(0, MAX_STEPS);
+ return picked.map((id, i) => {
+ const n = byId[id];
+ return {
+ order: i + 1,
+ nodeIds: [id],
+ title: n.label,
+ blurb: (n.ref && n.ref.purpose) ? String(n.ref.purpose) : `${n.label} (${n.kind}).`,
+ };
+ });
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run tests/tour.test.js`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tour.js tests/tour.test.js
+git commit -m "feat(canvas): buildTour — dependency-ordered guided tour steps"
+```
+
+---
+
+### Task 6: Exporters (`canvas-export.js`)
+
+**Files:**
+- Create: `canvas-export.js`
+- Test: `tests/canvas-export.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/canvas-export.test.js
+import { describe, it, expect } from 'vitest';
+import { toCanvasSvg, toExcalidraw } from '../canvas-export.js';
+
+const scene = {
+ id: 'repo:1', scope: 'blueprint', title: 'esbuild',
+ nodes: [
+ { id: 'cli', label: 'CLI', kind: 'entrypoint', layer: null, x: 40, y: 40, pinned: false, ref: {} },
+ { id: 'core', label: 'Core', kind: 'subsystem', layer: null, x: 300, y: 120, pinned: false, ref: {} },
+ ],
+ edges: [{ id: 'e1', from: 'cli', to: 'core', rel: 'depends-on', note: null, userDrawn: false }],
+ annotations: [{ id: 'a1', x: 60, y: 220, text: 'check this ', tone: 'warn' }],
+ camera: { x: 0, y: 0, zoom: 1 },
+};
+
+describe('toCanvasSvg', () => {
+ it('emits an