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: buildBlueprintScenelayoutBlueprintmountCanvasbuildTour/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 <title>). 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 `<path class="ce-edge ce-${esc(e.rel)}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none"/>`; + }).join(''); + + const nodeSvg = nodes.map((n) => { + const x = num(n.x), y = num(n.y); + return `<g class="ce-node ce-kind-${esc(n.kind)}"><rect x="${x}" y="${y}" width="${NW}" height="${NH}" rx="8"/>` + + `<text x="${x + NW / 2}" y="${y + NH / 2}" text-anchor="middle" dominant-baseline="central">${esc(n.label)}</text></g>`; + }).join(''); + + const annSvg = ann.map((a) => { + const x = num(a.x), y = num(a.y); + return `<g class="ce-note ce-${esc(a.tone)}"><rect x="${x}" y="${y}" width="150" height="48" rx="4"/>` + + `<text x="${x + 8}" y="${y + 20}">${esc(a.text)}</text></g>`; + }).join(''); + + return `<svg class="canvas-export" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" xmlns="http://www.w3.org/2000/svg">${edgeSvg}${nodeSvg}${annSvg}</svg>`; +} + +/** 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <title>Corkboard demo — RepoLens + + + + +

🧭 RepoLens — Corkboard (live demo)

+

Real pipeline: getLibraryGraph-shape → buildLibraryScenelayoutCorkboardmountCanvas. 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 with a node label and escaped annotation text', () => { + const svg = toCanvasSvg(scene); + expect(svg.startsWith(''); + }); +}); + +describe('toExcalidraw', () => { + it('emits valid excalidraw JSON with rectangles, bound text, and an arrow', () => { + const doc = JSON.parse(toExcalidraw(scene)); + expect(doc.type).toBe('excalidraw'); + expect(doc.version).toBe(2); + const types = doc.elements.map((e) => e.type); + expect(types).toContain('rectangle'); + expect(types).toContain('text'); + expect(types).toContain('arrow'); + // arrow binds to existing element ids + const arrow = doc.elements.find((e) => e.type === 'arrow'); + const ids = new Set(doc.elements.map((e) => e.id)); + expect(ids.has(arrow.startBinding.elementId)).toBe(true); + expect(ids.has(arrow.endBinding.elementId)).toBe(true); + }); + it('is deterministic for the same scene', () => { + expect(toExcalidraw(scene)).toBe(toExcalidraw(scene)); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/canvas-export.test.js` +Expected: FAIL — cannot resolve module. + +- [ ] **Step 3: Write minimal implementation** + +```js +// 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; }; + +/** 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) => n.x)) - 20; + const minY = Math.min(0, ...nodes.map((n) => n.y)) - 20; + const maxX = Math.max(...nodes.map((n) => n.x + NW), 200) + 20; + const maxY = Math.max(...nodes.map((n) => 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 = a.x + NW, y1 = a.y + NH / 2, x2 = b.x, y2 = b.y + NH / 2, mx = (x1 + x2) / 2; + return ``; + }).join(''); + + const nodeSvg = nodes.map((n) => + `` + + `${esc(n.label)}` + ).join(''); + + const annSvg = ann.map((a) => + `` + + `${esc(a.text)}` + ).join(''); + + return `${edgeSvg}${nodeSvg}${annSvg}`; +} + +/** 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}`; + elements.push(base(rid, { + type: 'rectangle', x: n.x, y: n.y, width: 132, height: 44, + backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6', + boundElements: [{ type: 'text', id: tid }], + })); + elements.push(base(tid, { + type: 'text', x: n.x + 8, y: n.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 = a.x + 132, y1 = a.y + 22, x2 = b.x, y2 = 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 || []) { + elements.push(base(`note-${a.id}`, { + type: 'text', x: a.x, y: a.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); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/canvas-export.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add canvas-export.js tests/canvas-export.test.js +git commit -m "feat(canvas): toCanvasSvg + toExcalidraw exporters" +``` + +--- + +### Task 7: Add the `scenes` IndexedDB store (`store/idb.js`) + +**Files:** +- Modify: `store/idb.js` (the `STORES` array + `DB_VERSION`) + +- [ ] **Step 1: Read the current store list** + +Run: `grep -n "DB_VERSION\|STORES =" store/idb.js` +Expected: shows `DB_VERSION = 4` and `STORES = ['repos','nodes','edges','collections','decisions','snapshots']`. + +- [ ] **Step 2: Bump version and append the store** + +Edit `store/idb.js`: change `DB_VERSION = 4` → `DB_VERSION = 5`, and append `'scenes'` to the `STORES` array so it reads: + +```js +const DB_VERSION = 5; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes']; +``` + +(The existing `onupgradeneeded` loop creates any missing store with `{ keyPath: 'id' }` — additive, no data migration.) + +- [ ] **Step 3: Verify the module still parses** + +Run: `node --check store/idb.js` +Expected: no output (exit 0). + +- [ ] **Step 4: Commit** + +```bash +git add store/idb.js +git commit -m "feat(canvas): add 'scenes' object store (idb v4->v5, additive)" +``` + +--- + +### Task 8: Scene persistence APIs (`store.js`) + +**Files:** +- Modify: `store.js` (add scene functions; include `scenes` in `exportStores`/`importStores`) +- Test: `tests/store-scenes.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/store-scenes.test.js +// store.js talks to IndexedDB; this test uses fake-indexeddb (already a dev dep used by store tests). +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { saveScene, getScene, listScenes, deleteScene } from '../store.js'; + +const mk = (id, repoId) => ({ id, scope: 'blueprint', repoId, title: id, nodes: [], edges: [], annotations: [], camera: { x: 0, y: 0, zoom: 1 }, tour: null, source: {}, createdAt: 'x', updatedAt: 'x' }); + +describe('scene persistence', () => { + it('saves and reads a scene by id', async () => { + await saveScene(mk('repo:1', 'a/b')); + const got = await getScene('repo:1'); + expect(got.title).toBe('repo:1'); + }); + it('lists scenes filtered by repoId', async () => { + await saveScene(mk('repo:2', 'x/y')); + await saveScene(mk('repo:3', 'x/y')); + const list = await listScenes('x/y'); + expect(list.map((s) => s.id).sort()).toEqual(['repo:2', 'repo:3']); + }); + it('deletes a scene', async () => { + await saveScene(mk('repo:4', 'q/r')); + await deleteScene('repo:4'); + expect(await getScene('repo:4')).toBeNull(); + }); +}); +``` + +> If the repo's existing store tests use a different IndexedDB shim, mirror their import. Check with `grep -rl "fake-indexeddb\|indexedDB" tests | head`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/store-scenes.test.js` +Expected: FAIL — `saveScene` is not exported. + +- [ ] **Step 3: Add the APIs** + +In `store.js`, near the snapshot helpers, add (use the file's existing `idbPut`/`idbGet`/`idbGetAll` helpers — confirm names with `grep -n "function idb" store.js`): + +```js +// --- Canvas scenes --- +export async function saveScene(scene) { + if (!scene || !scene.id) throw new Error('saveScene: scene.id required'); + await idbPut('scenes', scene); +} +export async function getScene(id) { + return (await idbGet('scenes', String(id))) || null; +} +export async function listScenes(repoId) { + const all = (await idbGetAll('scenes')) || []; + return repoId == null ? all : all.filter((s) => s.repoId === repoId); +} +export async function deleteScene(id) { + await idbDelete('scenes', String(id)); +} +``` + +Then add `scenes` to the bulk export/import. In `exportStores()` add `const scenes = await idbGetAll('scenes');` and include `scenes: scenes || []` in the returned object. In `importStores(rows, ...)`, handle a `scenes` array the same way `snapshots` is handled (iterate, `idbPut('scenes', row)`). + +> If `idbDelete` isn't the helper name, use whatever delete wrapper exists (`grep -n "delete" store/idb.js store.js`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/store-scenes.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add store.js tests/store-scenes.test.js +git commit -m "feat(canvas): saveScene/getScene/listScenes/deleteScene + bulk export/import" +``` + +--- + +### Task 9: Scenes in the backup envelope (`backup.js`) + +**Files:** +- Modify: `backup.js` +- Test: `tests/backup-scenes.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/backup-scenes.test.js +import { describe, it, expect } from 'vitest'; +import { buildBackup, validateBackup } from '../backup.js'; + +const scene = { id: 'repo:1', scope: 'blueprint', repoId: 'a/b', nodes: [], edges: [], annotations: [] }; + +describe('backup with scenes', () => { + it('includes scenes in the envelope and count', () => { + const env = buildBackup({ repos: [], scenes: [scene] }); + expect(env.scenes).toHaveLength(1); + expect(env.counts.scenes).toBe(1); + }); + it('keeps valid scenes and drops malformed rows on validate', () => { + const env = buildBackup({ scenes: [scene, { nope: true }] }); + const r = validateBackup(env); + expect(r.ok).toBe(true); + expect(r.value.scenes).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/backup-scenes.test.js` +Expected: FAIL — `env.scenes` is undefined. + +- [ ] **Step 3: Implement** + +In `backup.js`: +1. Add `scenes` to `MAX_ROWS` (match the snapshots cap, e.g. `scenes: 2000`). +2. In `buildBackup({ ..., scenes } = {})`: `const sc = arr(scenes);` add `scenes: sc` to the returned object and `scenes: sc.length` to `counts`. +3. Add a validator `const sceneOk = (s) => !!(s && s.id && s.scope && Array.isArray(s.nodes) && Array.isArray(s.edges));` +4. In `validateBackup`, clamp+filter: `scenes: clamp('scenes', arr(obj.scenes).filter(sceneOk))` (mirror the `snapshots` line). + +(Match the exact helper names already in `backup.js` — `arr`, `clamp` per the Scan Ledger work.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/backup-scenes.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backup.js tests/backup-scenes.test.js +git commit -m "feat(canvas): round-trip scenes through the backup envelope" +``` + +--- + +### Task 10: Settings allowlist (`settings-backup.js`) + +**Files:** +- Modify: `settings-backup.js` + +- [ ] **Step 1: Add the keys** + +Append `'canvasEnabled'` and `'canvasTourAutoplay'` to the `SAFE_SETTING_KEYS` array (no secrets; these are booleans). + +- [ ] **Step 2: Verify parse** + +Run: `node --check settings-backup.js` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add settings-backup.js +git commit -m "feat(canvas): allowlist canvasEnabled/canvasTourAutoplay in settings backup" +``` + +--- + +### Task 11: The canvas engine (`canvas-engine.js`) + +**Files:** +- Create: `canvas-engine.js` +- Test: `tests/canvas-engine.test.js` (jsdom) + +This is the largest task. The engine deep-clones the scene, renders SVG, handles pan/zoom/drag/connect/note, and exposes an **overlay** API that toggles classes without relayout. Vitest runs jsdom (`vitest.config.js` — confirm `environment: 'jsdom'`; if not present, add `// @vitest-environment jsdom` atop the test). + +- [ ] **Step 1: Write the failing test** + +```js +// tests/canvas-engine.test.js +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { mountCanvas } from '../canvas-engine.js'; + +const scene = () => ({ + id: 'repo:1', scope: 'blueprint', title: 't', + nodes: [ + { id: 'a', label: 'A', kind: 'module', layer: null, x: 0, y: 0, pinned: false, ref: {} }, + { id: 'b', label: 'B', kind: 'module', layer: null, x: 200, y: 0, pinned: false, ref: {} }, + ], + edges: [{ id: 'e1', from: 'a', to: 'b', rel: 'depends-on', note: null, userDrawn: false }], + annotations: [], camera: { x: 0, y: 0, zoom: 1 }, tour: null, source: {}, +}); + +describe('mountCanvas', () => { + let host; + beforeEach(() => { host = document.createElement('div'); document.body.appendChild(host); }); + + it('renders one per node and one path per edge', () => { + mountCanvas(host, scene(), {}); + expect(host.querySelectorAll('[data-node]').length).toBe(2); + expect(host.querySelectorAll('[data-edge]').length).toBe(1); + }); + + it('moveNode persists via onChange and updates the node transform without re-creating nodes', () => { + let saved = null; + const api = mountCanvas(host, scene(), { onChange: (s) => { saved = s; } }); + const before = host.querySelector('[data-node="a"]'); + api.moveNode('a', 50, 60); + const after = host.querySelector('[data-node="a"]'); + expect(after).toBe(before); // same element, not re-created (overlay/position, no relayout) + expect(after.getAttribute('transform')).toContain('50'); + expect(saved.nodes.find((n) => n.id === 'a')).toMatchObject({ x: 50, y: 60 }); + }); + + it('setSpotlight toggles classes without changing node count', () => { + const api = mountCanvas(host, scene(), {}); + api.setSpotlight(['a']); + expect(host.querySelector('[data-node="a"]').classList.contains('is-spotlight')).toBe(true); + expect(host.querySelector('[data-node="b"]').classList.contains('is-dim')).toBe(true); + expect(host.querySelectorAll('[data-node]').length).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/canvas-engine.test.js` +Expected: FAIL — cannot resolve `../canvas-engine.js`. + +- [ ] **Step 3: Implement the engine** + +```js +// 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. + +import { escapeHtml as esc } from './safe-html.js'; + +const SVGNS = 'http://www.w3.org/2000/svg'; +const NW = 132, NH = 44; +const el = (name, attrs = {}) => { const e = document.createElementNS(SVGNS, name); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }; + +/** + * Mount an interactive canvas into `host`. + * @returns {{ moveNode, setSpotlight, clearSpotlight, getScene, destroy }} + */ +export function mountCanvas(host, inputScene, { onChange } = {}) { + const scene = structuredClone(inputScene); // engine owns its copy + let saveTimer = null; + const persist = () => { if (!onChange) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => onChange(structuredClone(scene)), 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(); // id -> + const edgeEls = new Map(); // id -> + + function nodeAnchor(n, side) { return side === 'out' ? { x: n.x + NW, y: n.y + NH / 2 } : { x: n.x, y: n.y + NH / 2 }; } + function edgePath(e) { + const a = byId(e.from), b = byId(e.to); if (!a || !b) return ''; + const s = nodeAnchor(a, 'out'), t = nodeAnchor(b, 'in'), mx = (s.x + t.x) / 2; + return `M${s.x},${s.y} C${mx},${s.y} ${mx},${t.y} ${t.x},${t.y}`; + } + const byId = (id) => scene.nodes.find((n) => n.id === id); + + // --- initial render (the only place nodes/edges are created) --- + 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: `rl-node rl-kind-${n.kind}`, transform: `translate(${n.x},${n.y})`, tabindex: '0' }); + g.dataset.node = n.id; + if (n.ref && n.ref.root) g.classList.add('is-root'); + const rect = el('rect', { width: NW, height: NH, rx: 8 }); + const text = el('text', { x: NW / 2, y: NH / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central' }); + text.textContent = n.label; // textContent is safe (no HTML parse) + g.append(rect, text); + nodeLayer.append(g); nodeEls.set(n.id, g); + wireDrag(g, n); + } + applyCamera(); + + // --- node drag (pointer events) --- + 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(); } }); + } + + // --- camera pan (drag empty space) + zoom (wheel) --- + 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 }); + + // --- public API --- + 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 }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/canvas-engine.test.js` +Expected: PASS. (If jsdom lacks `structuredClone`, it's available in Node ≥17 globally; the repo's Node satisfies this.) + +- [ ] **Step 5: Commit** + +```bash +git add canvas-engine.js tests/canvas-engine.test.js +git commit -m "feat(canvas): vanilla SVG engine — pan/zoom/drag + overlay spotlight" +``` + +--- + +### Task 12: Tour runner (`tour-runner.js`) + +**Files:** +- Create: `tour-runner.js` +- Test: `tests/tour-runner.test.js` (jsdom) + +Drives an engine instance through tour steps via the overlay API; renders a narration card. No relayout, no data mutation. + +- [ ] **Step 1: Write the failing test** + +```js +// tests/tour-runner.test.js +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { startTour } from '../tour-runner.js'; + +function fakeEngine() { + const calls = []; + return { calls, setSpotlight: (ids) => calls.push(ids), clearSpotlight: () => calls.push('clear') }; +} +const steps = [ + { order: 1, nodeIds: ['a'], title: 'A', blurb: 'first' }, + { order: 2, nodeIds: ['b'], title: 'B', blurb: 'second' }, +]; + +describe('startTour', () => { + let host; + beforeEach(() => { host = document.createElement('div'); document.body.appendChild(host); }); + + it('spotlights step 1 and shows its narration', () => { + const eng = fakeEngine(); + startTour({ host, engine: eng, steps, autoplay: false }); + expect(eng.calls[0]).toEqual(['a']); + expect(host.textContent).toContain('first'); + expect(host.textContent).toContain('1'); + }); + + it('next() advances the spotlight and narration', () => { + const eng = fakeEngine(); + const t = startTour({ host, engine: eng, steps, autoplay: false }); + t.next(); + expect(eng.calls.at(-1)).toEqual(['b']); + expect(host.textContent).toContain('second'); + }); + + it('exit() clears the spotlight and removes the card', () => { + const eng = fakeEngine(); + const t = startTour({ host, engine: eng, steps, autoplay: false }); + t.exit(); + expect(eng.calls.at(-1)).toBe('clear'); + expect(host.querySelector('.rl-tour-card')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/tour-runner.test.js` +Expected: FAIL — cannot resolve `../tour-runner.js`. + +- [ ] **Step 3: Implement** + +```js +// tour-runner.js +// Drive a canvas engine through tour steps. Overlay only — no relayout, no data mutation. + +/** + * @param {{host:HTMLElement, engine:{setSpotlight,clearSpotlight}, steps:object[], autoplay?:boolean}} args + * @returns {{ next, prev, go, exit }} + */ +export function startTour({ host, engine, steps, autoplay = false }) { + let i = 0; + const card = document.createElement('div'); + card.className = 'rl-tour-card'; + host.appendChild(card); + + const reduced = typeof window !== 'undefined' && window.matchMedia + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let timer = null; + + function render() { + const s = steps[i]; + engine.setSpotlight(s.nodeIds); + card.innerHTML = ''; + const step = document.createElement('div'); step.className = 'rl-tour-step'; + step.textContent = `Step ${s.order} of ${steps.length}`; + const title = document.createElement('div'); title.className = 'rl-tour-title'; title.textContent = s.title; + const blurb = document.createElement('p'); blurb.className = 'rl-tour-blurb'; blurb.textContent = s.blurb; + const ctl = document.createElement('div'); ctl.className = 'rl-tour-ctl'; + const back = document.createElement('button'); back.textContent = '← Back'; back.disabled = i === 0; back.onclick = prev; + const fwd = document.createElement('button'); fwd.textContent = i === steps.length - 1 ? 'Done' : 'Next →'; fwd.onclick = () => (i === steps.length - 1 ? exit() : next()); + ctl.append(back, fwd); + card.append(step, title, blurb, ctl); + if (s.lesson) { const l = document.createElement('div'); l.className = 'rl-tour-lesson'; l.textContent = s.lesson; card.insertBefore(l, ctl); } + if (autoplay && !reduced) { clearTimeout(timer); timer = setTimeout(() => (i < steps.length - 1 ? next() : exit()), 6000); } + } + function go(n) { i = Math.max(0, Math.min(steps.length - 1, n)); render(); } + function next() { go(i + 1); } + function prev() { go(i - 1); } + function exit() { clearTimeout(timer); engine.clearSpotlight(); card.remove(); } + + const onKey = (ev) => { if (ev.key === 'ArrowRight') next(); else if (ev.key === 'ArrowLeft') prev(); else if (ev.key === 'Escape') exit(); }; + host.addEventListener('keydown', onKey); + + render(); + return { next, prev, go, exit }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/tour-runner.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tour-runner.js tests/tour-runner.test.js +git commit -m "feat(canvas): tour-runner — narrated spotlight walkthrough" +``` + +--- + +### Task 13: Canvas styles (`themes.css`) + +**Files:** +- Modify: `themes.css` + +- [ ] **Step 1: Append canvas tokens + classes** + +Add at the end of `themes.css` (reuses existing `--dur-*`/`--ease-*`; all motion guarded by reduced-motion like the rest of the file): + +```css +/* ── Interactive canvas ── */ +.rl-canvas { width: 100%; height: 520px; display: block; background: var(--bg, #fbf6ea); touch-action: none; cursor: grab; } +.rl-canvas:active { cursor: grabbing; } +.rl-node rect { fill: var(--surface, #fffdf6); stroke: var(--text, #211c14); stroke-width: 1.5; } +.rl-node text { font: 600 13px ui-monospace, monospace; fill: var(--text, #211c14); pointer-events: none; } +.rl-node { cursor: grab; } +.rl-node.is-root rect { fill: var(--accent, #c2691c); stroke: #8a480f; } +.rl-node.is-root text { fill: #fff; } +/* colour by atom kind (is-root overrides) */ +.rl-kind-entrypoint rect { stroke: #3b6ea5; } +.rl-kind-subsystem rect { stroke: #8a480f; } +.rl-kind-data rect { stroke: #2f7d34; } +.rl-kind-concept rect { stroke-dasharray: 4 3; } +.canvas-legend { display: flex; gap: 12px; padding: 8px 12px; font: 11px ui-monospace, monospace; color: var(--text-sub, #6b5a36); flex-wrap: wrap; } +.canvas-legend .lg { display: inline-flex; align-items: center; gap: 5px; } +.canvas-legend .lg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; border: 1.5px solid; background: var(--surface, #fffdf6); } +.canvas-legend .lg-entrypoint::before { border-color: #3b6ea5; } +.canvas-legend .lg-subsystem::before { border-color: #8a480f; background: var(--accent, #c2691c); } +.canvas-legend .lg-module::before { border-color: var(--text, #211c14); } +.canvas-legend .lg-data::before { border-color: #2f7d34; } +.canvas-legend .lg-concept::before { border-color: var(--text, #211c14); border-style: dashed; } +.canvas-export-bar { display: flex; gap: 8px; padding: 9px 12px; border-top: 1px solid var(--rule, #b9a273); } +.canvas-export-bar button { padding: 5px 11px; border-radius: 7px; font-size: 12px; cursor: pointer; border: 1px solid var(--text, #211c14); background: var(--surface, #fffdf6); } +.rl-node:focus { outline: none; } +.rl-node:focus rect { stroke: var(--accent, #3b6ea5); stroke-width: 2.5; } +.rl-edge { stroke: var(--text, #211c14); stroke-width: 1.7; } +.rl-edge.rl-triggers { stroke: #3b6ea5; stroke-dasharray: 6 4; } +.rl-edge.rl-enables { stroke: #2f7d34; } +@media (prefers-reduced-motion: no-preference) { + .rl-node { transition: opacity var(--dur, .2s) var(--ease-out, ease); } +} +.rl-node.is-dim { opacity: .3; } +.rl-node.is-spotlight rect { stroke-width: 2.5; filter: drop-shadow(0 0 8px rgba(194,105,28,.5)); } +.rl-tour-card { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); width: min(440px, 90%); + background: var(--surface, #fffdf6); border: 1px solid var(--rule, #b9a273); border-left: 4px solid var(--accent, #c2691c); + border-radius: 11px; padding: 13px 15px; box-shadow: 0 8px 22px rgba(33,28,20,.2); } +.rl-tour-step { font: 600 10.5px ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--accent, #c2691c); } +.rl-tour-title { font-size: 15px; font-weight: 800; margin: 5px 0; color: var(--text, #211c14); } +.rl-tour-blurb { font-size: 12.5px; line-height: 1.5; color: var(--text-sub, #4a4034); margin: 0; } +.rl-tour-ctl { display: flex; gap: 8px; margin-top: 11px; } +.rl-tour-ctl button { padding: 5px 11px; border-radius: 7px; font-size: 12px; cursor: pointer; } +``` + +- [ ] **Step 2: Verify the canvas tab host is positioned** + +Confirm the tab content host can position the absolute tour card: in Task 14 the `#t27` inner wrapper gets `position: relative`. + +- [ ] **Step 3: Commit** + +```bash +git add themes.css +git commit -m "feat(canvas): canvas + tour styles (theme-aware, reduced-motion safe)" +``` + +--- + +### Task 14: Wire the Canvas tab (`output-tab.html` + `output-tab.js`) + +**Files:** +- Modify: `output-tab.html` (tab button, content host) +- Modify: `output-tab.js` (`TAB_SLUGS`, `renderCanvas`, export bar) + +- [ ] **Step 1: Add the tab button + host in `output-tab.html`** + +Inside the **Lenses** `.tab-menu-list` (next to Deep Dive, `data-tab="10"`), add: + +```html + +``` + +And alongside the other `.tab-content` divs, add a positioned host: + +```html +
+``` + +- [ ] **Step 2: Register the slug + renderer in `output-tab.js`** + +At the top, extend imports: + +```js +import { buildBlueprintScene } from './blueprint-adapter.js'; +import { mountCanvas } from './canvas-engine.js'; +import { buildTour } from './tour.js'; +import { startTour } from './tour-runner.js'; +import { toCanvasSvg, toExcalidraw } from './canvas-export.js'; +import { getScene, saveScene } from './store.js'; +``` + +Add `27: 'canvas'` to the `TAB_SLUGS` object. + +Add the renderer (call it from the same place other tabs render, e.g. inside the tab-show path or `renderPage`): + +```js +async function renderCanvas(d) { + const hostWrap = document.querySelector('#t27 .canvas-host'); + if (!hostWrap || hostWrap.dataset.mounted === '1') return; // mount once per page + const dd = d.deepDive; + if (!dd || !dd.atoms || !dd.atoms.length) { + hostWrap.innerHTML = '
Run Deep Dive first — the Blueprint is built from its atoms & lineage.
'; + return; + } + const sceneId = 'repo:' + (await import('./scene.js')).hashId(d.repoId); + let scene = await getScene(sceneId); + if (!scene) { + scene = buildBlueprintScene({ deepDive: dd, repoId: d.repoId, title: d.repoId, scanAt: d.analyzedAt || null }); + await saveScene(scene); + } + + hostWrap.dataset.mounted = '1'; + const api = mountCanvas(hostWrap, scene, { onChange: (s) => saveScene(s).catch(() => {}) }); + + // export bar + const bar = document.createElement('div'); + bar.className = 'canvas-export-bar'; + const exSvg = document.createElement('button'); exSvg.textContent = 'SVG'; exSvg.onclick = () => download(`${d.repoId.replace('/', '-')}.svg`, 'image/svg+xml', toCanvasSvg(api.getScene())); + const exEx = document.createElement('button'); exEx.textContent = '.excalidraw'; exEx.onclick = () => download(`${d.repoId.replace('/', '-')}.excalidraw`, 'application/json', toExcalidraw(api.getScene())); + const tourBtn = document.createElement('button'); tourBtn.textContent = '▶ Guided Tour'; + tourBtn.onclick = () => startTour({ host: hostWrap, engine: api, steps: buildTour(api.getScene(), { roots: (dd.lineage && dd.lineage.roots) || [] }), autoplay: false }); + bar.append(tourBtn, exEx, exSvg); + hostWrap.appendChild(bar); + + // color legend (Phase 1 colours by atom kind; layer-based tint is a 1.5 refinement) + const legend = document.createElement('div'); + legend.className = 'canvas-legend'; + for (const [k, lab] of [['entrypoint', 'Entry'], ['subsystem', 'Core'], ['module', 'Module'], ['data', 'Data'], ['concept', 'Concept']]) { + const sw = document.createElement('span'); sw.className = `lg lg-${k}`; sw.textContent = lab; legend.appendChild(sw); + } + hostWrap.appendChild(legend); +} + +function download(filename, type, content) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +``` + +> Match how existing tabs are dispatched. If there's a `switch`/map on tab index that calls render functions, add `case 27: renderCanvas(lastData); break;`. If tabs render eagerly in `renderPage(d)`, call `renderCanvas(d)` there. Confirm with `grep -n "renderDeepDive\|function show\|renderPage" output-tab.js`. Reuse the existing download helper if one already exists (`grep -n "URL.createObjectURL" output-tab.js`) instead of adding a duplicate. + +- [ ] **Step 3: Manual verification (no automated DOM test for the full tab)** + +Run the test suite to ensure nothing regressed: +Run: `npx vitest run` +Expected: all suites green (existing + the new canvas suites). + +Then load the unpacked extension in Chrome, scan a repo, run **Deep Dive**, open the **Canvas** tab, and confirm: nodes render, drag works and survives a tab switch (persisted), `▶ Guided Tour` spotlights step-by-step, and `.excalidraw` downloads + opens in excalidraw.com. + +- [ ] **Step 4: Commit** + +```bash +git add output-tab.html output-tab.js +git commit -m "feat(canvas): wire the Canvas tab — Blueprint render, Guided Tour, export" +``` + +--- + +### Task 15: Changelog + README note + +**Files:** +- Modify: `CHANGELOG.md`, `README.md` + +- [ ] **Step 1: Add a changelog entry** under the newest version heading describing the Canvas tab (Blueprint + Guided Tour + `.excalidraw`/SVG export), and add a **Canvas** row to the README's "What you get" table. + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md README.md +git commit -m "docs: changelog + README for the interactive Canvas" +``` + +--- + +## Final verification + +- [ ] Run the full suite: `npx vitest run` — all green, coverage on new pure modules ≥80%. +- [ ] `node --check` passes on every new/modified `.js`. +- [ ] Manual smoke (Task 14 Step 3) confirmed in Chrome. + +## Phase 1.5 / 2 / 3 (out of scope here — see spec §14) + +- **1.5:** search-to-focus (BM25 over scene) + Scan-Ledger diff overlay. +- **2:** Corkboard (library-wide scene from `nodes`/`edges` stores, scoped layout, red string). +- **3:** Stack Studio (generative wiring via Combinator plumbing). diff --git a/docs/superpowers/plans/2026-06-16-corkboard.md b/docs/superpowers/plans/2026-06-16-corkboard.md new file mode 100644 index 0000000..cf23465 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-corkboard.md @@ -0,0 +1,424 @@ +# Corkboard (Canvas Phase 2) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** A library-wide **Corkboard** — every scanned repo a draggable manila card, related repos joined by colored "red string", arrangement persisted — as a view toggle in the Library page. + +**Architecture:** Reuses the Phase-1 canvas engine (`mountCanvas`, `'corkboard'` scene scope, `'library'` scene id). New: a full-library graph reader, a library→scene adapter, a simple seed layout, a tiny engine fit-class, Corkboard CSS, and a Library-page view toggle (DOM glue, verified live). + +**Tech Stack:** Vanilla ES modules, no deps, Vitest (pure logic only — DOM glue verified live per repo convention), IndexedDB. + +**Spec:** `docs/superpowers/specs/2026-06-15-interactive-canvas-design.md` §14 (Phase 2). **Branch:** continue on `feat/canvas-engine`. + +**Decisions (locked):** simple seed layout + drag (positions persist); Corkboard is a *view* in the Library page (grid ⇄ corkboard), modeled on the existing Radar view; filters by the active Collection; "Collections" stays the name for groups, "Corkboard" is the visual board. + +## Shared shapes (from Phase-1 grounding) +```js +// nodes store row: { id, payload: { repoId?, name?, analyzed, kind?:'repo'|'idea', title?, pitch?, sources? } } +// edges store row: { id, source, target, label, properties } label ∈ {ALTERNATIVE_TO,SYNERGIZES_WITH,COMPARED_TO,COMBINES} +// scene node (Phase 1): { id, label, kind, layer, x, y, pinned, ref } +// scene edge (Phase 1): { id, from, to, rel, note, userDrawn } +``` + +## File map +| File | Change | +|---|---| +| `store.js` | + `getLibraryGraph()` (all nodes+edges, best-effort) | +| `canvas-layout.js` | + `layoutCorkboard(nodes, edges)` (component-clustered grid seed) | +| `library-scene.js` | NEW — `buildLibraryScene({ graph, repos, only })` → corkboard scene | +| `canvas-engine.js` | + add `rl-fit-` class when `node.ref.fit` is set (1 line) | +| `themes.css` | + corkboard styles (cork bg, string colors by relation, fit cards, idea nodes) | +| `library.html` | + Corkboard toggle button + `#corkboard-panel` | +| `library.js` | + `state.view`, `toggleCorkboardView()`, `renderCorkboard()` (DOM glue) | +| `CHANGELOG.md`/`README.md` | + Corkboard note | + +--- + +### Task 1: `getLibraryGraph()` in `store.js` + +**Files:** Modify `store.js`; Test `tests/store-library-graph.test.js`. + +- [ ] **Step 1 — failing test** (mirror the IndexedDB setup used by `tests/store-scenes.test.js`): +```js +import { describe, it, expect } from 'vitest'; +// + same indexedDB shim import the other store tests use (e.g. 'fake-indexeddb/auto') +import { upsertNode, addEdge, getLibraryGraph } from '../store.js'; + +describe('getLibraryGraph', () => { + it('returns all node payloads and all edges', async () => { + await upsertNode(1, { repoId: 'a/b', name: 'b', analyzed: true, kind: 'repo' }); + await upsertNode(2, { repoId: 'c/d', name: 'd', analyzed: true, kind: 'repo' }); + await addEdge({ id: 'e1', source: '1', target: '2', label: 'ALTERNATIVE_TO', properties: {} }); + const g = await getLibraryGraph(); + expect(g.nodes.some((n) => n.repoId === 'a/b')).toBe(true); + expect(g.edges.some((e) => e.label === 'ALTERNATIVE_TO')).toBe(true); + }); + it('is best-effort: returns empty arrays on no data', async () => { + const g = await getLibraryGraph(); + expect(Array.isArray(g.nodes)).toBe(true); + expect(Array.isArray(g.edges)).toBe(true); + }); +}); +``` +> Confirm `upsertNode`/`addEdge` signatures by reading `store.js` (grounding: `upsertNode(nodeId, payload)`, `addEdge({id,source,target,label,properties})`). Match the real ones. + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/store-library-graph.test.js` + +- [ ] **Step 3 — implement** (add near `getEgoGraph` in `store.js`, using the file's real idb helpers): +```js +/** The whole library graph: every node payload + every edge. Best-effort — empty on failure. */ +export async function getLibraryGraph() { + try { + const [nodeRows, edges] = await Promise.all([idbGetAll('nodes'), idbGetAll('edges')]); + return { + nodes: (nodeRows || []).map((r) => r.payload).filter(Boolean), + edges: (edges || []), + }; + } catch { + return { nodes: [], edges: [] }; + } +} +``` + +- [ ] **Step 4 — run, expect PASS + full suite:** `npx vitest run tests/store-library-graph.test.js` then `npx vitest run` +- [ ] **Step 5 — commit:** `git add store.js tests/store-library-graph.test.js && git commit -m "feat(corkboard): getLibraryGraph — full-library nodes + edges reader"` + +--- + +### Task 2: `layoutCorkboard()` in `canvas-layout.js` + +**Files:** Modify `canvas-layout.js` (add export, keep `layoutBlueprint` untouched); Test `tests/corkboard-layout.test.js`. + +Simple deterministic seed: union-find connected components, then place nodes in a grid ordered by (component, id) so related repos seed adjacent; user drags to refine (positions persist). + +- [ ] **Step 1 — failing test:** +```js +import { describe, it, expect } from 'vitest'; +import { layoutCorkboard } from '../canvas-layout.js'; +const N = (id) => ({ id, label: id, kind: 'repo', layer: null, x: 0, y: 0, pinned: false, ref: {} }); + +describe('layoutCorkboard', () => { + it('assigns every node a finite position', () => { + const nodes = [N('a'), N('b'), N('c')]; + const placed = layoutCorkboard(nodes, []); + for (const n of placed) { expect(Number.isFinite(n.x)).toBe(true); expect(Number.isFinite(n.y)).toBe(true); } + }); + it('seeds connected repos in adjacent grid cells (closer than unrelated)', () => { + const nodes = [N('a'), N('b'), N('x'), N('y')]; + const edges = [{ id: 'e', from: 'a', to: 'b', rel: 'ALTERNATIVE_TO' }]; + const placed = layoutCorkboard(nodes, edges); + const by = Object.fromEntries(placed.map((n) => [n.id, n])); + const d = (p, q) => Math.hypot(p.x - q.x, p.y - q.y); + expect(d(by.a, by.b)).toBeLessThanOrEqual(Math.max(d(by.a, by.x), d(by.a, by.y))); + }); + it('keeps pinned nodes where they are', () => { + const nodes = [{ ...N('a'), x: 500, y: 500, pinned: true }, N('b')]; + const placed = layoutCorkboard(nodes, []); + expect(placed.find((n) => n.id === 'a')).toMatchObject({ x: 500, y: 500 }); + }); +}); +``` + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/corkboard-layout.test.js` + +- [ ] **Step 3 — implement** (append to `canvas-layout.js`): +```js +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); + + // order: group by component root, then by id (deterministic) + 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 })); +} +``` + +- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/corkboard-layout.test.js` +- [ ] **Step 5 — commit:** `git add canvas-layout.js tests/corkboard-layout.test.js && git commit -m "feat(corkboard): layoutCorkboard — component-clustered grid seed"` + +--- + +### Task 3: `library-scene.js` adapter + +**Files:** Create `library-scene.js`; Test `tests/library-scene.test.js`. + +Turns the library graph + repo metadata into a corkboard scene. Maps edge `label`→`rel`, node payload→card with fit/health in `ref`, filters to a Collection's repoIds when given. + +- [ ] **Step 1 — failing test:** +```js +import { describe, it, expect } from 'vitest'; +import { buildLibraryScene } from '../library-scene.js'; + +const graph = { + nodes: [ + { repoId: 'evanw/esbuild', name: 'esbuild', analyzed: true, kind: 'repo' }, + { repoId: 'rollup/rollup', name: 'rollup', analyzed: true, kind: 'repo' }, + { title: 'esbuild + rollup glue', kind: 'idea', sources: ['evanw/esbuild', 'rollup/rollup'] }, + ], + edges: [{ id: 'e1', source: 'evanw/esbuild', target: 'rollup/rollup', label: 'ALTERNATIVE_TO', properties: {} }], +}; +const repos = [ + { repoId: 'evanw/esbuild', fit: 'strong', health: { score: 92 } }, + { repoId: 'rollup/rollup', fit: 'solid', health: { score: 80 } }, +]; + +describe('buildLibraryScene', () => { + it('builds a corkboard scene with repo cards + an idea node + a rel edge', () => { + const s = buildLibraryScene({ graph, repos }); + expect(s.scope).toBe('corkboard'); + expect(s.id).toBe('library'); + expect(s.nodes.length).toBe(3); + const esb = s.nodes.find((n) => n.id === 'evanw/esbuild'); + expect(esb.ref.fit).toBe('strong'); + expect(esb.ref.health).toBe(92); + expect(s.edges[0]).toMatchObject({ from: 'evanw/esbuild', to: 'rollup/rollup', rel: 'ALTERNATIVE_TO' }); + expect(s.nodes.some((n) => n.kind === 'idea')).toBe(true); + }); + it('filters to a collection when `only` repoIds are given (+ drops dangling edges)', () => { + const s = buildLibraryScene({ graph, repos, only: ['evanw/esbuild'] }); + expect(s.nodes.map((n) => n.id)).toEqual(['evanw/esbuild']); + expect(s.edges).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/library-scene.test.js` + +- [ ] **Step 3 — implement** `library-scene.js`: +```js +// library-scene.js +// Library graph (nodes/edges stores) + repo metadata → a 'corkboard' scene. +import { createScene } from './scene.js'; + +const idOf = (n) => String(n.repoId || n.id || n.title || ''); + +/** + * @param {object} args + * @param {{nodes:any[], edges:any[]}} args.graph from store.getLibraryGraph() + * @param {Array<{repoId:string, fit?:string, health?:{score:number}, decision?:string}>} [args.repos] + * @param {string[]} [args.only] when set, keep only these repoIds (Collection filter) + * @returns {object} corkboard scene (id 'library') + */ +export function buildLibraryScene({ graph, repos = [], only = null }) { + const meta = Object.fromEntries(repos.map((r) => [r.repoId, r])); + const keep = only ? new Set(only) : null; + + const rawNodes = (graph?.nodes || []).filter((n) => { + const id = idOf(n); + if (!id) return false; + if (keep && n.kind !== 'idea') return keep.has(id); // collection filter applies to repos + if (keep && n.kind === 'idea') return (n.sources || []).some((s) => keep.has(s)); + return true; + }); + + const nodes = rawNodes.map((n) => { + const id = idOf(n); + const m = meta[n.repoId] || {}; + return { + id, + label: n.kind === 'idea' ? String(n.title || 'idea') : String(n.name || id.split('/').pop() || id), + kind: n.kind === 'idea' ? 'idea' : 'repo', + layer: null, + x: 0, y: 0, pinned: false, + ref: { + repoId: n.repoId || null, + analyzed: !!n.analyzed, + fit: m.fit || null, + health: (m.health && Number.isFinite(m.health.score)) ? m.health.score : null, + decision: m.decision || null, + pitch: n.pitch || null, + sources: n.sources || null, + }, + }; + }); + + const ids = new Set(nodes.map((n) => n.id)); + const edges = (graph?.edges || []) + .filter((e) => ids.has(String(e.source)) && ids.has(String(e.target))) + .map((e) => ({ id: String(e.id), from: String(e.source), to: String(e.target), rel: String(e.label || 'ALTERNATIVE_TO'), note: null, userDrawn: false })); + + const scene = createScene({ scope: 'corkboard', repoId: null, title: 'Library' }); + scene.nodes = nodes; + scene.edges = edges; + return scene; +} +``` + +- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/library-scene.test.js` +- [ ] **Step 5 — commit:** `git add library-scene.js tests/library-scene.test.js && git commit -m "feat(corkboard): buildLibraryScene — library graph → corkboard scene"` + +--- + +### Task 4: fit-class in `canvas-engine.js` + +**Files:** Modify `canvas-engine.js`; Test: extend `tests/canvas-engine.test.js` (pure — assert the class string is built, via a tiny exported helper). + +The engine renders nodes as `class="rl-node rl-kind-"`. Corkboard cards should also carry their fit. Add a fit class without breaking Blueprint. + +- [ ] **Step 1 — failing test** (add to `tests/canvas-engine.test.js`): +```js +import { nodeClass } from '../canvas-engine.js'; +describe('nodeClass', () => { + it('includes kind, root, and fit when present', () => { + expect(nodeClass({ kind: 'repo', ref: { root: false, fit: 'strong' } })).toBe('rl-node rl-kind-repo rl-fit-strong'); + expect(nodeClass({ kind: 'module', ref: { root: true } })).toBe('rl-node rl-kind-module is-root'); + expect(nodeClass({ kind: 'data', ref: {} })).toBe('rl-node rl-kind-data'); + }); +}); +``` + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/canvas-engine.test.js` + +- [ ] **Step 3 — implement:** in `canvas-engine.js`, add the exported pure helper and use it where the node `` class is built: +```js +/** Pure: the class string for a node element (kind + optional root/fit). */ +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}`; + return c; +} +``` +Replace the inline `const g = el('g', { class: \`rl-node rl-kind-${n.kind}\`, ... })` + the subsequent `if (n.ref && n.ref.root) g.classList.add('is-root');` with `const g = el('g', { class: nodeClass(n), transform: ..., tabindex: '0' }); g.dataset.node = n.id;` (drop the now-redundant is-root line; nodeClass handles it). + +- [ ] **Step 4 — run, expect PASS + full suite:** `npx vitest run tests/canvas-engine.test.js` then `npx vitest run` +- [ ] **Step 5 — commit:** `git add canvas-engine.js tests/canvas-engine.test.js && git commit -m "feat(corkboard): nodeClass helper — carry fit on cards (engine)"` + +--- + +### Task 5: Corkboard styles in `themes.css` + +**Files:** Modify `themes.css`. + +- [ ] **Step 1 — append** (after the Phase-1 canvas block; uses theme tokens with fallbacks): +```css +/* ── Corkboard (library board) ── */ +.corkboard-panel { position: relative; height: 70vh; min-height: 460px; border: 1px solid var(--border, #b9a273); border-radius: 14px; overflow: hidden; background: #c9a86a; background-image: radial-gradient(circle, rgba(120,90,40,.28) 1px, transparent 1px); background-size: 12px 12px; } +.corkboard-panel.hidden, #grid.hidden, #radar-panel.hidden { display: none; } +.corkboard-panel .rl-canvas { background: transparent; height: 100%; } +/* repo cards read as manila pinned notes */ +.corkboard-panel .rl-kind-repo rect { fill: #f4e8cb; stroke: #9a8358; } +.corkboard-panel .rl-kind-idea rect { fill: #fff7e6; stroke: #c2691c; stroke-dasharray: 5 3; } +.corkboard-panel .rl-fit-strong rect { stroke: #2f7d34; stroke-width: 2.5; } +.corkboard-panel .rl-fit-solid rect { stroke: #3b6ea5; stroke-width: 2; } +.corkboard-panel .rl-fit-care rect { stroke: #c2691c; stroke-width: 2; } +.corkboard-panel .rl-fit-risky rect { stroke: #b3372f; stroke-width: 2.5; } +.corkboard-panel .rl-node text { fill: #211c14; } +/* red string by relation (mirrors Connections hues) */ +.corkboard-panel .rl-edge { stroke-width: 1.8; opacity: .85; } +.corkboard-panel .rl-ALTERNATIVE_TO { stroke: #3b6ea5; } +.corkboard-panel .rl-SYNERGIZES_WITH { stroke: #2f7d34; } +.corkboard-panel .rl-COMPARED_TO { stroke: #b3372f; } +.corkboard-panel .rl-COMBINES { stroke: #c2691c; stroke-dasharray: 5 4; } +.corkboard-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; color: #4a3f28; font: 13px ui-monospace, monospace; padding: 24px; } +``` + +- [ ] **Step 2 — verify braces balanced** (`node -e` brace count like Phase 1) and `npx vitest run` green. +- [ ] **Step 3 — commit:** `git add themes.css && git commit -m "feat(corkboard): cork texture, manila cards, red-string + fit styles"` + +--- + +### Task 6: Library-page view toggle (`library.html` + `library.js`) + +**Files:** Modify `library.html`, `library.js`. DOM glue — NO unit test (verified live, per repo convention). + +**Before editing:** read `library.js` for how `renderRadar()`/`toggleRadar()` work, the `state` object, `render()`, how rows/repo metadata (`allRows`, `libraryRow`, fit) are available, the active-collection state (`state.collection` + its repoIds), and how a card click opens a repo (`openRow`). Mirror those patterns. + +- [ ] **Step 1 — `library.html`:** add a toggle button in `.lib-actions` near the Radar button: +```html + +``` +and a panel after the radar panel (or beside `#grid`): +```html + +``` + +- [ ] **Step 2 — `library.js`:** import what's needed: +```js +import { getLibraryGraph } from './store.js'; // add to an existing store.js import if cleaner +import { buildLibraryScene } from './library-scene.js'; +import { layoutCorkboard } from './canvas-layout.js'; +import { mountCanvas } from './canvas-engine.js'; +import { saveScene, getScene } from './store.js'; +``` +Add `view: 'list'` to the `state` object. Add the toggle + renderer (adapt to the real `state`, `allRows`, collection repoIds, and `openRow`): +```js +function toggleCorkboardView() { + state.view = state.view === 'corkboard' ? 'list' : 'corkboard'; + document.getElementById('lib-btn-corkboard')?.classList.toggle('on', state.view === 'corkboard'); + document.getElementById('corkboard-panel')?.classList.toggle('hidden', state.view !== 'corkboard'); + document.getElementById('grid')?.classList.toggle('hidden', state.view === 'corkboard'); + document.getElementById('radar-panel')?.classList.add('hidden'); + if (state.view === 'corkboard') renderCorkboard(); +} + +let cbApi = null; +async function renderCorkboard() { + const panel = document.getElementById('corkboard-panel'); + if (!panel) return; + const graph = await getLibraryGraph(); + if (!graph.nodes.length) { panel.innerHTML = '
Scan a few repos (and run Alternatives / Synergies / Versus) to grow your board.
'; return; } + // repo metadata for fit/health from the already-loaded rows; collection filter via active collection's repoIds + const repos = (allRows || []).map((r) => ({ repoId: r.repoId, fit: r.fit?.level || r.fitLevel || null, health: r.health, decision: r.decision })); + const only = state.collection ? (collectionRepoIds(state.collection)) : null; // use the real accessor for a collection's repoIds + const built = buildLibraryScene({ graph, repos, only }); + // reuse a saved arrangement if present, else seed + const saved = await getScene('library'); + if (saved && saved.nodes?.length) { + const posById = Object.fromEntries(saved.nodes.map((n) => [n.id, n])); + built.nodes = built.nodes.map((n) => posById[n.id] ? { ...n, x: posById[n.id].x, y: posById[n.id].y, pinned: posById[n.id].pinned } : n); + built.nodes = layoutCorkboard(built.nodes, built.edges); // seed only the un-positioned (pinned/saved kept) + } else { + built.nodes = layoutCorkboard(built.nodes, built.edges); + } + panel.innerHTML = ''; + if (cbApi) cbApi.destroy(); + cbApi = mountCanvas(panel, built, { onChange: (s) => saveScene(s).catch(() => {}) }); + // click a card → open the repo (delegate; nodes carry data-node = repoId) + panel.querySelector('svg')?.addEventListener('dblclick', (ev) => { + const g = ev.target.closest('[data-node]'); if (!g) return; + const id = g.dataset.node; if (id && id.includes('/')) openRow(id); + }); +} +document.getElementById('lib-btn-corkboard')?.addEventListener('click', toggleCorkboardView); +``` +> Adapt every name to the real code: the fit field on a row, `collectionRepoIds`/how a collection's `repoIds` are read (grounding: collection `{ id, repoIds[] }` — use `listCollections()`/the in-memory collections), `openRow`, and `allRows`. To reuse saved positions cleanly, mark restored nodes `pinned:true` before `layoutCorkboard` so the seed only places new ones, then unpin — or simpler: if a saved scene exists, skip `layoutCorkboard` for nodes present in it. Keep it correct to the real helpers. + +- [ ] **Step 3 — verify:** `node --check library.js` exit 0; `npx vitest run` all green (no test for this; just no regression). Re-read the diff. +- [ ] **Step 4 — commit:** `git add library.html library.js && git commit -m "feat(corkboard): Library Corkboard view — mount, filter by collection, persist, open on dblclick"` + +--- + +### Task 7: Docs + +**Files:** `CHANGELOG.md`, `README.md`. +- [ ] Add a Corkboard bullet under the Canvas changelog entry; mention the Corkboard in the README Canvas row. Commit `docs: Corkboard in changelog + README`. + +--- + +## Final verification +- [ ] `npx vitest run` — all green (new pure suites: store-library-graph, corkboard-layout, library-scene, nodeClass). +- [ ] `node --check` on every changed `.js`. +- [ ] **Live smoke** (like Phase 1): a standalone harness OR load the extension, open Library → Corkboard, confirm cards render with fit colors, string connects related repos, drag persists across reload, collection filter narrows the board. Screenshot. + +## Out of scope (later) +- Force-directed / community-cluster auto-layout (Phase 2.5 if libraries get large). +- Hover tooltips with rich card detail, pin/zoom-to-fit affordances, board export. +- Phase 3 Stack Studio (generative wiring). diff --git a/docs/superpowers/plans/2026-06-16-stack-studio.md b/docs/superpowers/plans/2026-06-16-stack-studio.md new file mode 100644 index 0000000..edab239 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-stack-studio.md @@ -0,0 +1,264 @@ +# Stack Studio (Canvas Phase 3) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Render the existing **Tech-Stack Builder** result on the interactive canvas — repos as layer-colored cards, integrations as "integrates" string, gaps as dashed gap-cards — via a "View on canvas" toggle in the stack result page. + +**Architecture:** Pure adapter + layout + a DOM toggle. **Reuses** the existing generation (`STACK_BUILD` → `parseStack` → session `result`), the canvas engine (`mountCanvas`, scene scope `'stack'`), and routing. No new AI path. + +**Tech Stack:** Vanilla ES modules, no deps, Vitest (pure logic only; DOM toggle verified live). **Branch:** continue on `feat/canvas-engine`. + +**Existing stack result shape** (from `stack-prompt.js` `parseStack`): +```js +// { title, roles:[{repoId, role, layer}], integrations:[{from, to, glue}], gaps:[string], order:[repoId], summary } +// layer ∈ frontend|backend|data|infra|testing|tooling +``` +Result lives in `chrome.storage.session[sessionKey]` (`.result`), shown by `stack-tab.html?key=` via `stack-tab.js`. Repos chosen via Library bulk-select (2–6) → `STACK_BUILD`. + +**Decisions:** gaps render as **gap-kind nodes** (the live engine renders nodes/edges, not free annotations); layout is **adoption-order left→right**; the canvas is a **toggle inside the existing stack page** (not a new route). + +## File map +| File | Change | +|---|---| +| `stack-scene.js` | NEW — `buildStackScene(result, title)` → `'stack'` scene | +| `canvas-layout.js` | + `layoutStack(nodes, order)` | +| `themes.css` | + `.stack-canvas` styles (layer-colored repo cards, gap cards, integrates edge) | +| `stack-tab.html` | + a "View on canvas" toggle button + `#stack-canvas` host; ensure `themes.css` is linked | +| `stack-tab.js` | + build scene from session `result` and mount the engine on toggle | +| `CHANGELOG.md`/`README.md` | + Stack Studio note | + +--- + +### Task 1: `stack-scene.js` + +**Files:** Create `stack-scene.js`; Test `tests/stack-scene.test.js`. + +- [ ] **Step 1 — failing test:** +```js +import { describe, it, expect } from 'vitest'; +import { buildStackScene } from '../stack-scene.js'; + +const result = { + title: 'Edge API stack', + roles: [ + { repoId: 'honojs/hono', role: 'HTTP router', layer: 'backend' }, + { repoId: 'drizzle-team/drizzle-orm', role: 'data layer', layer: 'data' }, + ], + integrations: [{ from: 'honojs/hono', to: 'drizzle-team/drizzle-orm', glue: 'handlers call the ORM' }], + gaps: ['no auth layer'], + order: ['honojs/hono', 'drizzle-team/drizzle-orm'], + summary: 'A minimal edge API.', +}; + +describe('buildStackScene', () => { + it('maps roles→repo nodes (with layer), integrations→edges (glue note), gaps→gap nodes', () => { + const s = buildStackScene(result); + expect(s.scope).toBe('stack'); + const hono = s.nodes.find((n) => n.id === 'honojs/hono'); + expect(hono.kind).toBe('repo'); + expect(hono.layer).toBe('backend'); + expect(hono.ref.role).toBe('HTTP router'); + expect(s.edges[0]).toMatchObject({ from: 'honojs/hono', to: 'drizzle-team/drizzle-orm', rel: 'integrates', note: 'handlers call the ORM' }); + const gap = s.nodes.find((n) => n.kind === 'gap'); + expect(gap.label).toBe('no auth layer'); + expect(s.source.order).toEqual(['honojs/hono', 'drizzle-team/drizzle-orm']); + }); + it('drops integrations whose endpoints are not roles', () => { + const s = buildStackScene({ roles: [{ repoId: 'a/b', role: 'x', layer: 'tooling' }], integrations: [{ from: 'a/b', to: 'ghost', glue: 'g' }], gaps: [], order: [] }); + expect(s.edges).toHaveLength(0); + }); + it('handles a missing/empty result without throwing', () => { + const s = buildStackScene(null); + expect(s.scope).toBe('stack'); + expect(s.nodes).toEqual([]); + }); +}); +``` + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/stack-scene.test.js` + +- [ ] **Step 3 — implement** `stack-scene.js`: +```js +// stack-scene.js +// Tech-Stack Builder result → a 'stack'-scope canvas scene. +import { createScene } from './scene.js'; + +/** + * @param {{title?:string, roles?:any[], integrations?:any[], gaps?:any[], order?:string[]}} result + * @param {string} [title] + * @returns {object} stack scene + */ +export function buildStackScene(result, title) { + const roles = (result && result.roles) || []; + const integrations = (result && result.integrations) || []; + const gaps = (result && result.gaps) || []; + const order = (result && result.order) || []; + + const nodes = roles.map((r) => ({ + id: String(r.repoId), + label: String(r.repoId).split('/').pop() || String(r.repoId), + kind: 'repo', + layer: r.layer || null, + x: 0, y: 0, pinned: false, + ref: { repoId: r.repoId, role: r.role || null }, + })); + const repoIds = new Set(nodes.map((n) => n.id)); + + gaps.forEach((g, i) => nodes.push({ + id: `gap:${i}`, label: String(g), kind: 'gap', layer: null, + x: 0, y: 0, pinned: false, ref: { gap: true }, + })); + + const edges = integrations + .filter((it) => it && repoIds.has(String(it.from)) && repoIds.has(String(it.to))) + .map((it, i) => ({ id: `int:${i}`, from: String(it.from), to: String(it.to), rel: 'integrates', note: it.glue || null, userDrawn: false })); + + const scene = createScene({ scope: 'stack', repoId: null, title: title || (result && result.title) || 'Stack' }); + scene.nodes = nodes; + scene.edges = edges; + scene.source = { ...scene.source, order }; + return scene; +} +``` + +- [ ] **Step 4 — run, expect PASS + full suite:** `npx vitest run tests/stack-scene.test.js` then `npx vitest run` +- [ ] **Step 5 — commit:** `git add stack-scene.js tests/stack-scene.test.js && git commit -m "feat(stack-studio): buildStackScene — stack result → canvas scene"` + +--- + +### Task 2: `layoutStack()` in `canvas-layout.js` + +**Files:** Modify `canvas-layout.js` (append; leave `layoutBlueprint`/`layoutCorkboard` untouched); Test `tests/stack-layout.test.js`. + +- [ ] **Step 1 — failing test:** +```js +import { describe, it, expect } from 'vitest'; +import { layoutStack } from '../canvas-layout.js'; +const repo = (id) => ({ id, label: id, kind: 'repo', layer: null, x: 0, y: 0, pinned: false, ref: {} }); +const gap = (id) => ({ id, label: id, kind: 'gap', layer: null, x: 0, y: 0, pinned: false, ref: {} }); + +describe('layoutStack', () => { + it('places repos left→right by adoption order', () => { + const nodes = [repo('b'), repo('a')]; + const placed = layoutStack(nodes, ['a', 'b']); + const by = Object.fromEntries(placed.map((n) => [n.id, n])); + expect(by.a.x).toBeLessThan(by.b.x); + expect(by.a.y).toBe(by.b.y); // repos share the top row + }); + it('puts gap cards in a row below the repos', () => { + const placed = layoutStack([repo('a'), gap('gap:0')], ['a']); + const by = Object.fromEntries(placed.map((n) => [n.id, n])); + expect(by['gap:0'].y).toBeGreaterThan(by.a.y); + }); + it('keeps pinned nodes', () => { + const placed = layoutStack([{ ...repo('a'), x: 9, y: 9, pinned: true }], ['a']); + expect(placed[0]).toMatchObject({ x: 9, y: 9 }); + }); +}); +``` + +- [ ] **Step 2 — run, expect FAIL:** `npx vitest run tests/stack-layout.test.js` + +- [ ] **Step 3 — append to `canvas-layout.js`** (reuses the `CARD_W/CARD_H/GAP_X/GAP_Y/ORIGIN` constants already defined for `layoutCorkboard`): +```js + +/** 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 })); +} +``` +> If `CARD_W` etc. are not in module scope where you append, reference the same literals used by `layoutCorkboard` (read the file to confirm the constant names). + +- [ ] **Step 4 — run, expect PASS:** `npx vitest run tests/stack-layout.test.js` +- [ ] **Step 5 — commit:** `git add canvas-layout.js tests/stack-layout.test.js && git commit -m "feat(stack-studio): layoutStack — adoption-order rows"` + +--- + +### Task 3: Stack-canvas styles in `themes.css` + +**Files:** Modify `themes.css`. + +- [ ] **Step 1 — append:** +```css +/* ── Stack Studio (canvas view of a tech-stack) ── */ +.stack-canvas { position: relative; height: 60vh; min-height: 420px; border: 1px solid var(--border, #b9a273); border-radius: 14px; overflow: hidden; background: var(--bg, #fbf6ea); } +.stack-canvas.hidden { display: none; } +.stack-canvas .rl-canvas { height: 100%; } +.stack-canvas .rl-kind-repo rect { fill: var(--surface, #fffdf6); stroke: var(--text, #211c14); } +/* layer accent on the card's left edge via stroke colour */ +.stack-canvas .rl-layer-frontend rect { stroke: #3b6ea5; stroke-width: 2.5; } +.stack-canvas .rl-layer-backend rect { stroke: #2f7d34; stroke-width: 2.5; } +.stack-canvas .rl-layer-data rect { stroke: #c2691c; stroke-width: 2.5; } +.stack-canvas .rl-layer-infra rect { stroke: #7a5bb0; stroke-width: 2.5; } +.stack-canvas .rl-layer-testing rect { stroke: #b3372f; stroke-width: 2.5; } +.stack-canvas .rl-layer-tooling rect { stroke: #6b5a36; stroke-width: 2.5; } +.stack-canvas .rl-kind-gap rect { fill: #fbeae6; stroke: #b3372f; stroke-dasharray: 5 3; } +.stack-canvas .rl-kind-gap text { fill: #8a2f25; } +.stack-canvas .rl-integrates { stroke: #3b6ea5; stroke-width: 1.8; } +``` +> The engine sets a node class `rl-kind-` and (Phase-2) `rl-fit-`; it does NOT emit a layer class today. So in Task 1 the scene carries `layer`, but for the layer stroke to apply, the engine must add `rl-layer-`. **Add that to `nodeClass` in `canvas-engine.js`** as part of Task 3 (one line): after the fit clause, `if (n.layer) c += ' rl-layer-' + n.layer;`. Update the `nodeClass` unit test in `tests/canvas-engine.test.js` to assert the layer class (e.g. `nodeClass({kind:'repo', layer:'backend', ref:{}})` → `'rl-node rl-kind-repo rl-layer-backend'`). + +- [ ] **Step 2 — verify:** braces balanced (node brace-count one-liner) + `node --check canvas-engine.js` + `npx vitest run` green. +- [ ] **Step 3 — commit:** `git add themes.css canvas-engine.js tests/canvas-engine.test.js && git commit -m "feat(stack-studio): layer-colored cards + gap/integrates styles (+ nodeClass layer class)"` + +--- + +### Task 4: "View on canvas" toggle (`stack-tab.html` + `stack-tab.js`) + +**Files:** Modify `stack-tab.html`, `stack-tab.js`. DOM glue — NO unit test (verified live). + +**Before editing:** read `stack-tab.js` to see how it reads the session `result` (the `key` query param → `chrome.storage.session[key].result`), how it renders the text view, and its render entry point. Read `stack-tab.html` for the layout + confirm whether it links `themes.css` (the engine's `.rl-*` classes live there — **add `` if missing**). + +- [ ] **Step 1 — `stack-tab.html`:** ensure `themes.css` is linked in ``; add a toggle button near the result header and a canvas host: +```html + + +``` + +- [ ] **Step 2 — `stack-tab.js`:** import and wire (adapt to the real result-access + element ids): +```js +import { buildStackScene } from './stack-scene.js'; +import { layoutStack } from './canvas-layout.js'; +import { mountCanvas } from './canvas-engine.js'; + +let stackCanvasApi = null; +function toggleStackCanvas(result) { + const host = document.getElementById('stack-canvas'); + if (!host) return; + const showing = !host.classList.contains('hidden'); + if (showing) { host.classList.add('hidden'); if (stackCanvasApi) { stackCanvasApi.destroy(); stackCanvasApi = null; } return; } + host.classList.remove('hidden'); + const scene = buildStackScene(result, result && result.title); + scene.nodes = layoutStack(scene.nodes, (scene.source && scene.source.order) || []); + host.innerHTML = ''; + stackCanvasApi = mountCanvas(host, scene, {}); +} +// wire after the result is loaded/rendered (use the SAME `result` object the text view uses): +document.getElementById('stack-view-canvas')?.addEventListener('click', () => toggleStackCanvas(currentResult)); +``` +> Adapt: `currentResult` = whatever variable holds the parsed session result in `stack-tab.js`. If the result loads asynchronously (polling), attach the listener once and read the latest result at click time (e.g. a module-scoped `let currentResult`). Hide/disable the button until a `result` exists. + +- [ ] **Step 3 — verify:** `node --check stack-tab.js` exit 0; `npx vitest run` green (no test; just no regression). Re-read the diff. +- [ ] **Step 4 — commit:** `git add stack-tab.html stack-tab.js && git commit -m "feat(stack-studio): View-on-canvas toggle in the stack result page"` + +--- + +### Task 5: Docs +- [ ] Add a Stack Studio bullet to the Canvas changelog entry + a phrase in the README Canvas row. Commit `docs: Stack Studio (Canvas Phase 3)`. + +## Final verification +- [ ] `npx vitest run` green (new: stack-scene, stack-layout, nodeClass layer assertion). +- [ ] `node --check` on every changed `.js`. +- [ ] **Live smoke:** standalone harness — feed a sample stack `result` → `buildStackScene` → `layoutStack` → `mountCanvas` in a `.stack-canvas`; confirm layer-colored repo cards left→right, an "integrates" edge, a dashed gap card. Screenshot. + +## Out of scope +- Persisting the stack scene (it's session-derived); saving a stack to the library. +- Re-generation from the canvas; editing wiring back into a regenerate. diff --git a/docs/superpowers/specs/2026-06-15-interactive-canvas-design.md b/docs/superpowers/specs/2026-06-15-interactive-canvas-design.md new file mode 100644 index 0000000..39ae9a2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-interactive-canvas-design.md @@ -0,0 +1,300 @@ +# Interactive Canvas — Design Spec + +> Status: **Draft for review** · 2026-06-15 +> Branch target: a new `feat/canvas-engine` off `main` (not the current `feat/scan-ledger`). +> Brainstorm artifacts: `.superpowers/brainstorm/91097-1781590287/` (mockups). +> Inspiration studied: [Egonex-AI/Understand-Anything](https://github.com/Egonex-AI/Understand-Anything) +> (knowledge-graph dashboard) — concepts ported, **none of its React/Vite/ELK stack adopted.** + +## 1. The idea in one paragraph + +Turn a RepoLens scan into a **draggable, annotatable, shareable canvas** — and make +exploring it *fun*. One zero-build **canvas engine** powers three scopes at three zoom +levels: a single repo's architecture (**Blueprint**), the whole library as a red-string +detective board (**Corkboard**), and a hand-picked subset wired into a system (**Stack +Studio**). The same scene serialises to `.excalidraw` and SVG, so the "interactive canvas" +and "exportable artifact" are one engine with two outputs. The headline delight is a +**Guided Tour**: the camera flies node-to-node in dependency order, spotlighting one piece +at a time and narrating it — the detective walking you through the case file. This advances +the product's north star (*"your evaluations compound"*) by giving the ephemeral Deep Dive +a durable, portable home. + +## 2. Goals / Non-goals + +**Goals** +- A vanilla-JS, **zero-build**, Manifest-V3-safe canvas engine — no React, no bundler, no new runtime deps. +- Persist arranged scenes (positions, pins, notes, drawn edges) so they survive the refresh that wipes Deep Dive. +- Ship **Phase 1** end-to-end: engine + **Blueprint** adapter + **Guided Tour** + export. +- Reuse data the codebase already produces (Deep Dive `atoms`/`lineage`, `taxonomy.layerOf`). +- Re-skin across all 13 themes for free (SVG + CSS tokens, like `diagram.js`/`graph.js`). +- Reduced-motion safe; keyboard reachable; all user text escaped. + +**Non-goals (this spec)** +- The Corkboard (Phase 2) and Stack Studio (Phase 3) are **sketched, not specified** here. +- Search-to-focus and the Scan-Ledger diff overlay are **Phase 1.5** fast-follows. +- No backend, no telemetry, no third-party graph library, no WebGL. +- No tree-sitter / deterministic source parsing (Understand-Anything's structural half) — + RepoLens stays LLM-sourced for atoms; we instead harden against messy LLM output (§6). + +## 3. Architecture overview + +``` +Deep Dive (session) scenes store (idb v5, durable) + atoms + lineage ──┐ ▲ │ + │ │ save │ load + ▼ │ ▼ + blueprint-adapter.js ──► scene model ◄──► canvas-engine.js + (seed nodes/edges) (§5 schema) (pan/zoom/drag/connect/note) + │ │ │ + repair-graph.js (§6) │ tour-runner.js (§8, overlay) + (normalize LLM data) │ + ▼ + exporter.js → .excalidraw / SVG (§9) + │ + output-tab Canvas tab (§10) +``` + +Two **invariants** borrowed from Understand-Anything's layout design and made first-class here: + +1. **Layout is pure + memoised.** Node positions are computed only when the *topology* + changes (nodes/edges added or removed, or an explicit Re-layout). Dragging persists a + position; it does not recompute layout. +2. **Visual state is a separate O(n) overlay pass.** Selection, hover, search highlight, + tour spotlight, and diff tint never trigger a relayout — they toggle classes/attributes + on already-positioned elements. + +This split is what keeps the canvas smooth and the engine simple. + +## 4. Module map (new + touched) + +**New modules (pure where possible, all unit-testable without a DOM):** + +| File | Responsibility | +|---|---| +| `scene.js` | Scene model: factory, validation, immutable update helpers, id/seed hashing. Pure. | +| `repair-graph.js` | Normalize messy LLM node/edge data into a valid scene graph (§6). Pure. | +| `canvas-layout.js` | Seed layout for a scope (Blueprint reuses `diagram.js` depth math). Pure. | +| `canvas-engine.js` | The interactive surface: Pointer-Events pan/zoom/drag/connect/note, render, overlay pass. DOM. | +| `blueprint-adapter.js` | Build a Blueprint scene from Deep Dive `atoms`/`lineage` + layers. Pure. | +| `tour.js` | Compute tour steps from graph topology (fan-in/out, BFS order, clusters). Pure. | +| `tour-runner.js` | Drive the camera + narration overlay through tour steps. DOM. | +| `canvas-export.js` *(or extend `exporter.js`)* | `toExcalidraw(scene)`, `toCanvasSvg(scene)`. Pure strings. | + +**Touched modules:** + +| File | Change | +|---|---| +| `store/idb.js` | Add `'scenes'` to `STORES`; bump `DB_VERSION` 4 → 5 (additive). | +| `store.js` | `saveScene/getScene/listScenes/deleteScene`; include `scenes` in `exportStores`/`importStores`. | +| `backup.js` | Add `scenes` to envelope build/validate + `MAX_ROWS['scenes']`. | +| `output-tab.html` | Add Canvas tab button + `#t27` content host (27 = next free index after `ask`=26) + tool/overlay CSS. | +| `output-tab.js` | `TAB_SLUGS[27]='canvas'`; `renderCanvas(d)` mounts the engine; export-bar wiring. | +| `exporter.js` | Re-export the two canvas exporters (or host them directly). | +| `settings-backup.js` | Add `canvasEnabled` (and `canvasTourAutoplay`) to `SAFE_SETTING_KEYS`. | +| `themes.css` | Canvas tokens reuse existing `--dur-*`/`--ease-*`; add `--canvas-*` surface tokens. | + +## 5. The scene model + +One schema serves every scope. Stored as a row in the `scenes` store (keyed by `id`). + +```js +// scene.js +{ + id: string, // 'repo:' (Blueprint) | 'library' (Corkboard) | 'stack:' + scope: 'blueprint' | 'corkboard' | 'stack', + repoId: string | null, // owning repo for blueprint/stack; null for library + title: string, + nodes: [{ + id: string, // stable, from source data (atom id, repoId, …) + label: string, + kind: string, // atom kind | 'repo' | 'idea' | 'note' + layer: string | null, // taxonomy.layerOf(...) — drives colour + x: number, y: number, // persisted position (engine writes on drag) + pinned: boolean, // user pinned → excluded from re-layout + ref: object | null, // back-pointer payload (files, purpose, repoId, summary…) + }], + edges: [{ + id: string, // deterministic: hash(from|rel|to) + from: string, to: string, // must reference existing node ids (repair drops orphans) + rel: string, // 'depends-on'|'enables'|'triggers'|'derives-from'|'string'|… + note: string | null, // user annotation on the edge + userDrawn: boolean, // hand-drawn 'string' vs seeded lineage edge + }], + annotations: [{ // free sticky notes pinned to the board + id: string, x: number, y: number, text: string, tone: 'note'|'warn', + }], + camera: { x: number, y: number, zoom: number }, + tour: null | { steps: TourStep[], generatedAt: string }, // see §8 + source: { lens: 'deepDive', generatedAt: string, scanAt: string|null }, + createdAt: string, updatedAt: string, +} +``` + +Notes +- **No `Math.random` for ids/seeds** — derive from `hashRepoId`-style djb2 over stable keys + so re-seeding is idempotent and exports diff cleanly. +- Positions are authored in a single **world coordinate space**; the engine applies one + `transform` (the camera) to a root ``. (Fixes the mock's two-coordinate-system bug.) +- The scene is the **single source of truth** for both render and export. + +## 6. `repair-graph.js` — taming messy LLM data + +Deep Dive's `atoms`/`lineage` are LLM-produced; they will sometimes be malformed. Borrowed +directly from Understand-Anything's tiered robustness model (`GraphIssue`). `repairGraph(raw)` +returns `{ nodes, edges, issues: GraphIssue[] }` and **never throws**: + +| Repair | Trigger | Level | +|---|---|---| +| coerce node `kind` aliases (`func`→`function`, mixed case) to known set | unknown/aliased kind | `auto-corrected` | +| fill missing `label`/`layer` defaults | missing field | `auto-corrected` | +| dedupe duplicate node ids | duplicate id | `auto-corrected` | +| **drop edges whose `from`/`to` isn't a node** | dangling ref | `dropped` | +| drop nodes missing an `id` | unrecoverable | `dropped` | +| coerce edge `rel` aliases to known set | aliased relation | `auto-corrected` | + +`diagram.js` already filters links with unknown endpoints (`valid = links.filter(...)`); this +generalises that into one reusable, tested pass. The Canvas surfaces a quiet +`"cleaned N issues"` chip (click → list) so problems are visible, never silent. A `strict` +flag throws in tests so malformed fixtures are caught in CI rather than shipped as silent drops. + +## 7. `canvas-engine.js` — the interactive surface + +Pure vanilla, Pointer Events, one SVG root. No deps. MV3-safe (no `eval`, no inline handlers — +listeners attached in JS; honours the extension's existing CSP). + +- **Coordinate system:** world coords on a root ``. + Screen↔world conversion in one helper; everything else works in world space. +- **Camera:** wheel = zoom toward cursor (clamped 0.2–3×); drag empty space = pan; `Fit` frames + all nodes; `100%` resets. Camera persisted in `scene.camera`. +- **Tools:** Select · Pan · Connect · Note (mirrors the mock). Tool = a small state machine. +- **Nodes:** rendered as `` (rect + text, themed via CSS classes by `kind`/`layer`). + Drag updates `node.x/y` (debounced persist). Hit-testing via pointer capture on the node ``. +- **Edges:** cubic Béziers from source-anchor to target-anchor, recomputed *only* for edges + touching a moved node (cheap). Relation → stroke class (solid/green/dashed/red-string). +- **Connect:** drag from a node port to another node → new `userDrawn` edge. +- **Notes:** Note tool drops an annotation; double-click to edit (escaped on render). +- **Overlay pass** (`applyOverlay(state)`): toggles `.is-selected/.is-dim/.is-spotlight/.is-hit` + classes — never relays out. Tour, search, hover, diff all route through this. +- **Persistence:** any structural/position change → `scheduleSave()` (debounced `saveScene`). +- **Performance budget:** Blueprint ≤ ~15 nodes — trivial. Engine must stay smooth to ~300 + nodes for Phase 2; layout cost is bounded because it only runs on topology change. + +**A11y / motion:** nodes are focusable (`tabindex`), arrow-key nudge, `Enter` opens detail; +all camera/tour animation behind `@media (prefers-reduced-motion: no-preference)` (reduced +motion = instant jumps, no fly-through). Canvas is decorative-graph; a text outline of the +scene is available for screen readers. + +## 8. The Guided Tour (the "fun" centrepiece) + +Ported from Understand-Anything's `tour-builder`, adapted to RepoLens data and the canvas. + +**`tour.js` — compute steps (pure, deterministic, no LLM required):** +- Inputs: scene `nodes`/`edges` + Deep Dive `roots`/`leaves`. +- Compute **fan-in** (importance) and **fan-out** (scope) per node; pick **entry point(s)** + (lineage `roots`, tie-broken by fan-out); **BFS** forward along edges for reading order; + group **tightly-coupled clusters** (mutual edges) into shared steps. +- Emit 5–15 `TourStep { order, nodeIds[], title, blurb, lesson? }`. `blurb` defaults to the + atom `purpose`/Feynman text already in Deep Dive (zero extra tokens); an **optional** single + LLM pass can upgrade narration (reuses `background.js` plumbing, behind a "Polish narration" + action — not required to ship). + +**`tour-runner.js` — drive it (overlay only):** +- Step N: camera eases to frame `step.nodeIds`, those nodes get `.is-spotlight`, the rest + `.is-dim`; a narration card shows `title`/`blurb`/optional `lesson` + Back/Next + a progress + rail; auto-play advances on a timer (pausable; setting `canvasTourAutoplay`). +- `←`/`→`/`Esc` keyboard control. Reduced-motion → instant cuts, no eased camera. +- No relayout, no data mutation — pure overlay over the existing scene. + +## 9. Export — `toExcalidraw(scene)` + `toCanvasSvg(scene)` + +Pure string functions in the `exporter.js` mould (Blob+anchor download already exists in +`output-tab.js`). + +- **`toCanvasSvg(scene)`** — serialise the current scene to a standalone, themed SVG + (reuses the engine's node/edge rendering helpers). Escaped text. Instant, offline. +- **`toExcalidraw(scene)`** — emit a valid Excalidraw document + (`{type:"excalidraw",version:2,source,elements,appState}`): each node → a `rectangle` + bound + `text`; each edge → an `arrow` with `startBinding`/`endBinding`; annotations → sticky `text`. + Element `seed`/`versionNonce` derived by hashing the element id (deterministic, no RNG). + **Why it's worth it:** opening the file in excalidraw.com / Obsidian / VS Code renders it + **hand-drawn** (Excalidraw's roughness) — so users get the playful sketch aesthetic for free + while the in-extension canvas stays crisp Case-File ink. PNG export can come later (canvas + rasterise) — out of Phase 1. + +Export options on the Canvas footer: `.excalidraw` · `SVG` · `Markdown` (atoms/edges table via +existing exporter patterns). + +## 10. Persistence, storage & backup + +- **`store/idb.js`**: append `'scenes'` to `STORES`, bump `DB_VERSION` to 5. The existing + `onupgradeneeded` loop creates the store additively — v4 data survives untouched. +- **`store.js`**: `saveScene(scene)`, `getScene(id)`, `listScenes(repoId?)`, `deleteScene(id)`; + add `scenes` to `exportStores()`/`importStores()` (same row-merge pattern as snapshots). +- **`backup.js`**: add `scenes` to `buildBackup`/`validateBackup` with a `sceneOk` validator + (`id` + `scope` + arrays present) and `MAX_ROWS['scenes']` clamp — scenes round-trip through + the v2 envelope exactly like snapshots/decisions. +- **`settings-backup.js`**: allowlist `canvasEnabled`, `canvasTourAutoplay` (no secrets). +- **Lifecycle:** opening the Canvas tab calls `getScene('repo:'+hash)`; if absent, the + blueprint-adapter seeds one from the live (session) Deep Dive and `saveScene`s it. Thereafter + the durable scene is authoritative; a `↻ Re-seed from latest Deep Dive` action re-derives on demand. + +## 11. UI integration (`output-tab`) + +- Add a **Canvas** tab (`data-tab="27"`, the next free index; `TAB_SLUGS[27]='canvas'`), grouped + under the existing **Lenses** menu beside Deep Dive. URL-hash routing + per-repo tab memory work + automatically via the existing `show()` logic. +- `renderCanvas(d)` mounts `canvas-engine` into `#t27` with the loaded/seeded scene; the + toolbar, tour controls, repair chip, and export footer live in this host. +- Empty/edge states: if no Deep Dive has been run, show a guided `.dd-cta`-style card + ("Run Deep Dive to build the Blueprint") — reuses the existing empty-state pattern. + +## 12. Theming, security, performance + +- **Theming:** nodes/edges use `currentColor` + theme tokens; verified on dark *and* light + (same approach as `diagram.js`/`graph.js`/Vee). Layer palette = a small token set in `themes.css`. +- **Security:** every label/note/title rendered via `escapeHtml`/`html\`\`` from `safe-html.js`; + SVG structure built by code, user data only in escaped text. No inline event handlers (CSP). +- **Performance:** no new bundle weight (vanilla). Layout pure+memoised; overlay O(n). Honours + the bundle/motion budgets already in the repo. + +## 13. Testing (TDD; matches the repo's vitest setup, target ≥80%) + +| Type | Target | Cases | +|---|---|---| +| Unit | `scene.js` | factory defaults; immutable update; deterministic id/seed hashing | +| Unit | `repair-graph.js` | each repair in isolation; correct `GraphIssue` level; dangling-edge drop; `strict` throws | +| Unit | `canvas-layout.js` | Blueprint depth layout matches `diagram.js`; pinned nodes excluded from relayout | +| Unit | `blueprint-adapter.js` | atoms/links → scene; missing layer default; roots highlighted | +| Unit | `tour.js` | fan-in/out ranking; BFS order; 5–15 steps; clusters grouped; never empty `nodeIds` | +| Unit | `canvas-export.js` | `toExcalidraw` is valid Excalidraw JSON; bindings reference real ids; SVG escapes text; deterministic output | +| Unit | `store.js` (scenes) | save/get/list/delete; export/import round-trip | +| Unit | `backup.js` | scenes survive build→validate; bad rows dropped; clamp respected | +| Integration | engine (jsdom) | drag persists position; connect adds edge; overlay toggles classes without relayout | + +## 14. Phase plan + +- **Phase 1 (this spec):** `scene.js`, `repair-graph.js`, `canvas-layout.js`, `canvas-engine.js`, + `blueprint-adapter.js`, `tour.js`, `tour-runner.js`, exporters, `scenes` store (v5), + backup/export round-trip, Canvas tab, color-by-layer + legend. **Ships the Blueprint + Tour + export.** +- **Phase 1.5 (fast-follow):** search-to-focus (BM25 over scene), Scan-Ledger **diff overlay** + (changed nodes light up on re-scan). +- **Phase 2 — Corkboard:** library-wide scene assembly from the `nodes`/`edges` stores + (`getEgoGraph` generalised to a full graph), persistent board positions, red-string by + relation, and **scoped layout** (folder/community grouping + edge aggregation + expand-on-demand, + ported conceptually from Understand-Anything's two-stage layout — custom, no ELK). +- **Phase 3 — Stack Studio:** pick 2–6 repos → generative wiring (reuse Combinator/Stack-Builder + plumbing) → roles/glue/gaps seeded onto the canvas. + +## 15. Risks & mitigations + +- **Engine scope creep** → strict Phase 1 boundary; Blueprint's ≤15 nodes keeps the first build honest. +- **Messy LLM graphs crashing render** → `repair-graph.js` + `strict` tests (§6). +- **Zero-build temptation to add a lib** → explicit non-goal; custom layout is small because + graphs are small in P1. +- **Tour narration cost** → defaults to existing Deep Dive text; LLM polish is opt-in. +- **A11y/motion regressions** → reduced-motion fallbacks + focusable nodes specified up front. + +## 16. Open questions + +1. Product name for the tab — "Canvas", "The Board", or a Case-File-flavoured name? (Provisional: **Canvas**.) +2. Should the Blueprint scene auto-seed on first Deep Dive completion, or only when the user opens the Canvas tab? (Provisional: **on tab open**, to avoid doing work users don't ask for.) diff --git a/eslint.config.js b/eslint.config.js index 0d1220e..7b0b7a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import globals from 'globals'; // intentionally light — advisory warnings, not a wall of errors — so the gate // is useful without demanding a rewrite of working code. export default [ - { ignores: ['node_modules/**', 'coverage/**', 'website/**', '.vitest/**'] }, + { ignores: ['node_modules/**', 'coverage/**', 'website/**', '.vitest/**', 'vendor/**'] }, js.configs.recommended, { files: ['**/*.js', '**/*.mjs'], diff --git a/library-scene.js b/library-scene.js new file mode 100644 index 0000000..cf22e55 --- /dev/null +++ b/library-scene.js @@ -0,0 +1,63 @@ +// library-scene.js +// Library graph (nodes/edges stores) + repo metadata → a 'corkboard' scene. +import { createScene } from './scene.js'; + +const idOf = (n) => String(n.repoId || n.title || n.nodeId || ''); + +/** + * @param {object} args + * @param {{nodes:any[], edges:any[]}} args.graph from store.getLibraryGraph() + * @param {Array<{repoId:string, fit?:string, health?:{score:number}, decision?:string}>} [args.repos] + * @param {string[]} [args.only] when set, keep only these repoIds (Collection filter) + * @returns {object} corkboard scene (id 'library') + */ +export function buildLibraryScene({ graph, repos = [], only = null }) { + const meta = Object.fromEntries(repos.map((r) => [r.repoId, r])); + const keep = only ? new Set(only) : null; + + const rawNodes = (graph?.nodes || []).filter((n) => { + const id = idOf(n); + if (!id) return false; + if (keep && n.kind === 'idea') { const src = n.sources || []; return src.length > 0 && src.every((s) => keep.has(s)); } + if (keep) return keep.has(id); + return true; + }); + + const nodes = rawNodes.map((n) => { + const id = idOf(n); + const m = meta[n.repoId] || {}; + return { + id, + label: n.kind === 'idea' ? String(n.title || 'idea') : String(n.name || id.split('/').pop() || id), + kind: n.kind === 'idea' ? 'idea' : 'repo', + layer: null, + x: 0, y: 0, pinned: false, + ref: { + repoId: n.repoId || null, + analyzed: !!n.analyzed, + fit: m.fit || null, + health: (m.health && Number.isFinite(m.health.score)) ? m.health.score : null, + decision: m.decision || null, + pitch: n.pitch || null, + sources: n.sources || null, + }, + }; + }); + + // Edges in the store reference the hashed node-store id (nodeIdFor), not the repoId. + // Map those back to scene node ids; also tolerate edges that already use the scene id. + const byNodeId = new Map(); + for (const n of rawNodes) { + const sid = idOf(n); + if (n.nodeId != null) byNodeId.set(String(n.nodeId), sid); + byNodeId.set(sid, sid); + } + const edges = (graph?.edges || []) + .map((e) => ({ id: String(e.id), from: byNodeId.get(String(e.source)), to: byNodeId.get(String(e.target)), rel: String(e.label || 'ALTERNATIVE_TO'), note: null, userDrawn: false })) + .filter((e) => e.from && e.to); + + const scene = createScene({ scope: 'corkboard', repoId: null, title: 'Library' }); + scene.nodes = nodes; + scene.edges = edges; + return scene; +} diff --git a/library.html b/library.html index 6ea79e8..ec03470 100644 --- a/library.html +++ b/library.html @@ -30,16 +30,39 @@ .lib-btn, .lib-settings { background: var(--panel); border: 1px solid var(--border); color: var(--sub); border-radius: 8px; padding: 6px 11px; cursor: pointer; font: 600 12px var(--sans); transition: color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); } - .lib-btn:hover, .lib-settings:hover { border-color: var(--border-2); color: var(--text); } - .lib-btn-danger:hover { border-color: var(--danger); color: var(--danger); } + .lib-btn:hover, .lib-settings:hover { border-color: var(--border-2); color: var(--text); + background: color-mix(in srgb, var(--text) 4%, var(--panel)); } + .lib-btn:focus-visible, .lib-settings:focus-visible { outline: none; + border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); } + .lib-btn-danger:hover { border-color: var(--danger); color: var(--danger); + background: color-mix(in srgb, var(--danger) 6%, var(--panel)); } .lib-btn.armed { border-color: var(--danger); color: var(--danger); } + .lib-btn-kbd { font-family: var(--mono); letter-spacing: .02em; } + + /* Grouped toolbar — a segmented view switcher + token-coloured dividers. */ + .lib-div { width: 1px; height: 20px; background: var(--border); + align-self: center; margin: 0 2px; flex-shrink: 0; } + .lib-seg { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; + background: var(--panel-2); border: 1px solid var(--border); } + .lib-seg-btn { border: none; background: transparent; color: var(--sub); + font: 600 12px var(--sans); padding: 6px 11px; border-radius: 7px; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-spring); } + .lib-seg-btn:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); } + .lib-seg-btn:active { transform: scale(.96); } + .lib-seg-btn:focus-visible { outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); } + .lib-seg-btn.on { color: var(--text); background: var(--panel); + border: 1px solid var(--border); box-shadow: 0 1px 3px -1px rgba(0,0,0,.3); } .lib-tagline { color: var(--sub); font-size: 13px; margin: 0 0 22px; } .lib-note { color: var(--muted); font-size: 12px; margin: -16px 0 18px; } .lib-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; } .lib-search { flex: 1 1 240px; min-width: 200px; background: var(--panel); border: 1px solid var(--border); - border-radius: 9px; padding: 10px 13px; color: var(--text); font: 400 13px var(--sans); } - .lib-search:focus { outline: none; border-color: var(--border-2); } + border-radius: 9px; padding: 10px 13px; color: var(--text); font: 400 13px var(--sans); + transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-spring), background-color var(--dur) var(--ease-out); } + .lib-search:focus { outline: none; border-color: var(--accent); background: var(--panel-2); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); } .lib-search::placeholder { color: var(--muted); } .nl-filter-banner { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 14px; margin-bottom: 10px; border-radius: 9px; @@ -62,8 +85,10 @@ /* Ask bar */ .lib-ask { display: flex; gap: 8px; margin: 0 0 12px; } .lib-ask-input { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 9px; - padding: 9px 13px; color: var(--text); font: 400 13px var(--sans); } - .lib-ask-input:focus { outline: none; border-color: var(--border-2); } + padding: 9px 13px; color: var(--text); font: 400 13px var(--sans); + transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-spring), background-color var(--dur) var(--ease-out); } + .lib-ask-input:focus { outline: none; border-color: var(--accent); background: var(--panel-2); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); } .lib-ask-input::placeholder { color: var(--muted); } .lib-ask-btn { background: var(--accent); color: #fff; border: none; border-radius: 9px; padding: 9px 18px; font: 600 13px var(--sans); cursor: pointer; white-space: nowrap; @@ -128,9 +153,17 @@ .bp-new { color: var(--cyan); border-top: 1px solid var(--border); border-radius: 0 0 7px 7px; margin-top: 2px; } #grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 14px; } - .lib-card { background: var(--panel); border: 1px solid var(--border); border-radius: 13px; padding: 15px 16px; + .lib-card { position: relative; overflow: hidden; background: var(--panel); border: 1px solid var(--border); border-radius: 13px; padding: 15px 16px; cursor: pointer; transition: transform var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out); display: flex; flex-direction: column; gap: 9px; } - .lib-card:hover { transform: translateY(-2px); border-color: var(--border-2); box-shadow: 0 14px 30px -18px rgba(0,0,0,.4); } + /* Hover spotlight — a soft radial highlight that follows the cursor. --mx/--my + are set by a single delegated pointermove listener on #grid (library.js). */ + .lib-card::before { content: ""; position: absolute; inset: 0; opacity: 0; pointer-events: none; border-radius: inherit; + background: radial-gradient(380px circle at var(--mx, 50%) var(--my, 0%), + color-mix(in srgb, var(--accent) 12%, transparent), transparent 42%); + transition: opacity var(--dur) var(--ease-out); } + .lib-card > * { position: relative; } + .lib-card:hover { transform: translateY(-3px); border-color: var(--border-2); box-shadow: 0 16px 34px -18px rgba(0,0,0,.45); } + .lib-card:hover::before { opacity: 1; } .lc-top { display: flex; align-items: center; gap: 9px; } .lc-name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; } .lc-owner { font: 500 11.5px/1 var(--mono); color: var(--muted); } @@ -189,6 +222,14 @@ .lib-empty { text-align: center; padding: 80px 20px; color: var(--sub); } .lib-empty h2 { font-size: 17px; color: var(--text); margin: 0 0 8px; } .lib-empty code { font: 600 12px var(--mono); background: var(--panel); border: 1px solid var(--border); padding: 2px 7px; border-radius: 5px; color: var(--cyan); } + /* Empty-state glyph — a magnifying glass that draws itself in. */ + .lib-empty-glyph { display: block; margin: 0 auto 18px; color: var(--accent); } + .lib-empty-glyph .leg { stroke-dasharray: 240; stroke-dashoffset: 240; } + @media (prefers-reduced-motion: no-preference) { + .lib-empty-glyph .leg { animation: lib-empty-draw 900ms var(--ease-out) forwards; } + .lib-empty-glyph .leg.l2 { animation-delay: 360ms; } + @keyframes lib-empty-draw { to { stroke-dashoffset: 0; } } + } /* Stats bar — a one-glance read on the shape of the library. */ .lib-stats { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin: 0 0 18px; @@ -419,20 +460,12 @@ #grid.selecting .lib-card:active { transform: none; } @media (prefers-reduced-motion: no-preference) { - /* Grid fills in with a short, capped stagger. `backwards` fill keeps the - hover lift working after the entrance (avoids `both` pinning transform). */ + /* Grid fills in with a short staggered rise. The per-card delay is set + inline (style="animation-delay:ms", capped in the card render), so + it keeps cascading past the first handful of cards. `backwards` fill keeps + the hover lift working after the entrance (avoids `both` pinning transform). */ .lib-card { animation: lib-card-in var(--dur-slow) var(--ease-out) backwards; } - @keyframes lib-card-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } - .lib-card:nth-child(1) { animation-delay: 0ms; } - .lib-card:nth-child(2) { animation-delay: 30ms; } - .lib-card:nth-child(3) { animation-delay: 60ms; } - .lib-card:nth-child(4) { animation-delay: 90ms; } - .lib-card:nth-child(5) { animation-delay: 120ms; } - .lib-card:nth-child(6) { animation-delay: 150ms; } - .lib-card:nth-child(7) { animation-delay: 180ms; } - .lib-card:nth-child(8) { animation-delay: 210ms; } - .lib-card:nth-child(9) { animation-delay: 240ms; } - .lib-card:nth-child(n+10) { animation-delay: 270ms; } + @keyframes lib-card-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } } .lib-card.jk-active { outline: 2px solid var(--cyan); outline-offset: 2px; } @@ -446,7 +479,7 @@ .radar-chip:hover { background: var(--border); color: var(--text); } .radar-empty-col { font-size: 12px; color: var(--muted); padding: 6px 0; display: block; } .radar-empty-msg { color: var(--muted); font-size: 13px; padding: 32px 0 16px; text-align: center; line-height: 1.6; } - #lib-btn-radar.on { color: var(--text); background: var(--surface); border-color: var(--border-2); } + /* Radar/Corkboard active state is now governed by .lib-seg-btn.on (segmented switcher). */ .radar-toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; } @media (max-width: 640px) { .radar-grid { grid-template-columns: repeat(2, 1fr); } } /* Keyboard hint bar */ @@ -505,20 +538,33 @@

Library

- + +
+ + + +
+ + + + + + - +

Every repo you've analyzed, at a glance — click a card to reopen its analysis, hover it to re-scan, open the source, or remove it.

@@ -597,6 +643,7 @@

Library

+
diff --git a/library.js b/library.js index 5824f55..931e720 100644 --- a/library.js +++ b/library.js @@ -3,7 +3,10 @@ // show), and each card manages its repo: click to reopen the saved analysis, hover for // re-scan / source / remove actions. -import { scrollPoints, deleteRepo, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots } from './store.js'; +import { scrollPoints, deleteRepo, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots, getLibraryGraph, getScene, saveScene } from './store.js'; +import { buildLibraryScene } from './library-scene.js'; +import { layoutCorkboard } from './canvas-layout.js'; +import { mountCanvas } from './canvas-engine.js'; import { rankRepos } from './store/search.js'; import { DECISION_META } from './decision-log.js'; import { makeCollection, validateCollectionName, addRepoToCollection, toggleRepoInCollection, collectionContains, sortedCollections, repoCollections, removeRepoFromCollection, nextColor, COLLECTION_COLORS } from './collections.js'; @@ -18,10 +21,47 @@ import { veeSvg } from './mascot.js'; import { initPalette } from './palette.js'; import { loadRubric, saveRubric, saveEval, clearEval, listEvals, computeScore, DEFAULT_RUBRIC } from './evaluations.js'; import { applyFilters } from './library-filters.js'; +// Vendored animation libs (local ES modules — never CDN; the MV3 CSP forbids remote scripts). +import confetti from './vendor/confetti.mjs'; +import { autoAnimate } from './vendor/auto-animate.mjs'; +import { CountUp } from './vendor/countup.mjs'; // Honour the user's chosen theme on this standalone page (sets ). initTheme(); +// Respect the OS "reduce motion" setting — used to skip count-up / confetti / etc. +const prefersReducedMotion = () => + typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + +// A restrained confetti burst, fired when a repo is marked "Adopt". Origin is +// optional (defaults to centre); particle count is deliberately small. No-op +// under reduced-motion. +function celebrateAdopt(origin) { + if (prefersReducedMotion()) return; + try { + confetti({ + particleCount: 36, + spread: 52, + startVelocity: 28, + ticks: 120, + scalar: 0.85, + origin: origin || { x: 0.5, y: 0.35 }, + disableForReducedMotion: true, + }); + } catch { /* confetti is decorative — never let it break a decision save */ } +} + +// Translate a DOM element's centre into confetti's normalized {x,y} origin. +function originFromEl(el) { + if (!el || typeof el.getBoundingClientRect !== 'function') return undefined; + const r = el.getBoundingClientRect(); + if (!r.width && !r.height) return undefined; + return { + x: (r.left + r.width / 2) / window.innerWidth, + y: (r.top + r.height / 2) / window.innerHeight, + }; +} + const MAX_BACKUP_BYTES = 50 * 1024 * 1024; // refuse absurd import files before parsing const LANG_COLORS = { @@ -31,6 +71,14 @@ const LANG_COLORS = { }; const langColor = (n) => LANG_COLORS[n] || '#64748b'; +// Static, code-owned empty-state glyph: a magnifying glass whose outline draws +// itself in (stroke-dashoffset keyframe in library.html, gated to no-preference). +// Colour comes from --accent via the .lib-empty-glyph rule (currentColor). +const EMPTY_GLYPH = ``; + // Highlight search terms in card text. Returns HTML with around matches. // Only used for plain text queries (not NL/AI filter). Safe: escapes all content. function hilite(text, q) { @@ -96,8 +144,11 @@ async function togglePin(repoId) { render(); } -function card(r) { +function card(r, i = 0) { const hq = !nlFilter && state.query.length >= 2 ? state.query.toLowerCase() : ''; + // Staggered reveal: each card enters ~40ms after the previous, capped at ~600ms + // so a large library doesn't crawl in. The @keyframes is gated to no-preference. + const revealDelay = Math.min(i, 15) * 40; const owner = r.repoId.includes('/') ? r.repoId.slice(0, r.repoId.indexOf('/')) : ''; const dots = r.languages .map((l) => ``) @@ -133,7 +184,7 @@ function card(r) { const evalBadge = evalScore !== null ? `` : ``; - return `
+ return `
${hilite(r.name, hq)} @@ -297,6 +348,25 @@ function wireGridEvents(grid) { clearTimeout(_hoverTimer); scheduleHidePreview(); }); + + // Hover spotlight: a single delegated pointermove updates the hovered card's + // --mx/--my so its ::before radial-gradient tracks the cursor. Compositor-only + // (it just drives a custom property); skipped entirely under reduced-motion. + if (!prefersReducedMotion()) { + grid.addEventListener('pointermove', (e) => { + const c = e.target.closest('.lib-card'); + if (!c) return; + const b = c.getBoundingClientRect(); + c.style.setProperty('--mx', `${e.clientX - b.left}px`); + c.style.setProperty('--my', `${e.clientY - b.top}px`); + }); + } + + // Smoothly reflow the grid when sort/filter changes reorder the cards. The + // Radar / Corkboard views hide #grid and render into their own hosts, so this + // never fights them. autoAnimate disables itself automatically under + // (prefers-reduced-motion: reduce). + autoAnimate(grid); } const rowFor = (repoId) => allRows.find((r) => r.repoId === repoId); @@ -760,6 +830,7 @@ async function bulkDecide(decision) { setSelectionMode(false, false); renderDecisionFilter(); render(); + if (decision === 'adopt') celebrateAdopt(); // one restrained burst for the batch setStatus(`Decision set to ${label} for ${ids.length} repo${ids.length === 1 ? '' : 's'}.`); } @@ -803,14 +874,16 @@ function renderStats() { ? `${triagePct}% triaged` : ''; host.innerHTML = String(html` - ${s.total} repo${s.total === 1 ? '' : 's'} + ${s.total} repo${s.total === 1 ? '' : 's'} ${triagePill} ${barSegments ? `${barSegments}` : ''} ${FIT_ORDER_ALL.map((lvl) => pill(lvl, s.byFit[lvl]))} - ${s.avgHealth != null ? html`avg health ${s.avgHealth}` : ''} + ${s.avgHealth != null ? html`avg health ${s.avgHealth}` : ''} ${stalePill} ${decSummary} `); + countUpStat(host.querySelector('.ls-total-n')); + countUpStat(host.querySelector('.ls-health-n')); host.querySelectorAll('[data-filter-dec]').forEach((btn) => { btn.addEventListener('click', () => { state.decision = btn.dataset.filterDec; @@ -821,6 +894,18 @@ function renderStats() { document.getElementById('refresh-stale')?.addEventListener('click', refreshStale); } +// Animate a single stat number from 0 → its value with the vendored CountUp. +// Under reduced-motion we skip the animation and leave the final value in place. +function countUpStat(el) { + if (!el) return; + const target = Number(el.dataset.count); + if (!Number.isFinite(target)) return; + if (prefersReducedMotion()) { el.textContent = String(target); return; } + const cu = new CountUp(el, target, { duration: 0.9, useGrouping: true }); + if (cu.error) { el.textContent = String(target); return; } + cu.start(); +} + function renderNlFilterBanner() { const host = document.getElementById('nl-filter-banner'); if (!host) return; @@ -1103,12 +1188,33 @@ function renderCaps() { const RADAR_ICONS = { adopt: '✅', trial: '🔬', hold: '⏸', reject: '🚫' }; +// Keep the segmented view switcher in lockstep with state.view. Grid is the +// "on" segment whenever no overlay view is active (state.view === 'list'). +function syncViewSwitcher() { + const map = { 'lib-btn-grid': 'list', 'lib-btn-radar': 'radar', 'lib-btn-corkboard': 'corkboard' }; + for (const [id, view] of Object.entries(map)) { + const b = document.getElementById(id); + if (!b) continue; + const on = state.view === view; + b.classList.toggle('on', on); + b.setAttribute('aria-pressed', String(on)); + } +} + +// Return to the default card grid from any overlay view (Radar / Corkboard). +function showGridView() { + if (state.view === 'radar') toggleRadarView(); + else if (state.view === 'corkboard') toggleCorkboardView(); + else syncViewSwitcher(); // already on the grid — just refresh the switcher +} + function toggleRadarView() { state.view = state.view === 'radar' ? 'list' : 'radar'; - const btn = document.getElementById('lib-btn-radar'); - btn?.classList.toggle('on', state.view === 'radar'); document.getElementById('radar-panel')?.classList.toggle('hidden', state.view !== 'radar'); document.getElementById('grid')?.classList.toggle('hidden', state.view === 'radar'); + // Ensure the Corkboard view is closed when the Radar opens. + document.getElementById('corkboard-panel')?.classList.add('hidden'); + syncViewSwitcher(); if (state.view === 'radar') renderRadar(); else render(); } @@ -1181,6 +1287,63 @@ function radarToMarkdown(byDecision) { return lines.join('\n').trim(); } +// ─── Corkboard view ────────────────────────────────────────────────────────── +// A red-string board of the library: the nodes/edges graph laid out on an +// interactive canvas. Mirrors the Radar view-toggle pattern (state.view). + +function toggleCorkboardView() { + state.view = state.view === 'corkboard' ? 'list' : 'corkboard'; + const on = state.view === 'corkboard'; + document.getElementById('corkboard-panel')?.classList.toggle('hidden', !on); + document.getElementById('grid')?.classList.toggle('hidden', on); + // Ensure the Radar view is closed when the Corkboard opens. + document.getElementById('radar-panel')?.classList.add('hidden'); + syncViewSwitcher(); + if (on) renderCorkboard(); else render(); +} + +let cbApi = null; +async function renderCorkboard() { + const panel = document.getElementById('corkboard-panel'); + if (!panel) return; + const graph = await getLibraryGraph(); + if (!graph.nodes.length) { + if (cbApi) { cbApi.destroy(); cbApi = null; } + panel.innerHTML = '
Scan a few repos — and run Alternatives / Synergies / Versus — to grow your board.
'; + return; + } + // Repo metadata (fit level + health) from the loaded rows. `r.fit.level` is the + // semantic fit key (strong/solid/care/risky); `r.health` is a 0–100 number. + const repos = (allRows || []).map((r) => ({ + repoId: r.repoId, + fit: (r.fit && r.fit.level) || null, + health: Number.isFinite(r.health) ? { score: r.health } : null, + decision: decisionMap.get(r.repoId)?.decision || null, + })); + // Collection filter: when a board is active, restrict to its repoIds. + let only = null; + if (state.collection) { + only = collections.find((c) => c.id === state.collection)?.repoIds || null; + } + const built = buildLibraryScene({ graph, repos, only }); + // Reuse a saved arrangement: keep saved positions, seed-layout the rest. + const saved = await getScene('library'); + const savedPos = saved ? Object.fromEntries((saved.nodes || []).map((n) => [n.id, n])) : {}; + const seeded = layoutCorkboard(built.nodes, built.edges); + built.nodes = seeded.map((n) => (savedPos[n.id] + ? { ...n, x: savedPos[n.id].x, y: savedPos[n.id].y, pinned: !!savedPos[n.id].pinned } + : n)); + panel.innerHTML = ''; + if (cbApi) cbApi.destroy(); + cbApi = mountCanvas(panel, built, { onChange: (s) => saveScene(s).catch(() => {}) }); + panel.querySelector('svg')?.addEventListener('dblclick', (ev) => { + const g = ev.target.closest('[data-node]'); + if (!g) return; + const id = g.dataset.node; + if (id && id.includes('/')) openRow(id); // repo node ids are "owner/name" + }); +} + // ─── Decision filter ───────────────────────────────────────────────────────── function renderDecisionFilter() { @@ -1920,6 +2083,7 @@ function showQuickDecision(repoId, anchorEl) { (current ? `` : ''); async function pick(d) { + const origin = originFromEl(anchorEl); pop.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); @@ -1927,6 +2091,7 @@ function showQuickDecision(repoId, anchorEl) { const rec = { repoId, decision: d, savedAt: new Date().toISOString() }; await saveDecision(rec); decisionMap.set(repoId, rec); + if (d === 'adopt') celebrateAdopt(origin); } else { const { clearDecision } = await import('./store.js'); await clearDecision(repoId); @@ -2164,16 +2329,19 @@ async function applyVeeSuggestions() { if (!undecided.length) { setStatus('All rated repos already have a decision.'); return; } const now = new Date().toISOString(); setStatus(`Applying Vee's suggestions to ${undecided.length} repos…`); + let adopted = 0; for (const row of undecided) { const decision = FIT_SUGGESTION[row.fit.level]; if (!decision) continue; const rec = { repoId: row.repoId, decision, savedAt: now }; await saveDecision(rec); decisionMap.set(row.repoId, rec); + if (decision === 'adopt') adopted++; } renderDecisionFilter(); renderStats(); render(); + if (adopted) celebrateAdopt(); // one restrained burst when Vee adopts anything setStatus(`Vee auto-decided ${undecided.length} repo${undecided.length === 1 ? '' : 's'}.`); } @@ -2254,7 +2422,8 @@ function initLibraryPalette() { render(); } }, { section: 'View', name: 'Tech Radar', description: 'Organize repos by Adopt/Trial/Hold/Reject decision', action: () => { if (state.view !== 'radar') toggleRadarView(); } }, - { name: 'List view', description: 'Default card grid', action: () => { if (state.view !== 'list') toggleRadarView(); } }, + { name: 'Corkboard', description: 'A red-string board of your library', action: () => { if (state.view !== 'corkboard') toggleCorkboardView(); } }, + { name: 'List view', description: 'Default card grid', action: () => { if (state.view === 'radar') toggleRadarView(); else if (state.view === 'corkboard') toggleCorkboardView(); } }, { section: 'Pins', name: 'Unpin all', description: 'Remove all pinned repos from the top section', action: async () => { pinned.clear(); await chrome.storage.local.set({ repolens_pinned: [] }); render(); } }, { section: 'Actions', name: 'Auto-organize by language', description: 'Group repos into language collections', action: () => autoOrganize() }, { name: 'Re-scan all stale (30+ days)', description: 'Open Batch Scan pre-filled with repos not scanned in 30 days', action: () => refreshStale() }, @@ -2310,7 +2479,9 @@ function initLibraryPalette() { async function init() { document.getElementById('settings')?.addEventListener('click', () => chrome.runtime.openOptionsPage()); + document.getElementById('lib-btn-grid')?.addEventListener('click', showGridView); document.getElementById('lib-btn-radar')?.addEventListener('click', toggleRadarView); + document.getElementById('lib-btn-corkboard')?.addEventListener('click', toggleCorkboardView); wireToolbar(); // before the empty-state return, so Import works on an empty library const [points, cachedList, prefs, savedCollections, savedDecisions] = await Promise.all([ @@ -2381,8 +2552,9 @@ async function init() { } if (!allRows.length) { - // veeSvg() is a static, code-owned string — safe for the STATIC-only showEmpty. - const vee = mascotOn ? `` : ''; + // veeSvg() and EMPTY_GLYPH are static, code-owned strings — safe for the + // STATIC-only showEmpty (no user data ever reaches innerHTML here). + const vee = mascotOn ? `` : EMPTY_GLYPH; showEmpty( `${vee}

No repos yet

Open any GitHub / GitLab / npm / PyPI page and click the RepoLens icon —
every scan lands here automatically.

` ); diff --git a/output-tab.html b/output-tab.html index 8318006..43cbcfc 100644 --- a/output-tab.html +++ b/output-tab.html @@ -859,6 +859,7 @@
+ @@ -913,6 +914,7 @@
+
diff --git a/output-tab.js b/output-tab.js index 95edf83..8229e57 100644 --- a/output-tab.js +++ b/output-tab.js @@ -28,6 +28,13 @@ import { initPalette } from './palette.js'; import { toggleRepoInCollection, collectionContains, sortedCollections, COLLECTION_COLORS } from './collections.js'; import { detectPlatform } from './url-detector.js'; import { listSnapshots } from './store.js'; +import { hashId } from './scene.js'; +import { buildBlueprintScene } from './blueprint-adapter.js'; +import { mountCanvas } from './canvas-engine.js'; +import { buildTour } from './tour.js'; +import { startTour } from './tour-runner.js'; +import { toCanvasSvg, toExcalidraw } from './canvas-export.js'; +import { getScene, saveScene } from './store.js'; import { snapshotTrend, sparkline } from './snapshots.js'; // Apply the saved theme ASAP (before render) to minimise flash. @@ -528,6 +535,57 @@ function renderConnections(d) { cnDraw(host, d, [d.repoId]); } +// ─── Canvas tab — interactive Blueprint built from Deep Dive atoms + lineage ── +// Mounts lazily on tab open (like Connections) and only once per page: until the +// Deep Dive has run, it shows a CTA; once atoms exist it builds/loads the scene, +// wires the engine, a guided tour, and SVG/.excalidraw export. +async function renderCanvas(d) { + const hostWrap = document.querySelector('#t27 .canvas-host'); + if (!hostWrap || hostWrap.dataset.mounted === '1') return; // mount once per page + const dd = d && d.deepDive; + if (!dd || !dd.atoms || !dd.atoms.length) { + hostWrap.innerHTML = '
Run Deep Dive first — the Blueprint is built from its atoms & lineage.
'; + return; + } + const sceneId = 'repo:' + hashId(d.repoId); + let scene = await getScene(sceneId); + if (!scene) { + scene = buildBlueprintScene({ deepDive: dd, repoId: d.repoId, title: d.repoId, scanAt: d.saved_at || null }); + await saveScene(scene); + } + hostWrap.dataset.mounted = '1'; + const api = mountCanvas(hostWrap, scene, { onChange: (s) => saveScene(s).catch(() => {}) }); + + const bar = document.createElement('div'); + bar.className = 'canvas-export-bar'; + const tourBtn = document.createElement('button'); tourBtn.textContent = '▶ Guided Tour'; + let activeTour = null; + tourBtn.onclick = () => { + if (activeTour) activeTour.exit(); // tear down a prior tour first — don't stack cards/listeners on re-launch + activeTour = startTour({ host: hostWrap, engine: api, steps: buildTour(api.getScene(), { roots: (dd.lineage && dd.lineage.roots) || [] }), autoplay: false }); + }; + const exEx = document.createElement('button'); exEx.textContent = '.excalidraw'; + exEx.onclick = () => download(`${slugify(d.repoId)}.excalidraw`, 'application/json', toExcalidraw(api.getScene())); + const exSvg = document.createElement('button'); exSvg.textContent = 'SVG'; + exSvg.onclick = () => download(`${slugify(d.repoId)}.svg`, 'image/svg+xml', toCanvasSvg(api.getScene())); + bar.append(tourBtn, exEx, exSvg); + hostWrap.appendChild(bar); + + const legend = document.createElement('div'); legend.className = 'canvas-legend'; + for (const [k, lab] of [['entrypoint', 'Entry'], ['subsystem', 'Core'], ['module', 'Module'], ['data', 'Data'], ['concept', 'Concept']]) { + const sw = document.createElement('span'); sw.className = `lg lg-${k}`; sw.textContent = lab; legend.appendChild(sw); + } + hostWrap.appendChild(legend); +} + +// Anchor-download helper (no equivalent named helper exists in this module). +function download(filename, type, content) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + // ─── Combinator tab — fuse complementary library repos into new project ideas ─ function startCombinator(d) { const mode = document.querySelector('input[name="cb-mode"]:checked')?.value || 'repo'; @@ -2092,13 +2150,16 @@ const TAB_SLUGS = { 15: 'tech-stack', 10: 'deep-dive', 11: 'systems', 12: 'ideate', 13: 'prioritize', 14: 'sktpg', 21: 'docs', 22: 'maintenance', 23: 'license', 24: 'diff', 25: 'stack-fit', 26: 'ask', 16: 'similar', 17: 'versus', 18: 'synergies', - 19: 'connections', 20: 'combine', + 19: 'connections', 20: 'combine', 27: 'canvas', }; const SLUG_TO_TAB = Object.fromEntries(Object.entries(TAB_SLUGS).map(([k, v]) => [v, Number(k)])); function show(n, { updateHash = true } = {}) { document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.tab) === n)); document.querySelectorAll('.tab-content').forEach((c, idx) => c.classList.toggle('active', idx === n)); + // Blueprint canvas mounts lazily on every activation path (click, #hash deep-link, per-repo restore). + // renderCanvas is idempotent (dataset.mounted guard) and null-safe, so calling it on each show(27) is cheap. + if (n === 27) renderCanvas(lastData).catch((err) => console.error('[canvas] render failed', err)); // Reflect the active tab on its parent menu button + close any open menus. document.querySelectorAll('.tab-menu').forEach(m => { const owns = [...m.querySelectorAll('.tab-btn')].some(b => Number(b.dataset.tab) === n); @@ -2129,6 +2190,7 @@ document.querySelector('.tab-nav')?.addEventListener('click', e => { const n = Number(btn.dataset.tab); show(n); if (n === 19) renderConnections(lastData); // network tab — pull fresh on each open (like Similar) + // (canvas, tab 27, is dispatched inside show() so deep-link/restore paths render it too) } }); diff --git a/repair-graph.js b/repair-graph.js new file mode 100644 index 0000000..52e417d --- /dev/null +++ b/repair-graph.js @@ -0,0 +1,81 @@ +// 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; } diff --git a/scene.js b/scene.js new file mode 100644 index 0000000..7092776 --- /dev/null +++ b/scene.js @@ -0,0 +1,49 @@ +// 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 }; +} diff --git a/settings-backup.js b/settings-backup.js index 7321525..859934a 100644 --- a/settings-backup.js +++ b/settings-backup.js @@ -25,6 +25,8 @@ export const SAFE_SETTING_KEYS = [ 'xaiModel', 'nousModel', 'librarySort', + 'canvasEnabled', + 'canvasTourAutoplay', ]; const pickSafe = (src) => { diff --git a/stack-demo.html b/stack-demo.html new file mode 100644 index 0000000..796ba21 --- /dev/null +++ b/stack-demo.html @@ -0,0 +1,60 @@ + + + + + Stack Studio demo — RepoLens + + + + +

⚙ RepoLens — Stack Studio (live demo)

+

Real pipeline: a Tech-Stack Builder result → buildStackScenelayoutStackmountCanvas. Repos in adoption order, integrations as string, gaps as dashed cards.

+
+
+ frontend + backend + data + infra + testing + tooling + gap +
+ + + + diff --git a/stack-scene.js b/stack-scene.js new file mode 100644 index 0000000..01ed962 --- /dev/null +++ b/stack-scene.js @@ -0,0 +1,40 @@ +// stack-scene.js +// Tech-Stack Builder result → a 'stack'-scope canvas scene. +import { createScene } from './scene.js'; + +/** + * @param {{title?:string, roles?:any[], integrations?:any[], gaps?:any[], order?:string[]}} result + * @param {string} [title] + * @returns {object} stack scene + */ +export function buildStackScene(result, title) { + const roles = (result && result.roles) || []; + const integrations = (result && result.integrations) || []; + const gaps = (result && result.gaps) || []; + const order = (result && result.order) || []; + + const nodes = roles.map((r) => ({ + id: String(r.repoId), + label: String(r.repoId).split('/').pop() || String(r.repoId), + kind: 'repo', + layer: r.layer || null, + x: 0, y: 0, pinned: false, + ref: { repoId: r.repoId, role: r.role || null }, + })); + const repoIds = new Set(nodes.map((n) => n.id)); + + gaps.forEach((g, i) => nodes.push({ + id: `gap:${i}`, label: String(g), kind: 'gap', layer: null, + x: 0, y: 0, pinned: false, ref: { gap: true }, + })); + + const edges = integrations + .filter((it) => it && repoIds.has(String(it.from)) && repoIds.has(String(it.to))) + .map((it, i) => ({ id: `int:${i}`, from: String(it.from), to: String(it.to), rel: 'integrates', note: it.glue || null, userDrawn: false })); + + const scene = createScene({ scope: 'stack', repoId: null, title: title || (result && result.title) || 'Stack' }); + scene.nodes = nodes; + scene.edges = edges; + scene.source = { ...scene.source, order }; + return scene; +} diff --git a/stack-tab.html b/stack-tab.html index 7424f61..4e28bd4 100644 --- a/stack-tab.html +++ b/stack-tab.html @@ -80,6 +80,8 @@
Building your stack…
+
+ diff --git a/stack-tab.js b/stack-tab.js index 551c05c..3cd00ca 100644 --- a/stack-tab.js +++ b/stack-tab.js @@ -1,6 +1,9 @@ // Stack Builder output tab — polls chrome.storage.session for the build result. import { initTheme } from './theme.js'; import { STACK_LAYERS } from './stack-prompt.js'; +import { buildStackScene } from './stack-scene.js'; +import { layoutStack } from './canvas-layout.js'; +import { mountCanvas } from './canvas-engine.js'; initTheme(); @@ -10,6 +13,28 @@ const main = document.getElementById('main'); const esc = (s) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +// ── Stack Studio: render the result on the interactive canvas (toggle) ── +let latestResult = null; +let stackCanvasApi = null; +function toggleStackCanvas() { + const host = document.getElementById('stack-canvas'); + const btn = document.getElementById('stack-view-canvas'); + if (!host || !latestResult) return; + if (!host.classList.contains('hidden')) { + host.classList.add('hidden'); + if (stackCanvasApi) { stackCanvasApi.destroy(); stackCanvasApi = null; } + if (btn) btn.textContent = '▦ View on canvas'; + return; + } + const scene = buildStackScene(latestResult, latestResult.title); + scene.nodes = layoutStack(scene.nodes, (scene.source && scene.source.order) || []); + host.innerHTML = ''; + host.classList.remove('hidden'); + stackCanvasApi = mountCanvas(host, scene, {}); + if (btn) btn.textContent = '▤ Hide canvas'; +} +document.getElementById('stack-view-canvas')?.addEventListener('click', toggleStackCanvas); + function render(data) { if (!data) { main.innerHTML = `
Waiting for data…
`; @@ -34,6 +59,8 @@ function render(data) { } document.title = `RepoLens — ${r.title || 'Stack Builder'}`; + latestResult = r; + document.getElementById('stack-view-canvas')?.removeAttribute('hidden'); const rolesHtml = (r.roles || []).map(role => { const layer = STACK_LAYERS.includes(role.layer) ? role.layer : 'tooling'; diff --git a/store.js b/store.js index b72cb97..4c8b3b5 100644 --- a/store.js +++ b/store.js @@ -237,6 +237,40 @@ export async function listDecisions() { } } +// ─── Canvas scenes ──────────────────────────────────────────────────────────── + +/** Persist a canvas scene (upsert by id). Throws on failure. */ +export async function saveScene(scene) { + if (!scene || !scene.id) throw new Error('saveScene: scene.id required'); + await idbPut('scenes', scene); +} + +/** Get a single scene by id. Returns null if not found or on store error. */ +export async function getScene(id) { + try { + return (await idbGet('scenes', String(id))) || null; + } catch { + return null; + } +} + +/** All scenes, optionally filtered by repoId. Best-effort — [] on failure. */ +export async function listScenes(repoId) { + try { + const all = (await idbGetAll('scenes')) || []; + return repoId == null ? all : all.filter((s) => s.repoId === repoId); + } catch { + return []; + } +} + +/** Delete a scene by id. Best-effort — never throws. */ +export async function deleteScene(id) { + try { + await idbDelete('scenes', String(id)); + } catch { /* store unavailable */ } +} + // ─── graph: nodes + edges for the Connections tab ───────────────────────────── /** Upsert a graph node's payload (idempotent by id). Throws on failure (callers wrap best-effort). */ @@ -249,6 +283,19 @@ export async function addEdge({ id, source, target, label, properties = {} }) { await idbPut('edges', { id: String(id), source: String(source), target: String(target), label, properties }); } +/** The whole library graph: every node payload + every edge. Best-effort — empty on failure. */ +export async function getLibraryGraph() { + try { + const [nodeRows, edges] = await Promise.all([idbGetAll('nodes'), idbGetAll('edges')]); + return { + nodes: (nodeRows || []).filter((r) => r && r.payload).map((r) => ({ nodeId: String(r.id), ...r.payload })), + edges: (edges || []), + }; + } catch { + return { nodes: [], edges: [] }; + } +} + /** Ring-1 ego graph for one repo. Returns { center, edges, neighbors } or null on failure. */ export async function getEgoGraph(repoId) { const centerId = hashRepoId(repoId); @@ -276,15 +323,16 @@ const validRows = (rows) => (rows || []).filter((r) => r && r.id != null); /** Gather every row from all stores for a backup envelope. */ export async function exportStores() { - const [repos, nodes, edges, collections, decisions, snapshots] = await Promise.all([ + const [repos, nodes, edges, collections, decisions, snapshots, scenes] = await Promise.all([ idbGetAll('repos'), idbGetAll('nodes'), idbGetAll('edges'), idbGetAll('collections'), idbGetAll('decisions'), idbGetAll('snapshots'), + idbGetAll('scenes'), ]); - return { repos: repos || [], nodes: nodes || [], edges: edges || [], collections: collections || [], decisions: decisions || [], snapshots: snapshots || [] }; + return { repos: repos || [], nodes: nodes || [], edges: edges || [], collections: collections || [], decisions: decisions || [], snapshots: snapshots || [], scenes: scenes || [] }; } /** @@ -296,21 +344,22 @@ export async function exportStores() { * @param {{ repos?: object[], nodes?: object[], edges?: object[] }} rows * @param {{ mode?: 'merge'|'replace' }} [opts] */ -export async function importStores({ repos = [], nodes = [], edges = [], collections = [], decisions = [], snapshots = [] } = {}, { mode = 'merge' } = {}) { +export async function importStores({ repos = [], nodes = [], edges = [], collections = [], decisions = [], snapshots = [], scenes = [] } = {}, { mode = 'merge' } = {}) { if (mode === 'replace') { - await Promise.all([idbClear('repos'), idbClear('nodes'), idbClear('edges'), idbClear('collections'), idbClear('decisions'), idbClear('snapshots')]); + await Promise.all([idbClear('repos'), idbClear('nodes'), idbClear('edges'), idbClear('collections'), idbClear('decisions'), idbClear('snapshots'), idbClear('scenes')]); } - const vr = validRows(repos), vn = validRows(nodes), ve = validRows(edges), vc = validRows(collections), vd = validRows(decisions), vs = validRows(snapshots); + const vr = validRows(repos), vn = validRows(nodes), ve = validRows(edges), vc = validRows(collections), vd = validRows(decisions), vs = validRows(snapshots), vsc = validRows(scenes); for (const row of vr) await idbPut('repos', row); for (const row of vn) await idbPut('nodes', row); for (const row of ve) await idbPut('edges', row); for (const row of vc) await idbPut('collections', row); for (const row of vd) await idbPut('decisions', row); for (const row of vs) await idbPut('snapshots', row); - return { repos: vr.length, nodes: vn.length, edges: ve.length, collections: vc.length, decisions: vd.length, snapshots: vs.length }; + for (const row of vsc) await idbPut('scenes', row); + return { repos: vr.length, nodes: vn.length, edges: ve.length, collections: vc.length, decisions: vd.length, snapshots: vs.length, scenes: vsc.length }; } /** Wipe the whole library (all stores). Backs the "Clear library" action. */ export async function clearLibrary() { - await Promise.all([idbClear('repos'), idbClear('nodes'), idbClear('edges'), idbClear('collections'), idbClear('decisions'), idbClear('snapshots')]); + await Promise.all([idbClear('repos'), idbClear('nodes'), idbClear('edges'), idbClear('collections'), idbClear('decisions'), idbClear('snapshots'), idbClear('scenes')]); } diff --git a/store/idb.js b/store/idb.js index 592a4e6..86f3d6b 100644 --- a/store/idb.js +++ b/store/idb.js @@ -3,10 +3,11 @@ const DB_NAME = 'repolens'; // v2 added the 'collections' store. v3 added the 'decisions' store. v4 added the -// 'snapshots' store (the Scan Ledger). Each upgrade is additive — onupgradeneeded -// creates any store in STORES that doesn't already exist, so existing data survives. -const DB_VERSION = 4; -const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots']; +// 'snapshots' store (the Scan Ledger). v5 added the 'scenes' store (Canvas Engine). +// Each upgrade is additive — onupgradeneeded creates any store in STORES that +// doesn't already exist, so existing data survives. +const DB_VERSION = 5; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes']; let dbPromise = null; diff --git a/tests/backup-scenes.test.js b/tests/backup-scenes.test.js new file mode 100644 index 0000000..1608504 --- /dev/null +++ b/tests/backup-scenes.test.js @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { buildBackup, validateBackup } from '../backup.js'; + +const scene = { id: 'repo:1', scope: 'blueprint', repoId: 'a/b', nodes: [], edges: [], annotations: [] }; + +describe('backup with scenes', () => { + it('includes scenes in the envelope and count', () => { + const env = buildBackup({ repos: [], scenes: [scene] }); + expect(env.scenes).toHaveLength(1); + expect(env.counts.scenes).toBe(1); + }); + it('keeps valid scenes and drops malformed rows on validate', () => { + const env = buildBackup({ scenes: [scene, { nope: true }] }); + const r = validateBackup(env); + expect(r.ok).toBe(true); + expect(r.value.scenes).toHaveLength(1); + }); +}); diff --git a/tests/backup.test.js b/tests/backup.test.js index 9535e5d..5827da3 100644 --- a/tests/backup.test.js +++ b/tests/backup.test.js @@ -19,12 +19,12 @@ describe('buildBackup', () => { expect(b.format).toBe(BACKUP_FORMAT); expect(b.version).toBe(BACKUP_VERSION); expect(b.exportedAt).toBe('2026-06-12T00:00:00.000Z'); - expect(b.counts).toEqual({ repos: 2, nodes: 1, edges: 1, cache: 1, collections: 0, decisions: 0, snapshots: 0 }); + expect(b.counts).toEqual({ repos: 2, nodes: 1, edges: 1, cache: 1, collections: 0, decisions: 0, snapshots: 0, scenes: 0 }); expect(b.repos).toEqual(repos); }); it('tolerates missing sections (empty library export)', () => { const b = buildBackup(); - expect(b.counts).toEqual({ repos: 0, nodes: 0, edges: 0, cache: 0, collections: 0, decisions: 0, snapshots: 0 }); + expect(b.counts).toEqual({ repos: 0, nodes: 0, edges: 0, cache: 0, collections: 0, decisions: 0, snapshots: 0, scenes: 0 }); expect(b.repos).toEqual([]); expect(typeof b.exportedAt).toBe('string'); }); @@ -75,7 +75,7 @@ describe('validateBackup', () => { }); it('always returns a safe normalized value even on failure', () => { const { value } = validateBackup(undefined); - expect(value).toEqual({ repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [] }); + expect(value).toEqual({ repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [], scenes: [] }); }); it('clamps oversized sections and warns instead of importing unbounded rows', () => { const repos = Array.from({ length: 5001 }, (_, i) => ({ id: i + 1, payload: { repoId: `o/r${i}` } })); @@ -108,7 +108,7 @@ describe('collections in the envelope', () => { describe('summarizeBackup', () => { it('counts importable rows from the actual data, not the self-reported counts', () => { const lying = { format: BACKUP_FORMAT, version: 1, counts: { repos: 999 }, repos: [{ id: 1, payload: { repoId: 'a/b' } }] }; - expect(summarizeBackup(lying)).toEqual({ repos: 1, nodes: 0, edges: 0, cache: 0, collections: 0, decisions: 0, snapshots: 0 }); + expect(summarizeBackup(lying)).toEqual({ repos: 1, nodes: 0, edges: 0, cache: 0, collections: 0, decisions: 0, snapshots: 0, scenes: 0 }); }); }); diff --git a/tests/blueprint-adapter.test.js b/tests/blueprint-adapter.test.js new file mode 100644 index 0000000..5aa152b --- /dev/null +++ b/tests/blueprint-adapter.test.js @@ -0,0 +1,33 @@ +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); + }); +}); diff --git a/tests/canvas-engine.test.js b/tests/canvas-engine.test.js new file mode 100644 index 0000000..fe077e7 --- /dev/null +++ b/tests/canvas-engine.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { edgeBezier, NODE_W, NODE_H, nodeClass } from '../canvas-engine.js'; + +describe('edgeBezier', () => { + it('starts at the source node right-middle and ends at the target left-middle', () => { + const d = edgeBezier({ x: 0, y: 0 }, { x: 300, y: 0 }); + expect(d.startsWith(`M${NODE_W},${NODE_H / 2}`)).toBe(true); + expect(d.includes(`300,${NODE_H / 2}`)).toBe(true); + }); + it('emits exactly one cubic-bezier segment', () => { + const d = edgeBezier({ x: 10, y: 20 }, { x: 100, y: 80 }); + expect((d.match(/C/g) || []).length).toBe(1); + }); + it('routes the control points to the horizontal midpoint', () => { + const d = edgeBezier({ x: 0, y: 0 }, { x: 200, y: 0 }); + const mx = (0 + NODE_W + 200) / 2; + expect(d).toContain(`C${mx},`); + }); +}); + +describe('nodeClass', () => { + it('includes kind, root, and fit when present', () => { + expect(nodeClass({ kind: 'repo', ref: { root: false, fit: 'strong' } })).toBe('rl-node rl-kind-repo rl-fit-strong'); + expect(nodeClass({ kind: 'module', ref: { root: true } })).toBe('rl-node rl-kind-module is-root'); + expect(nodeClass({ kind: 'data', ref: {} })).toBe('rl-node rl-kind-data'); + }); + it('includes a layer class when node.layer is set', () => { + expect(nodeClass({ kind: 'repo', layer: 'backend', ref: {} })).toBe('rl-node rl-kind-repo rl-layer-backend'); + }); +}); diff --git a/tests/canvas-export.test.js b/tests/canvas-export.test.js new file mode 100644 index 0000000..a2ae4f5 --- /dev/null +++ b/tests/canvas-export.test.js @@ -0,0 +1,53 @@ +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 with a node label and escaped annotation text', () => { + const svg = toCanvasSvg(scene); + expect(svg.startsWith(''); + }); + + it('coerces non-numeric coordinates so they cannot inject into SVG attributes', () => { + const evil = { + nodes: [{ id: 'x', label: 'X', kind: 'module', layer: null, x: '40" onload="alert(1)', y: 0, pinned: false, ref: {} }], + edges: [], + annotations: [{ id: 'a', x: '0">