From a1b3fc8291ec71f83e2ba28b87e255264a32be12 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:53:17 -0700 Subject: [PATCH 01/41] docs: interactive canvas design spec (engine + Blueprint + Guided Tour) --- .../2026-06-15-interactive-canvas-design.md | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-interactive-canvas-design.md 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.) From 6a82e9256575b749ca25f3fa5e218c7e9ad57ad2 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:00:14 -0700 Subject: [PATCH 02/41] docs: interactive canvas Phase 1 implementation plan --- .../plans/2026-06-15-interactive-canvas.md | 1570 +++++++++++++++++ 1 file changed, 1570 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-interactive-canvas.md 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). From 2625dc273f53a24072c079afd50c7f366bde7f33 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:06:26 -0700 Subject: [PATCH 03/41] feat(canvas): pure scene model (hashId, createScene, withNodePos, validateScene) --- scene.js | 49 +++++++++++++++++++++++++++++++++++++++++++++ tests/scene.test.js | 48 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 scene.js create mode 100644 tests/scene.test.js 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/tests/scene.test.js b/tests/scene.test.js new file mode 100644 index 0000000..06ff7bd --- /dev/null +++ b/tests/scene.test.js @@ -0,0 +1,48 @@ +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 }); + 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/); + }); +}); From 7f404b8abea8cf4ab2347cf8a866930d653ed8fb Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:09:06 -0700 Subject: [PATCH 04/41] =?UTF-8?q?feat(canvas):=20repairGraph=20=E2=80=94?= =?UTF-8?q?=20tame=20messy=20LLM=20node/edge=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repair-graph.js | 81 ++++++++++++++++++++++++++++++++++++++ tests/repair-graph.test.js | 46 ++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 repair-graph.js create mode 100644 tests/repair-graph.test.js 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/tests/repair-graph.test.js b/tests/repair-graph.test.js new file mode 100644 index 0000000..81ced0a --- /dev/null +++ b/tests/repair-graph.test.js @@ -0,0 +1,46 @@ +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'); + expect(nodes[1].kind).toBe('subsystem'); + expect(edges[0].rel).toBe('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(); + }); +}); From 2d970305ee39f7dab097ce273d49c3d6dd5b62ba Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:10:57 -0700 Subject: [PATCH 05/41] =?UTF-8?q?feat(canvas):=20layoutBlueprint=20?= =?UTF-8?q?=E2=80=94=20cycle-safe=20layered=20DAG=20positions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- canvas-layout.js | 35 +++++++++++++++++++++++++++++++++++ tests/canvas-layout.test.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 canvas-layout.js create mode 100644 tests/canvas-layout.test.js diff --git a/canvas-layout.js b/canvas-layout.js new file mode 100644 index 0000000..41ecf05 --- /dev/null +++ b/canvas-layout.js @@ -0,0 +1,35 @@ +// 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 })); +} diff --git a/tests/canvas-layout.test.js b/tests/canvas-layout.test.js new file mode 100644 index 0000000..d8ea2cb --- /dev/null +++ b/tests/canvas-layout.test.js @@ -0,0 +1,35 @@ +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(); + }); +}); From 2761b865b20cd804180c1c0e69c5f16872e311e6 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:13:00 -0700 Subject: [PATCH 06/41] =?UTF-8?q?feat(canvas):=20blueprint-adapter=20?= =?UTF-8?q?=E2=80=94=20Deep=20Dive=20atoms/lineage=20=E2=86=92=20scene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blueprint-adapter.js | 40 +++++++++++++++++++++++++++++++++ tests/blueprint-adapter.test.js | 33 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 blueprint-adapter.js create mode 100644 tests/blueprint-adapter.test.js diff --git a/blueprint-adapter.js b/blueprint-adapter.js new file mode 100644 index 0000000..1e33b21 --- /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 + 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; +} 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); + }); +}); From 00f8e296e580d4e25d59e9490b44187565c75441 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:15:23 -0700 Subject: [PATCH 07/41] =?UTF-8?q?feat(canvas):=20buildTour=20=E2=80=94=20d?= =?UTF-8?q?ependency-ordered=20guided=20tour=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tour.test.js | 33 ++++++++++++++++++++++++++++ tour.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/tour.test.js create mode 100644 tour.js diff --git a/tests/tour.test.js b/tests/tour.test.js new file mode 100644 index 0000000..3c96419 --- /dev/null +++ b/tests/tour.test.js @@ -0,0 +1,33 @@ +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); + }); +}); diff --git a/tour.js b/tour.js new file mode 100644 index 0000000..e7b9957 --- /dev/null +++ b/tour.js @@ -0,0 +1,55 @@ +// 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}).`, + }; + }); +} From 360bb937d751a1641b9d548defe0f2a25af37d73 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:17:32 -0700 Subject: [PATCH 08/41] feat(canvas): toCanvasSvg + toExcalidraw exporters --- canvas-export.js | 90 +++++++++++++++++++++++++++++++++++++ tests/canvas-export.test.js | 42 +++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 canvas-export.js create mode 100644 tests/canvas-export.test.js diff --git a/canvas-export.js b/canvas-export.js new file mode 100644 index 0000000..c4d039c --- /dev/null +++ b/canvas-export.js @@ -0,0 +1,90 @@ +// 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); +} diff --git a/tests/canvas-export.test.js b/tests/canvas-export.test.js new file mode 100644 index 0000000..9649e7f --- /dev/null +++ b/tests/canvas-export.test.js @@ -0,0 +1,42 @@ +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'); + 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)); + }); +}); From 6608d0a6549a2f16445fc6415b83ae0ef3d3a8ac Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:26:16 -0700 Subject: [PATCH 09/41] fix(canvas): coerce coordinates to numbers in exporters (SVG attr-injection guard) --- canvas-export.js | 42 ++++++++++++++++++++++--------------- tests/canvas-export.test.js | 11 ++++++++++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/canvas-export.js b/canvas-export.js index c4d039c..d5f3b1a 100644 --- a/canvas-export.js +++ b/canvas-export.js @@ -5,6 +5,10 @@ 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) { @@ -12,27 +16,29 @@ export function toCanvasSvg(scene) { 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 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 = a.x + NW, y1 = a.y + NH / 2, x2 = b.x, y2 = b.y + NH / 2, mx = (x1 + x2) / 2; + const x1 = num(a.x) + NW, y1 = num(a.y) + NH / 2, x2 = num(b.x), y2 = num(b.y) + NH / 2, mx = (x1 + x2) / 2; return ``; }).join(''); - const nodeSvg = nodes.map((n) => - `` + - `${esc(n.label)}` - ).join(''); + const nodeSvg = nodes.map((n) => { + const x = num(n.x), y = num(n.y); + return `` + + `${esc(n.label)}`; + }).join(''); - const annSvg = ann.map((a) => - `` + - `${esc(a.text)}` - ).join(''); + const annSvg = ann.map((a) => { + const x = num(a.x), y = num(a.y); + return `` + + `${esc(a.text)}`; + }).join(''); return `${edgeSvg}${nodeSvg}${annSvg}`; } @@ -49,13 +55,14 @@ export function toExcalidraw(scene) { 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: n.x, y: n.y, width: 132, height: 44, + 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: n.x + 8, y: n.y + 14, width: 116, height: 20, text: String(n.label), + 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, })); @@ -65,7 +72,7 @@ export function toExcalidraw(scene) { 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; + 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]], @@ -76,8 +83,9 @@ export function toExcalidraw(scene) { } for (const a of scene.annotations || []) { + const x = num(a.x), y = num(a.y); elements.push(base(`note-${a.id}`, { - type: 'text', x: a.x, y: a.y, width: 150, height: 40, text: String(a.text), + 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', })); diff --git a/tests/canvas-export.test.js b/tests/canvas-export.test.js index 9649e7f..a2ae4f5 100644 --- a/tests/canvas-export.test.js +++ b/tests/canvas-export.test.js @@ -20,6 +20,17 @@ describe('toCanvasSvg', () => { expect(svg).toContain('check this <b>'); expect(svg).not.toContain('check this '); }); + + 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"> 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'; From fbdebf97002b5a1747e2fc6b2a7701031cd64d7d Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:22:34 -0700 Subject: [PATCH 37/41] docs: Stack Studio (Canvas Phase 3) in changelog + README --- CHANGELOG.md | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d8a71..b993319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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)_ +## [3.1.0] — 2026-06-16 · _Interactive Canvas (Blueprint · Guided Tour · Corkboard · Stack Studio)_ ### Added @@ -16,6 +16,7 @@ the same day, as a rapid burst of improvements, so they share a date. - **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_ diff --git a/README.md b/README.md index 3ada335..66301b0 100644 --- a/README.md +++ b/README.md @@ -37,7 +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. | +| 🗺️ | **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. From 4a64f8fc41a6138c3c2510ab6b04eeeee9679629 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:14:39 -0700 Subject: [PATCH 38/41] chore(canvas): standalone demo previews (Blueprint / Corkboard / Stack Studio) --- canvas-demo.html | 70 +++++++++++++++++++++++++++++++++++++++++++++ corkboard-demo.html | 68 +++++++++++++++++++++++++++++++++++++++++++ stack-demo.html | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 canvas-demo.html create mode 100644 corkboard-demo.html create mode 100644 stack-demo.html 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/corkboard-demo.html b/corkboard-demo.html new file mode 100644 index 0000000..6bb16c2 --- /dev/null +++ b/corkboard-demo.html @@ -0,0 +1,68 @@ + + + + + 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/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 +
+ + + + From eab15cb43c00a00c30c00acc4510b274674efac4 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:19:50 -0700 Subject: [PATCH 39/41] =?UTF-8?q?feat(library):=20clean=20&=20sexy=20UI=20?= =?UTF-8?q?polish=20=E2=80=94=20grouped=20toolbar,=20card=20micro-interact?= =?UTF-8?q?ions,=20vendored=20countUp/confetti/auto-animate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- library.html | 95 +++-- library.js | 128 +++++- vendor/auto-animate.mjs | 830 +++++++++++++++++++++++++++++++++++++ vendor/confetti.mjs | 887 ++++++++++++++++++++++++++++++++++++++++ vendor/countup.mjs | 8 + 5 files changed, 1912 insertions(+), 36 deletions(-) create mode 100644 vendor/auto-animate.mjs create mode 100644 vendor/confetti.mjs create mode 100644 vendor/countup.mjs diff --git a/library.html b/library.html index 3dc68f2..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,21 +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.

diff --git a/library.js b/library.js index baedec8..931e720 100644 --- a/library.js +++ b/library.js @@ -21,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 = { @@ -34,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) { @@ -99,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) => ``) @@ -136,7 +184,7 @@ function card(r) { const evalBadge = evalScore !== null ? `` : ``; - return `
+ return `
${hilite(r.name, hq)} @@ -300,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); @@ -763,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'}.`); } @@ -806,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; @@ -824,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; @@ -1106,15 +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'); - document.getElementById('lib-btn-corkboard')?.classList.remove('on'); + syncViewSwitcher(); if (state.view === 'radar') renderRadar(); else render(); } @@ -1194,12 +1294,11 @@ function radarToMarkdown(byDecision) { function toggleCorkboardView() { state.view = state.view === 'corkboard' ? 'list' : 'corkboard'; const on = state.view === 'corkboard'; - document.getElementById('lib-btn-corkboard')?.classList.toggle('on', on); 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'); - document.getElementById('lib-btn-radar')?.classList.remove('on'); + syncViewSwitcher(); if (on) renderCorkboard(); else render(); } @@ -1984,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); @@ -1991,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); @@ -2228,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'}.`); } @@ -2375,6 +2479,7 @@ 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 @@ -2447,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/vendor/auto-animate.mjs b/vendor/auto-animate.mjs new file mode 100644 index 0000000..7ad811c --- /dev/null +++ b/vendor/auto-animate.mjs @@ -0,0 +1,830 @@ +/** + * A set of all the parents currently being observe. This is the only non weak + * registry. + */ +const parents = new Set(); +/** + * Element coordinates that is constantly kept up to date. + */ +const coords = new WeakMap(); +/** + * Siblings of elements that have been removed from the dom. + */ +const siblings = new WeakMap(); +/** + * Animations that are currently running. + */ +const animations = new WeakMap(); +/** + * A map of existing intersection observers used to track element movements. + */ +const intersections = new WeakMap(); +/** + * A map of existing mutation observers used to track element movements. + */ +const mutationObservers = new WeakMap(); +/** + * Intervals for automatically checking the position of elements occasionally. + */ +const intervals = new WeakMap(); +/** + * The configuration options for each group of elements. + */ +const options = new WeakMap(); +/** + * Debounce counters by id, used to debounce calls to update positions. + */ +const debounces = new WeakMap(); +/** + * All parents that are currently enabled are tracked here. + */ +const enabled = new WeakSet(); +/** + * The document used to calculate transitions. + */ +let root; +/** + * The root’s XY scroll positions. + */ +let scrollX = 0; +let scrollY = 0; +/** + * Used to sign an element as the target. + */ +const TGT = "__aa_tgt"; +/** + * Used to sign an element as being part of a removal. + */ +const DEL = "__aa_del"; +/** + * Used to sign an element as being "new". When an element is removed from the + * dom, but may cycle back in we can sign it with new to ensure the next time + * it is recognized we consider it new. + */ +const NEW = "__aa_new"; +/** + * Callback for handling all mutations. + * @param mutations - A mutation list + */ +const handleMutations = (mutations) => { + const elements = getElements(mutations); + // If elements is "false" that means this mutation that should be ignored. + if (elements) { + elements.forEach((el) => animate(el)); + } +}; +/** + * + * @param entries - Elements that have been resized. + */ +const handleResizes = (entries) => { + entries.forEach((entry) => { + if (entry.target === root) + updateAllPos(); + if (coords.has(entry.target)) + updatePos(entry.target); + }); +}; +/** + * Determine if an element is fully outside of the current viewport. + * @param el - Element to test + */ +function isOffscreen(el) { + const rect = el.getBoundingClientRect(); + const vw = (root === null || root === void 0 ? void 0 : root.clientWidth) || 0; + const vh = (root === null || root === void 0 ? void 0 : root.clientHeight) || 0; + return rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw; +} +/** + * Observe this elements position. + * @param el - The element to observe the position of. + */ +function observePosition(el) { + const oldObserver = intersections.get(el); + oldObserver === null || oldObserver === void 0 ? void 0 : oldObserver.disconnect(); + let rect = coords.get(el); + let invocations = 0; + const buffer = 5; + if (!rect) { + rect = getCoords(el); + coords.set(el, rect); + } + const { offsetWidth, offsetHeight } = root; + const rootMargins = [ + rect.top - buffer, + offsetWidth - (rect.left + buffer + rect.width), + offsetHeight - (rect.top + buffer + rect.height), + rect.left - buffer, + ]; + const rootMargin = rootMargins + .map((px) => `${ -1 * Math.floor(px)}px`) + .join(" "); + const observer = new IntersectionObserver(() => { + ++invocations > 1 && updatePos(el); + }, { + root, + threshold: 1, + rootMargin, + }); + observer.observe(el); + intersections.set(el, observer); +} +/** + * Update the exact position of a given element. + * @param el - An element to update the position of. + * @param debounce - Whether or not to debounce the update. After an animation is finished, it should update as soon as possible to prevent flickering on quick toggles. + */ +function updatePos(el, debounce = true) { + clearTimeout(debounces.get(el)); + const optionsOrPlugin = getOptions(el); + const delay = debounce + ? isPlugin(optionsOrPlugin) + ? 500 + : optionsOrPlugin.duration + : 0; + debounces.set(el, setTimeout(async () => { + const currentAnimation = animations.get(el); + try { + await (currentAnimation === null || currentAnimation === void 0 ? void 0 : currentAnimation.finished); + coords.set(el, getCoords(el)); + observePosition(el); + } + catch { + // ignore errors as the `.finished` promise is rejected when animations were cancelled + } + }, delay)); +} +/** + * Updates all positions that are currently being tracked. + */ +function updateAllPos() { + clearTimeout(debounces.get(root)); + debounces.set(root, setTimeout(() => { + parents.forEach((parent) => forEach(parent, (el) => lowPriority(() => updatePos(el)))); + }, 100)); +} +/** + * Its possible for a quick scroll or other fast events to get past the + * intersection observer, so occasionally we need want "cold-poll" for the + * latests and greatest position. We try to do this in the most non-disruptive + * fashion possible. First we only do this ever couple seconds, staggard by a + * random offset. + * @param el - Element + */ +function poll(el) { + setTimeout(() => { + intervals.set(el, setInterval(() => lowPriority(updatePos.bind(null, el)), 2000)); + }, Math.round(2000 * Math.random())); +} +/** + * Perform some operation that is non critical at some point. + * @param callback + */ +function lowPriority(callback) { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => callback()); + } + else { + requestAnimationFrame(() => callback()); + } +} +/** + * A resize observer, responsible for recalculating elements on resize. + */ +let resize; +/** + * Ensure the browser is supported. + */ +const supportedBrowser = typeof window !== "undefined" && "ResizeObserver" in window; +/** + * If this is in a browser, initialize our Web APIs + */ +if (supportedBrowser) { + root = document.documentElement; + new MutationObserver(handleMutations); + resize = new ResizeObserver(handleResizes); + window.addEventListener("scroll", () => { + scrollY = window.scrollY; + scrollX = window.scrollX; + }); + resize.observe(root); +} +/** + * Retrieves all the elements that may have been affected by the last mutation + * including ones that have been removed and are no longer in the DOM. + * @param mutations - A mutation list. + * @returns + */ +function getElements(mutations) { + const observedNodes = mutations.reduce((nodes, mutation) => { + return [ + ...nodes, + ...Array.from(mutation.addedNodes), + ...Array.from(mutation.removedNodes), + ]; + }, []); + // Short circuit if _only_ comment nodes are observed + const onlyCommentNodesObserved = observedNodes.every((node) => node.nodeName === "#comment"); + if (onlyCommentNodesObserved) + return false; + return mutations.reduce((elements, mutation) => { + // Short circuit if we find a purposefully deleted node. + if (elements === false) + return false; + if (mutation.target instanceof Element) { + target(mutation.target); + if (!elements.has(mutation.target)) { + elements.add(mutation.target); + for (let i = 0; i < mutation.target.children.length; i++) { + const child = mutation.target.children.item(i); + if (!child) + continue; + if (DEL in child) { + return false; + } + target(mutation.target, child); + elements.add(child); + } + } + if (mutation.removedNodes.length) { + for (let i = 0; i < mutation.removedNodes.length; i++) { + const child = mutation.removedNodes[i]; + if (DEL in child) { + return false; + } + if (child instanceof Element) { + elements.add(child); + target(mutation.target, child); + siblings.set(child, [ + mutation.previousSibling, + mutation.nextSibling, + ]); + } + } + } + } + return elements; + }, new Set()); +} +/** + * Assign the target to an element. + * @param el - The root element + * @param child + */ +function target(el, child) { + if (!child && !(TGT in el)) + Object.defineProperty(el, TGT, { value: el }); + else if (child && !(TGT in child)) + Object.defineProperty(child, TGT, { value: el }); +} +/** + * Determines what kind of change took place on the given element and then + * performs the proper animation based on that. + * @param el - The specific element to animate. + */ +function animate(el) { + var _a, _b; + const isMounted = el.isConnected; + const preExisting = coords.has(el); + if (isMounted && siblings.has(el)) + siblings.delete(el); + if (((_a = animations.get(el)) === null || _a === void 0 ? void 0 : _a.playState) !== "finished") { + (_b = animations.get(el)) === null || _b === void 0 ? void 0 : _b.cancel(); + } + if (NEW in el) { + add(el); + } + else if (preExisting && isMounted) { + remain(el); + } + else if (preExisting && !isMounted) { + remove(el); + } + else { + add(el); + } +} +/** + * Removes all non-digits from a string and casts to a number. + * @param str - A string containing a pixel value. + * @returns + */ +function raw(str) { + return Number(str.replace(/[^0-9.\-]/g, "")); +} +/** + * Get the scroll offset of elements + * @param el - Element + * @returns + */ +function getScrollOffset(el) { + let p = el.parentElement; + while (p) { + if (p.scrollLeft || p.scrollTop) { + return { x: p.scrollLeft, y: p.scrollTop }; + } + p = p.parentElement; + } + return { x: 0, y: 0 }; +} +/** + * Get the coordinates of elements adjusted for scroll position. + * @param el - Element + * @returns + */ +function getCoords(el) { + const rect = el.getBoundingClientRect(); + const { x, y } = getScrollOffset(el); + return { + top: rect.top + y, + left: rect.left + x, + width: rect.width, + height: rect.height, + }; +} +/** + * Returns the width/height that the element should be transitioned between. + * This takes into account box-sizing. + * @param el - Element being animated + * @param oldCoords - Old set of Coordinates coordinates + * @param newCoords - New set of Coordinates coordinates + * @returns + */ +function getTransitionSizes(el, oldCoords, newCoords) { + let widthFrom = oldCoords.width; + let heightFrom = oldCoords.height; + let widthTo = newCoords.width; + let heightTo = newCoords.height; + const styles = getComputedStyle(el); + const sizing = styles.getPropertyValue("box-sizing"); + if (sizing === "content-box") { + const paddingY = raw(styles.paddingTop) + + raw(styles.paddingBottom) + + raw(styles.borderTopWidth) + + raw(styles.borderBottomWidth); + const paddingX = raw(styles.paddingLeft) + + raw(styles.paddingRight) + + raw(styles.borderRightWidth) + + raw(styles.borderLeftWidth); + widthFrom -= paddingX; + widthTo -= paddingX; + heightFrom -= paddingY; + heightTo -= paddingY; + } + return [widthFrom, widthTo, heightFrom, heightTo].map(Math.round); +} +/** + * Retrieves animation options for the current element. + * @param el - Element to retrieve options for. + * @returns + */ +function getOptions(el) { + return TGT in el && options.has(el[TGT]) + ? options.get(el[TGT]) + : { duration: 250, easing: "ease-in-out" }; +} +/** + * Returns the target of a given animation (generally the parent). + * @param el - An element to check for a target + * @returns + */ +function getTarget(el) { + if (TGT in el) + return el[TGT]; + return undefined; +} +/** + * Checks if animations are enabled or disabled for a given element. + * @param el - Any element + * @returns + */ +function isEnabled(el) { + const target = getTarget(el); + return target ? enabled.has(target) : false; +} +/** + * Iterate over the children of a given parent. + * @param parent - A parent element + * @param callback - A callback + */ +function forEach(parent, ...callbacks) { + callbacks.forEach((callback) => callback(parent, options.has(parent))); + for (let i = 0; i < parent.children.length; i++) { + const child = parent.children.item(i); + if (child) { + callbacks.forEach((callback) => callback(child, options.has(child))); + } + } +} +/** + * Always return tuple to provide consistent interface + */ +function getPluginTuple(pluginReturn) { + if (Array.isArray(pluginReturn)) + return pluginReturn; + return [pluginReturn]; +} +/** + * Determine if config is plugin + */ +function isPlugin(config) { + return typeof config === "function"; +} +/** + * The element in question is remaining in the DOM. + * @param el - Element to flip + * @returns + */ +function remain(el) { + const oldCoords = coords.get(el); + const newCoords = getCoords(el); + if (!isEnabled(el)) + return coords.set(el, newCoords); + if (isOffscreen(el)) { + // When element is offscreen, skip FLIP to avoid broken transforms + coords.set(el, newCoords); + observePosition(el); + return; + } + let animation; + if (!oldCoords) + return; + const pluginOrOptions = getOptions(el); + if (typeof pluginOrOptions !== "function") { + let deltaLeft = oldCoords.left - newCoords.left; + let deltaTop = oldCoords.top - newCoords.top; + const deltaRight = oldCoords.left + oldCoords.width - (newCoords.left + newCoords.width); + const deltaBottom = oldCoords.top + oldCoords.height - (newCoords.top + newCoords.height); + // element is probably anchored and doesn't need to be offset + if (deltaBottom == 0) + deltaTop = 0; + if (deltaRight == 0) + deltaLeft = 0; + const [widthFrom, widthTo, heightFrom, heightTo] = getTransitionSizes(el, oldCoords, newCoords); + const start = { + transform: `translate(${deltaLeft}px, ${deltaTop}px)`, + }; + const end = { + transform: `translate(0, 0)`, + }; + if (widthFrom !== widthTo) { + start.width = `${widthFrom}px`; + end.width = `${widthTo}px`; + } + if (heightFrom !== heightTo) { + start.height = `${heightFrom}px`; + end.height = `${heightTo}px`; + } + animation = el.animate([start, end], { + duration: pluginOrOptions.duration, + easing: pluginOrOptions.easing, + }); + } + else { + const [keyframes] = getPluginTuple(pluginOrOptions(el, "remain", oldCoords, newCoords)); + animation = new Animation(keyframes); + animation.play(); + } + animations.set(el, animation); + coords.set(el, newCoords); + animation.addEventListener("finish", updatePos.bind(null, el, false), { + once: true, + }); +} +/** + * Adds the element with a transition. + * @param el - Animates the element being added. + */ +function add(el) { + if (NEW in el) + delete el[NEW]; + const newCoords = getCoords(el); + coords.set(el, newCoords); + const pluginOrOptions = getOptions(el); + if (!isEnabled(el)) + return; + if (isOffscreen(el)) { + // Skip entry animation if element is not visible in viewport + observePosition(el); + return; + } + let animation; + if (typeof pluginOrOptions !== "function") { + animation = el.animate([ + { transform: "scale(.98)", opacity: 0 }, + { transform: "scale(0.98)", opacity: 0, offset: 0.5 }, + { transform: "scale(1)", opacity: 1 }, + ], { + duration: pluginOrOptions.duration * 1.5, + easing: "ease-in", + }); + } + else { + const [keyframes] = getPluginTuple(pluginOrOptions(el, "add", newCoords)); + animation = new Animation(keyframes); + animation.play(); + } + animations.set(el, animation); + animation.addEventListener("finish", updatePos.bind(null, el, false), { + once: true, + }); +} +/** + * Clean up after removing an element from the dom. + * @param el - Element being removed + * @param styles - Optional styles that should be removed from the element. + */ +function cleanUp(el, styles) { + var _a; + el.remove(); + coords.delete(el); + siblings.delete(el); + animations.delete(el); + (_a = intersections.get(el)) === null || _a === void 0 ? void 0 : _a.disconnect(); + setTimeout(() => { + if (DEL in el) + delete el[DEL]; + Object.defineProperty(el, NEW, { value: true, configurable: true }); + if (styles && el instanceof HTMLElement) { + for (const style in styles) { + el.style[style] = ""; + } + } + }, 0); +} +/** + * Animates the removal of an element. + * @param el - Element to remove + */ +function remove(el) { + var _a; + if (!siblings.has(el) || !coords.has(el)) + return; + const [prev, next] = siblings.get(el); + Object.defineProperty(el, DEL, { value: true, configurable: true }); + const finalX = window.scrollX; + const finalY = window.scrollY; + if (next && + next.parentNode && + next.parentNode instanceof Element) { + next.parentNode.insertBefore(el, next); + } + else if (prev && prev.parentNode) { + prev.parentNode.appendChild(el); + } + else { + (_a = getTarget(el)) === null || _a === void 0 ? void 0 : _a.appendChild(el); + } + if (!isEnabled(el)) + return cleanUp(el); + const [top, left, width, height] = deletePosition(el); + const optionsOrPlugin = getOptions(el); + const oldCoords = coords.get(el); + if (finalX !== scrollX || finalY !== scrollY) { + adjustScroll(el, finalX, finalY, optionsOrPlugin); + } + let animation; + let styleReset = { + position: "absolute", + top: `${top}px`, + left: `${left}px`, + width: `${width}px`, + height: `${height}px`, + margin: "0", + pointerEvents: "none", + transformOrigin: "center", + zIndex: "100", + }; + if (!isPlugin(optionsOrPlugin)) { + Object.assign(el.style, styleReset); + animation = el.animate([ + { + transform: "scale(1)", + opacity: 1, + }, + { + transform: "scale(.98)", + opacity: 0, + }, + ], { + duration: optionsOrPlugin.duration, + easing: "ease-out", + }); + } + else { + const [keyframes, options] = getPluginTuple(optionsOrPlugin(el, "remove", oldCoords)); + if ((options === null || options === void 0 ? void 0 : options.styleReset) !== false) { + styleReset = + (options === null || options === void 0 ? void 0 : options.styleReset) || + styleReset; + Object.assign(el.style, styleReset); + } + animation = new Animation(keyframes); + animation.play(); + } + animations.set(el, animation); + animation.addEventListener("finish", () => cleanUp(el, styleReset), { + once: true, + }); +} +/** + * If the element being removed is at the very bottom of the page, and the + * the page was scrolled into a space being "made available" by the element + * that was removed, the page scroll will have jumped up some amount. We need + * to offset the jump by the amount that the page was "automatically" scrolled + * up. We can do this by comparing the scroll position before and after the + * element was removed, and then offsetting by that amount. + * + * @param el - The element being deleted + * @param finalX - The final X scroll position + * @param finalY - The final Y scroll position + * @param optionsOrPlugin - The options or plugin + * @returns + */ +function adjustScroll(el, finalX, finalY, optionsOrPlugin) { + const scrollDeltaX = scrollX - finalX; + const scrollDeltaY = scrollY - finalY; + const scrollBefore = document.documentElement.style.scrollBehavior; + const scrollBehavior = getComputedStyle(root).scrollBehavior; + if (scrollBehavior === "smooth") { + document.documentElement.style.scrollBehavior = "auto"; + } + window.scrollTo(window.scrollX + scrollDeltaX, window.scrollY + scrollDeltaY); + if (!el.parentElement) + return; + const parent = el.parentElement; + let lastHeight = parent.clientHeight; + let lastWidth = parent.clientWidth; + const startScroll = performance.now(); + // Here we use a manual scroll animation to keep the element using the same + // easing and timing as the parent’s scroll animation. + function smoothScroll() { + requestAnimationFrame(() => { + if (!isPlugin(optionsOrPlugin)) { + const deltaY = lastHeight - parent.clientHeight; + const deltaX = lastWidth - parent.clientWidth; + if (startScroll + optionsOrPlugin.duration > + performance.now()) { + window.scrollTo({ + left: window.scrollX - deltaX, + top: window.scrollY - deltaY, + }); + lastHeight = parent.clientHeight; + lastWidth = parent.clientWidth; + smoothScroll(); + } + else { + document.documentElement.style.scrollBehavior = scrollBefore; + } + } + }); + } + smoothScroll(); +} +/** + * Determines the position of the element being removed. + * @param el - The element being deleted + * @returns + */ +function deletePosition(el) { + var _a; + const oldCoords = coords.get(el); + const [width, , height] = getTransitionSizes(el, oldCoords, getCoords(el)); + let offsetParent = el.parentElement; + while (offsetParent && + (getComputedStyle(offsetParent).position === "static" || + offsetParent instanceof HTMLBodyElement)) { + offsetParent = offsetParent.parentElement; + } + if (!offsetParent) + offsetParent = document.body; + const parentStyles = getComputedStyle(offsetParent); + const parentCoords = !animations.has(el) || ((_a = animations.get(el)) === null || _a === void 0 ? void 0 : _a.playState) === "finished" + ? getCoords(offsetParent) + : coords.get(offsetParent); + const top = Math.round(oldCoords.top - parentCoords.top) - + raw(parentStyles.borderTopWidth); + const left = Math.round(oldCoords.left - parentCoords.left) - + raw(parentStyles.borderLeftWidth); + return [top, left, width, height]; +} +/** + * A function that automatically adds animation effects to itself and its + * immediate children. Specifically it adds effects for adding, moving, and + * removing DOM elements. + * @param el - A parent element to add animations to. + * @param options - An optional object of options. + */ +function autoAnimate(el, config = {}) { + if (supportedBrowser && resize) { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const isDisabledDueToReduceMotion = mediaQuery.matches && + !isPlugin(config) && + !config.disrespectUserMotionPreference; + if (!isDisabledDueToReduceMotion) { + enabled.add(el); + if (getComputedStyle(el).position === "static") { + Object.assign(el.style, { position: "relative" }); + } + forEach(el, updatePos, poll, (element) => resize === null || resize === void 0 ? void 0 : resize.observe(element)); + if (isPlugin(config)) { + options.set(el, config); + } + else { + options.set(el, { + duration: 250, + easing: "ease-in-out", + ...config, + }); + } + const mo = new MutationObserver(handleMutations); + mo.observe(el, { childList: true }); + mutationObservers.set(el, mo); + parents.add(el); + } + } + const controller = Object.freeze({ + parent: el, + enable: () => { + enabled.add(el); + }, + disable: () => { + enabled.delete(el); + // Cancel any in-flight animations and pending timers for immediate effect + forEach(el, (node) => { + const a = animations.get(node); + try { + a === null || a === void 0 ? void 0 : a.cancel(); + } + catch { } + animations.delete(node); + const d = debounces.get(node); + if (d) + clearTimeout(d); + debounces.delete(node); + const i = intervals.get(node); + if (i) + clearInterval(i); + intervals.delete(node); + }); + }, + isEnabled: () => enabled.has(el), + destroy: () => { + enabled.delete(el); + parents.delete(el); + options.delete(el); + const mo = mutationObservers.get(el); + mo === null || mo === void 0 ? void 0 : mo.disconnect(); + mutationObservers.delete(el); + forEach(el, (node) => { + // unobserve resize + resize === null || resize === void 0 ? void 0 : resize.unobserve(node); + // cancel animations + const a = animations.get(node); + try { + a === null || a === void 0 ? void 0 : a.cancel(); + } + catch { } + animations.delete(node); + // disconnect observers + const io = intersections.get(node); + io === null || io === void 0 ? void 0 : io.disconnect(); + intersections.delete(node); + // clear intervals and debounces + const i = intervals.get(node); + if (i) + clearInterval(i); + intervals.delete(node); + const d = debounces.get(node); + if (d) + clearTimeout(d); + debounces.delete(node); + // clear state + coords.delete(node); + siblings.delete(node); + }); + }, + }); + return controller; +} +/** + * The vue directive. + */ +const vAutoAnimate = { + mounted: (el, binding) => { + const ctl = autoAnimate(el, binding.value || {}); + Object.defineProperty(el, "__aa_ctl", { value: ctl, configurable: true }); + }, + unmounted: (el) => { + var _a; + const ctl = el["__aa_ctl"]; + (_a = ctl === null || ctl === void 0 ? void 0 : ctl.destroy) === null || _a === void 0 ? void 0 : _a.call(ctl); + try { + delete el["__aa_ctl"]; + } + catch { } + }, + getSSRProps: () => ({}), +}; + +export { autoAnimate, autoAnimate as default, getTransitionSizes, vAutoAnimate }; diff --git a/vendor/confetti.mjs b/vendor/confetti.mjs new file mode 100644 index 0000000..ed8e521 --- /dev/null +++ b/vendor/confetti.mjs @@ -0,0 +1,887 @@ +// canvas-confetti v1.9.4 built on 2025-10-25T05:14:56.640Z +var module = {}; + +// source content +/* globals Map */ + +(function main(global, module, isWorker, workerSize) { + var canUseWorker = !!( + global.Worker && + global.Blob && + global.Promise && + global.OffscreenCanvas && + global.OffscreenCanvasRenderingContext2D && + global.HTMLCanvasElement && + global.HTMLCanvasElement.prototype.transferControlToOffscreen && + global.URL && + global.URL.createObjectURL); + + var canUsePaths = typeof Path2D === 'function' && typeof DOMMatrix === 'function'; + var canDrawBitmap = (function () { + // this mostly supports ssr + if (!global.OffscreenCanvas) { + return false; + } + + try { + var canvas = new OffscreenCanvas(1, 1); + var ctx = canvas.getContext('2d'); + ctx.fillRect(0, 0, 1, 1); + var bitmap = canvas.transferToImageBitmap(); + ctx.createPattern(bitmap, 'no-repeat'); + } catch (e) { + return false; + } + + return true; + })(); + + function noop() {} + + // create a promise if it exists, otherwise, just + // call the function directly + function promise(func) { + var ModulePromise = module.exports.Promise; + var Prom = ModulePromise !== void 0 ? ModulePromise : global.Promise; + + if (typeof Prom === 'function') { + return new Prom(func); + } + + func(noop, noop); + + return null; + } + + var bitmapMapper = (function (skipTransform, map) { + // see https://github.com/catdad/canvas-confetti/issues/209 + // creating canvases is actually pretty expensive, so we should create a + // 1:1 map for bitmap:canvas, so that we can animate the confetti in + // a performant manner, but also not store them forever so that we don't + // have a memory leak + return { + transform: function(bitmap) { + if (skipTransform) { + return bitmap; + } + + if (map.has(bitmap)) { + return map.get(bitmap); + } + + var canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + var ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + + map.set(bitmap, canvas); + + return canvas; + }, + clear: function () { + map.clear(); + } + }; + })(canDrawBitmap, new Map()); + + var raf = (function () { + var TIME = Math.floor(1000 / 60); + var frame, cancel; + var frames = {}; + var lastFrameTime = 0; + + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + frame = function (cb) { + var id = Math.random(); + + frames[id] = requestAnimationFrame(function onFrame(time) { + if (lastFrameTime === time || lastFrameTime + TIME - 1 < time) { + lastFrameTime = time; + delete frames[id]; + + cb(); + } else { + frames[id] = requestAnimationFrame(onFrame); + } + }); + + return id; + }; + cancel = function (id) { + if (frames[id]) { + cancelAnimationFrame(frames[id]); + } + }; + } else { + frame = function (cb) { + return setTimeout(cb, TIME); + }; + cancel = function (timer) { + return clearTimeout(timer); + }; + } + + return { frame: frame, cancel: cancel }; + }()); + + var getWorker = (function () { + var worker; + var prom; + var resolves = {}; + + function decorate(worker) { + function execute(options, callback) { + worker.postMessage({ options: options || {}, callback: callback }); + } + worker.init = function initWorker(canvas) { + var offscreen = canvas.transferControlToOffscreen(); + worker.postMessage({ canvas: offscreen }, [offscreen]); + }; + + worker.fire = function fireWorker(options, size, done) { + if (prom) { + execute(options, null); + return prom; + } + + var id = Math.random().toString(36).slice(2); + + prom = promise(function (resolve) { + function workerDone(msg) { + if (msg.data.callback !== id) { + return; + } + + delete resolves[id]; + worker.removeEventListener('message', workerDone); + + prom = null; + + bitmapMapper.clear(); + + done(); + resolve(); + } + + worker.addEventListener('message', workerDone); + execute(options, id); + + resolves[id] = workerDone.bind(null, { data: { callback: id }}); + }); + + return prom; + }; + + worker.reset = function resetWorker() { + worker.postMessage({ reset: true }); + + for (var id in resolves) { + resolves[id](); + delete resolves[id]; + } + }; + } + + return function () { + if (worker) { + return worker; + } + + if (!isWorker && canUseWorker) { + var code = [ + 'var CONFETTI, SIZE = {}, module = {};', + '(' + main.toString() + ')(this, module, true, SIZE);', + 'onmessage = function(msg) {', + ' if (msg.data.options) {', + ' CONFETTI(msg.data.options).then(function () {', + ' if (msg.data.callback) {', + ' postMessage({ callback: msg.data.callback });', + ' }', + ' });', + ' } else if (msg.data.reset) {', + ' CONFETTI && CONFETTI.reset();', + ' } else if (msg.data.resize) {', + ' SIZE.width = msg.data.resize.width;', + ' SIZE.height = msg.data.resize.height;', + ' } else if (msg.data.canvas) {', + ' SIZE.width = msg.data.canvas.width;', + ' SIZE.height = msg.data.canvas.height;', + ' CONFETTI = module.exports.create(msg.data.canvas);', + ' }', + '}', + ].join('\n'); + try { + worker = new Worker(URL.createObjectURL(new Blob([code]))); + } catch (e) { + // eslint-disable-next-line no-console + typeof console !== 'undefined' && typeof console.warn === 'function' ? console.warn('🎊 Could not load worker', e) : null; + + return null; + } + + decorate(worker); + } + + return worker; + }; + })(); + + var defaults = { + particleCount: 50, + angle: 90, + spread: 45, + startVelocity: 45, + decay: 0.9, + gravity: 1, + drift: 0, + ticks: 200, + x: 0.5, + y: 0.5, + shapes: ['square', 'circle'], + zIndex: 100, + colors: [ + '#26ccff', + '#a25afd', + '#ff5e7e', + '#88ff5a', + '#fcff42', + '#ffa62d', + '#ff36ff' + ], + // probably should be true, but back-compat + disableForReducedMotion: false, + scalar: 1 + }; + + function convert(val, transform) { + return transform ? transform(val) : val; + } + + function isOk(val) { + return !(val === null || val === undefined); + } + + function prop(options, name, transform) { + return convert( + options && isOk(options[name]) ? options[name] : defaults[name], + transform + ); + } + + function onlyPositiveInt(number){ + return number < 0 ? 0 : Math.floor(number); + } + + function randomInt(min, max) { + // [min, max) + return Math.floor(Math.random() * (max - min)) + min; + } + + function toDecimal(str) { + return parseInt(str, 16); + } + + function colorsToRgb(colors) { + return colors.map(hexToRgb); + } + + function hexToRgb(str) { + var val = String(str).replace(/[^0-9a-f]/gi, ''); + + if (val.length < 6) { + val = val[0]+val[0]+val[1]+val[1]+val[2]+val[2]; + } + + return { + r: toDecimal(val.substring(0,2)), + g: toDecimal(val.substring(2,4)), + b: toDecimal(val.substring(4,6)) + }; + } + + function getOrigin(options) { + var origin = prop(options, 'origin', Object); + origin.x = prop(origin, 'x', Number); + origin.y = prop(origin, 'y', Number); + + return origin; + } + + function setCanvasWindowSize(canvas) { + canvas.width = document.documentElement.clientWidth; + canvas.height = document.documentElement.clientHeight; + } + + function setCanvasRectSize(canvas) { + var rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + } + + function getCanvas(zIndex) { + var canvas = document.createElement('canvas'); + + canvas.style.position = 'fixed'; + canvas.style.top = '0px'; + canvas.style.left = '0px'; + canvas.style.pointerEvents = 'none'; + canvas.style.zIndex = zIndex; + + return canvas; + } + + function ellipse(context, x, y, radiusX, radiusY, rotation, startAngle, endAngle, antiClockwise) { + context.save(); + context.translate(x, y); + context.rotate(rotation); + context.scale(radiusX, radiusY); + context.arc(0, 0, 1, startAngle, endAngle, antiClockwise); + context.restore(); + } + + function randomPhysics(opts) { + var radAngle = opts.angle * (Math.PI / 180); + var radSpread = opts.spread * (Math.PI / 180); + + return { + x: opts.x, + y: opts.y, + wobble: Math.random() * 10, + wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05), + velocity: (opts.startVelocity * 0.5) + (Math.random() * opts.startVelocity), + angle2D: -radAngle + ((0.5 * radSpread) - (Math.random() * radSpread)), + tiltAngle: (Math.random() * (0.75 - 0.25) + 0.25) * Math.PI, + color: opts.color, + shape: opts.shape, + tick: 0, + totalTicks: opts.ticks, + decay: opts.decay, + drift: opts.drift, + random: Math.random() + 2, + tiltSin: 0, + tiltCos: 0, + wobbleX: 0, + wobbleY: 0, + gravity: opts.gravity * 3, + ovalScalar: 0.6, + scalar: opts.scalar, + flat: opts.flat + }; + } + + function updateFetti(context, fetti) { + fetti.x += Math.cos(fetti.angle2D) * fetti.velocity + fetti.drift; + fetti.y += Math.sin(fetti.angle2D) * fetti.velocity + fetti.gravity; + fetti.velocity *= fetti.decay; + + if (fetti.flat) { + fetti.wobble = 0; + fetti.wobbleX = fetti.x + (10 * fetti.scalar); + fetti.wobbleY = fetti.y + (10 * fetti.scalar); + + fetti.tiltSin = 0; + fetti.tiltCos = 0; + fetti.random = 1; + } else { + fetti.wobble += fetti.wobbleSpeed; + fetti.wobbleX = fetti.x + ((10 * fetti.scalar) * Math.cos(fetti.wobble)); + fetti.wobbleY = fetti.y + ((10 * fetti.scalar) * Math.sin(fetti.wobble)); + + fetti.tiltAngle += 0.1; + fetti.tiltSin = Math.sin(fetti.tiltAngle); + fetti.tiltCos = Math.cos(fetti.tiltAngle); + fetti.random = Math.random() + 2; + } + + var progress = (fetti.tick++) / fetti.totalTicks; + + var x1 = fetti.x + (fetti.random * fetti.tiltCos); + var y1 = fetti.y + (fetti.random * fetti.tiltSin); + var x2 = fetti.wobbleX + (fetti.random * fetti.tiltCos); + var y2 = fetti.wobbleY + (fetti.random * fetti.tiltSin); + + context.fillStyle = 'rgba(' + fetti.color.r + ', ' + fetti.color.g + ', ' + fetti.color.b + ', ' + (1 - progress) + ')'; + + context.beginPath(); + + if (canUsePaths && fetti.shape.type === 'path' && typeof fetti.shape.path === 'string' && Array.isArray(fetti.shape.matrix)) { + context.fill(transformPath2D( + fetti.shape.path, + fetti.shape.matrix, + fetti.x, + fetti.y, + Math.abs(x2 - x1) * 0.1, + Math.abs(y2 - y1) * 0.1, + Math.PI / 10 * fetti.wobble + )); + } else if (fetti.shape.type === 'bitmap') { + var rotation = Math.PI / 10 * fetti.wobble; + var scaleX = Math.abs(x2 - x1) * 0.1; + var scaleY = Math.abs(y2 - y1) * 0.1; + var width = fetti.shape.bitmap.width * fetti.scalar; + var height = fetti.shape.bitmap.height * fetti.scalar; + + var matrix = new DOMMatrix([ + Math.cos(rotation) * scaleX, + Math.sin(rotation) * scaleX, + -Math.sin(rotation) * scaleY, + Math.cos(rotation) * scaleY, + fetti.x, + fetti.y + ]); + + // apply the transform matrix from the confetti shape + matrix.multiplySelf(new DOMMatrix(fetti.shape.matrix)); + + var pattern = context.createPattern(bitmapMapper.transform(fetti.shape.bitmap), 'no-repeat'); + pattern.setTransform(matrix); + + context.globalAlpha = (1 - progress); + context.fillStyle = pattern; + context.fillRect( + fetti.x - (width / 2), + fetti.y - (height / 2), + width, + height + ); + context.globalAlpha = 1; + } else if (fetti.shape === 'circle') { + context.ellipse ? + context.ellipse(fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI) : + ellipse(context, fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI); + } else if (fetti.shape === 'star') { + var rot = Math.PI / 2 * 3; + var innerRadius = 4 * fetti.scalar; + var outerRadius = 8 * fetti.scalar; + var x = fetti.x; + var y = fetti.y; + var spikes = 5; + var step = Math.PI / spikes; + + while (spikes--) { + x = fetti.x + Math.cos(rot) * outerRadius; + y = fetti.y + Math.sin(rot) * outerRadius; + context.lineTo(x, y); + rot += step; + + x = fetti.x + Math.cos(rot) * innerRadius; + y = fetti.y + Math.sin(rot) * innerRadius; + context.lineTo(x, y); + rot += step; + } + } else { + context.moveTo(Math.floor(fetti.x), Math.floor(fetti.y)); + context.lineTo(Math.floor(fetti.wobbleX), Math.floor(y1)); + context.lineTo(Math.floor(x2), Math.floor(y2)); + context.lineTo(Math.floor(x1), Math.floor(fetti.wobbleY)); + } + + context.closePath(); + context.fill(); + + return fetti.tick < fetti.totalTicks; + } + + function animate(canvas, fettis, resizer, size, done) { + var animatingFettis = fettis.slice(); + var context = canvas.getContext('2d'); + var animationFrame; + var destroy; + + var prom = promise(function (resolve) { + function onDone() { + animationFrame = destroy = null; + + context.clearRect(0, 0, size.width, size.height); + bitmapMapper.clear(); + + done(); + resolve(); + } + + function update() { + if (isWorker && !(size.width === workerSize.width && size.height === workerSize.height)) { + size.width = canvas.width = workerSize.width; + size.height = canvas.height = workerSize.height; + } + + if (!size.width && !size.height) { + resizer(canvas); + size.width = canvas.width; + size.height = canvas.height; + } + + context.clearRect(0, 0, size.width, size.height); + + animatingFettis = animatingFettis.filter(function (fetti) { + return updateFetti(context, fetti); + }); + + if (animatingFettis.length) { + animationFrame = raf.frame(update); + } else { + onDone(); + } + } + + animationFrame = raf.frame(update); + destroy = onDone; + }); + + return { + addFettis: function (fettis) { + animatingFettis = animatingFettis.concat(fettis); + + return prom; + }, + canvas: canvas, + promise: prom, + reset: function () { + if (animationFrame) { + raf.cancel(animationFrame); + } + + if (destroy) { + destroy(); + } + } + }; + } + + function confettiCannon(canvas, globalOpts) { + var isLibCanvas = !canvas; + var allowResize = !!prop(globalOpts || {}, 'resize'); + var hasResizeEventRegistered = false; + var globalDisableForReducedMotion = prop(globalOpts, 'disableForReducedMotion', Boolean); + var shouldUseWorker = canUseWorker && !!prop(globalOpts || {}, 'useWorker'); + var worker = shouldUseWorker ? getWorker() : null; + var resizer = isLibCanvas ? setCanvasWindowSize : setCanvasRectSize; + var initialized = (canvas && worker) ? !!canvas.__confetti_initialized : false; + var preferLessMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion)').matches; + var animationObj; + + function fireLocal(options, size, done) { + var particleCount = prop(options, 'particleCount', onlyPositiveInt); + var angle = prop(options, 'angle', Number); + var spread = prop(options, 'spread', Number); + var startVelocity = prop(options, 'startVelocity', Number); + var decay = prop(options, 'decay', Number); + var gravity = prop(options, 'gravity', Number); + var drift = prop(options, 'drift', Number); + var colors = prop(options, 'colors', colorsToRgb); + var ticks = prop(options, 'ticks', Number); + var shapes = prop(options, 'shapes'); + var scalar = prop(options, 'scalar'); + var flat = !!prop(options, 'flat'); + var origin = getOrigin(options); + + var temp = particleCount; + var fettis = []; + + var startX = canvas.width * origin.x; + var startY = canvas.height * origin.y; + + while (temp--) { + fettis.push( + randomPhysics({ + x: startX, + y: startY, + angle: angle, + spread: spread, + startVelocity: startVelocity, + color: colors[temp % colors.length], + shape: shapes[randomInt(0, shapes.length)], + ticks: ticks, + decay: decay, + gravity: gravity, + drift: drift, + scalar: scalar, + flat: flat + }) + ); + } + + // if we have a previous canvas already animating, + // add to it + if (animationObj) { + return animationObj.addFettis(fettis); + } + + animationObj = animate(canvas, fettis, resizer, size , done); + + return animationObj.promise; + } + + function fire(options) { + var disableForReducedMotion = globalDisableForReducedMotion || prop(options, 'disableForReducedMotion', Boolean); + var zIndex = prop(options, 'zIndex', Number); + + if (disableForReducedMotion && preferLessMotion) { + return promise(function (resolve) { + resolve(); + }); + } + + if (isLibCanvas && animationObj) { + // use existing canvas from in-progress animation + canvas = animationObj.canvas; + } else if (isLibCanvas && !canvas) { + // create and initialize a new canvas + canvas = getCanvas(zIndex); + document.body.appendChild(canvas); + } + + if (allowResize && !initialized) { + // initialize the size of a user-supplied canvas + resizer(canvas); + } + + var size = { + width: canvas.width, + height: canvas.height + }; + + if (worker && !initialized) { + worker.init(canvas); + } + + initialized = true; + + if (worker) { + canvas.__confetti_initialized = true; + } + + function onResize() { + if (worker) { + // TODO this really shouldn't be immediate, because it is expensive + var obj = { + getBoundingClientRect: function () { + if (!isLibCanvas) { + return canvas.getBoundingClientRect(); + } + } + }; + + resizer(obj); + + worker.postMessage({ + resize: { + width: obj.width, + height: obj.height + } + }); + return; + } + + // don't actually query the size here, since this + // can execute frequently and rapidly + size.width = size.height = null; + } + + function done() { + animationObj = null; + + if (allowResize) { + hasResizeEventRegistered = false; + global.removeEventListener('resize', onResize); + } + + if (isLibCanvas && canvas) { + if (document.body.contains(canvas)) { + document.body.removeChild(canvas); + } + canvas = null; + initialized = false; + } + } + + if (allowResize && !hasResizeEventRegistered) { + hasResizeEventRegistered = true; + global.addEventListener('resize', onResize, false); + } + + if (worker) { + return worker.fire(options, size, done); + } + + return fireLocal(options, size, done); + } + + fire.reset = function () { + if (worker) { + worker.reset(); + } + + if (animationObj) { + animationObj.reset(); + } + }; + + return fire; + } + + // Make default export lazy to defer worker creation until called. + var defaultFire; + function getDefaultFire() { + if (!defaultFire) { + defaultFire = confettiCannon(null, { useWorker: true, resize: true }); + } + return defaultFire; + } + + function transformPath2D(pathString, pathMatrix, x, y, scaleX, scaleY, rotation) { + var path2d = new Path2D(pathString); + + var t1 = new Path2D(); + t1.addPath(path2d, new DOMMatrix(pathMatrix)); + + var t2 = new Path2D(); + // see https://developer.mozilla.org/en-US/docs/Web/API/DOMMatrix/DOMMatrix + t2.addPath(t1, new DOMMatrix([ + Math.cos(rotation) * scaleX, + Math.sin(rotation) * scaleX, + -Math.sin(rotation) * scaleY, + Math.cos(rotation) * scaleY, + x, + y + ])); + + return t2; + } + + function shapeFromPath(pathData) { + if (!canUsePaths) { + throw new Error('path confetti are not supported in this browser'); + } + + var path, matrix; + + if (typeof pathData === 'string') { + path = pathData; + } else { + path = pathData.path; + matrix = pathData.matrix; + } + + var path2d = new Path2D(path); + var tempCanvas = document.createElement('canvas'); + var tempCtx = tempCanvas.getContext('2d'); + + if (!matrix) { + // attempt to figure out the width of the path, up to 1000x1000 + var maxSize = 1000; + var minX = maxSize; + var minY = maxSize; + var maxX = 0; + var maxY = 0; + var width, height; + + // do some line skipping... this is faster than checking + // every pixel and will be mostly still correct + for (var x = 0; x < maxSize; x += 2) { + for (var y = 0; y < maxSize; y += 2) { + if (tempCtx.isPointInPath(path2d, x, y, 'nonzero')) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + width = maxX - minX; + height = maxY - minY; + + var maxDesiredSize = 10; + var scale = Math.min(maxDesiredSize/width, maxDesiredSize/height); + + matrix = [ + scale, 0, 0, scale, + -Math.round((width/2) + minX) * scale, + -Math.round((height/2) + minY) * scale + ]; + } + + return { + type: 'path', + path: path, + matrix: matrix + }; + } + + function shapeFromText(textData) { + var text, + scalar = 1, + color = '#000000', + // see https://nolanlawson.com/2022/04/08/the-struggle-of-using-native-emoji-on-the-web/ + fontFamily = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", "Twemoji Mozilla", "system emoji", sans-serif'; + + if (typeof textData === 'string') { + text = textData; + } else { + text = textData.text; + scalar = 'scalar' in textData ? textData.scalar : scalar; + fontFamily = 'fontFamily' in textData ? textData.fontFamily : fontFamily; + color = 'color' in textData ? textData.color : color; + } + + // all other confetti are 10 pixels, + // so this pixel size is the de-facto 100% scale confetti + var fontSize = 10 * scalar; + var font = '' + fontSize + 'px ' + fontFamily; + + var canvas = new OffscreenCanvas(fontSize, fontSize); + var ctx = canvas.getContext('2d'); + + ctx.font = font; + var size = ctx.measureText(text); + var width = Math.ceil(size.actualBoundingBoxRight + size.actualBoundingBoxLeft); + var height = Math.ceil(size.actualBoundingBoxAscent + size.actualBoundingBoxDescent); + + var padding = 2; + var x = size.actualBoundingBoxLeft + padding; + var y = size.actualBoundingBoxAscent + padding; + width += padding + padding; + height += padding + padding; + + canvas = new OffscreenCanvas(width, height); + ctx = canvas.getContext('2d'); + ctx.font = font; + ctx.fillStyle = color; + + ctx.fillText(text, x, y); + + var scale = 1 / scalar; + + return { + type: 'bitmap', + // TODO these probably need to be transfered for workers + bitmap: canvas.transferToImageBitmap(), + matrix: [scale, 0, 0, scale, -width * scale / 2, -height * scale / 2] + }; + } + + module.exports = function() { + return getDefaultFire().apply(this, arguments); + }; + module.exports.reset = function() { + getDefaultFire().reset(); + }; + module.exports.create = confettiCannon; + module.exports.shapeFromPath = shapeFromPath; + module.exports.shapeFromText = shapeFromText; +}((function () { + if (typeof window !== 'undefined') { + return window; + } + + if (typeof self !== 'undefined') { + return self; + } + + return this || {}; +})(), module, false)); + +// end source content + +export default module.exports; +export var create = module.exports.create; diff --git a/vendor/countup.mjs b/vendor/countup.mjs new file mode 100644 index 0000000..04e7db0 --- /dev/null +++ b/vendor/countup.mjs @@ -0,0 +1,8 @@ +/** + * Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0. + * Original file: /npm/countup.js@2.10.0/dist/countUp.min.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var t=function(){return t=Object.assign||function(t){for(var i,e=1,s=arguments.length;en.endVal;n.frameVal=e?n.endVal:n.frameVal,n.frameVal=Number(n.frameVal.toFixed(n.options.decimalPlaces)),n.printValue(n.frameVal),i1?n.options.decimal+r[1]:"",n.options.useGrouping){a="";for(var l=3,u=0,h=0,p=e.length;ht;var i=t-this.startVal;if(Math.abs(i)>this.options.smartEasingThreshold&&this.options.useEasing){this.finalEndVal=t;var e=this.countDown?1:-1;this.endVal=t+e*this.options.smartEasingAmount,this.duration=this.duration/2}else this.endVal=t,this.finalEndVal=null;null!==this.finalEndVal?this.useEasing=!1:this.useEasing=this.options.useEasing},i.prototype.start=function(t){this.error||(this.options.onStartCallback&&this.options.onStartCallback(),t&&(this.options.onCompleteCallback=t),this.duration>0?(this.determineDirectionAndSmartEasing(),this.paused=!1,this.rAF=requestAnimationFrame(this.count)):this.printValue(this.endVal))},i.prototype.pauseResume=function(){this.paused?(this.startTime=null,this.duration=this.remaining,this.startVal=this.frameVal,this.determineDirectionAndSmartEasing(),this.rAF=requestAnimationFrame(this.count)):cancelAnimationFrame(this.rAF),this.paused=!this.paused},i.prototype.reset=function(){clearTimeout(this.autoAnimateTimeout),cancelAnimationFrame(this.rAF),this.paused=!0,this.once=!1,this.resetDuration(),this.startVal=this.validateValue(this.options.startVal),this.frameVal=this.startVal,this.printValue(this.startVal)},i.prototype.update=function(t){cancelAnimationFrame(this.rAF),this.startTime=null,this.endVal=this.validateValue(t),this.endVal!==this.frameVal&&(this.startVal=this.frameVal,null==this.finalEndVal&&this.resetDuration(),this.finalEndVal=null,this.determineDirectionAndSmartEasing(),this.rAF=requestAnimationFrame(this.count))},i.prototype.printValue=function(t){var i;if(this.el){var e=this.formattingFn(t);(null===(i=this.options.plugin)||void 0===i?void 0:i.render)?this.options.plugin.render(this.el,e):"INPUT"===this.el.tagName?this.el.value=e:"text"===this.el.tagName||"tspan"===this.el.tagName?this.el.textContent=e:this.el.innerHTML=e}},i.prototype.ensureNumber=function(t){return"number"==typeof t&&!isNaN(t)},i.prototype.validateValue=function(t){var i=Number(t);return this.ensureNumber(i)?i:(this.error="[CountUp] invalid start or end value: ".concat(t),null)},i.prototype.resetDuration=function(){this.startTime=null,this.duration=1e3*Number(this.options.duration),this.remaining=this.duration},i.prototype.parse=function(t){var i=function(t){return t.replace(/([.,'  ])/g,"\\$1")},e=i(this.options.separator),s=i(this.options.decimal),n=t.replace(new RegExp(e,"g"),"").replace(new RegExp(s,"g"),".");return parseFloat(n)},i.observedElements=new WeakMap,i}();export{i as CountUp};export default null; +//# sourceMappingURL=/sm/7f49ab8592cbf2546c133fd68b27575fcf0afe9a601f48fcb937a70d06075a1a.map \ No newline at end of file From a4d016e3167b0c770a3877cb70eaaa50cca53243 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:07:35 -0700 Subject: [PATCH 40/41] fix(canvas): auto-size node cards to their label (clamp + ellipsis + hover title) so long labels don't overflow --- canvas-engine.js | 45 ++++++++++++++++++++++++++++++++++++++------- corkboard-demo.html | 2 +- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/canvas-engine.js b/canvas-engine.js index 45fe4e1..8b5b779 100644 --- a/canvas-engine.js +++ b/canvas-engine.js @@ -6,17 +6,22 @@ import { escapeHtml as esc } from './safe-html.js'; 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 path string from source node's right-middle to target node's left-middle. - * a, b are node objects with {x, y} top-left coordinates. - * @param {{ x: number, y: number }} a - * @param {{ x: number, y: number }} b + * 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 + NODE_W, sy = a.y + NODE_H / 2; + 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}`; } @@ -71,6 +76,30 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { 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; @@ -80,10 +109,12 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { 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', { x: NODE_W / 2, y: NODE_H / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central' }); + const text = el('text', { y: NODE_H / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central' }); text.textContent = n.label; - g.append(rect, text); + 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(); diff --git a/corkboard-demo.html b/corkboard-demo.html index 6bb16c2..f0bb6a6 100644 --- a/corkboard-demo.html +++ b/corkboard-demo.html @@ -39,7 +39,7 @@ <h1>🧭 RepoLens — Corkboard (live demo)</h1> { nodeId: '300', repoId: 'webpack/webpack', name: 'webpack', analyzed: true, kind: 'repo' }, { nodeId: '400', repoId: 'vitejs/vite', name: 'vite', analyzed: true, kind: 'repo' }, { nodeId: '500', repoId: 'parcel/parcel', name: 'parcel', analyzed: true, kind: 'repo' }, - { nodeId: '900', title: 'esbuild + vite dev', kind: 'idea', sources: ['evanw/esbuild', 'vitejs/vite'] }, + { nodeId: '900', title: 'ComfyUI + muapi-comfyui custom nodes', kind: 'idea', sources: ['evanw/esbuild', 'vitejs/vite'] }, ], edges: [ { id: 'e1', source: '100', target: '200', label: 'ALTERNATIVE_TO' }, From 4e1b6313c3ceb7ea6ffc18ff8732071d8b3d1782 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:19:12 -0700 Subject: [PATCH 41/41] ci: exclude vendored libs from eslint, drop unused esc import, bump deprecated Node-20 actions --- .github/workflows/ci.yml | 4 ++-- .prettierignore | 1 + canvas-engine.js | 2 -- eslint.config.js | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) 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/canvas-engine.js b/canvas-engine.js index 8b5b779..9f513e6 100644 --- a/canvas-engine.js +++ b/canvas-engine.js @@ -2,8 +2,6 @@ // 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'; export const NODE_W = 132, NODE_H = 44; // Auto-width card bounds: each card fits its label, clamped to [MIN_W, MAX_W]; 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'],