From 48b8d3ed4c6a8426a80fc05b5979ea2a9a023e8b Mon Sep 17 00:00:00 2001 From: Casey Gollan Date: Sun, 1 Nov 2026 11:25:29 -0500 Subject: [PATCH 01/21] docs: design for pele renderer PoC --- .../2026-11-01-pele-renderer-poc-design.md | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md diff --git a/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md b/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md new file mode 100644 index 0000000..9c962a2 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md @@ -0,0 +1,273 @@ +# Pele renderer PoC — design + +Date: 2026-11-01 +Status: awaiting approval + +## Intent + +Visimer's canvas renders through a user-supplied `mermaid` instance and +correlates the rendered SVG back to core's lossless CST to provide WYSIWYG +point editing. **Pele** (github.com/obsidianmd/pele, v1.x) is Obsidian's +from-scratch Mermaid renderer: it consumes the same Mermaid source syntax, +has zero runtime dependencies, is ~230 kB gzipped vs Mermaid's 1.6 MB, +renders synchronously in well under a millisecond, themes via CSS variables +instead of initialize-config, and was recently integrated into Obsidian. + +End goal (not this PoC): Visimer editing on top of Pele charts inside +Obsidian. This PoC proves the foundation standalone inside Visimer: that +the renderer is genuinely swappable behind one seam, with the full editing +loop working over a Pele-rendered flowchart, demonstrated side-by-side with +Mermaid in the playground. + +## Why it's plausible + +An audit of both codebases shows the Mermaid-specific surface of +`@visimer/dom` is small and well-fenced: + +- `MermaidLike` (packages/dom/src/view.ts:141) — 3 methods, the only + mermaid API the view consumes +- The 8 correlator bodies in packages/dom/src/correlate.ts — the only + mermaid-DOM conventions +- Scattered conventions: per-render re-initialize (mermaid's config is + module-global), `d${id}` error-element cleanup, font-settle re-render, + HTML-label in-place editing + +Everything downstream — gestures, popovers, selection, hit-testing — keys +off Visimer's own `data-mw-entity` tagging and core's entity-id scheme, +never mermaid's DOM. And Pele's SVG is friendlier to correlate than +Mermaid's: every entity carries a documented `data-id`. + +## Goals + +1. `@visimer/dom` renders and edits over a user-supplied Pele instance: + `ViewOptions` gains an optional `renderer` (additive; the `mermaid` path + is unchanged) +2. Full editing loop over a Pele-rendered flowchart: select, double-click + rename (overlay editor), drag-to-connect, add/delete nodes and edges, + popovers, one undo stack shared with the CodeMirror pane +3. Playground shows a Mermaid ⇄ Pele renderer toggle over the same source + and code pane +4. Correlator tests run against real Pele render output in jsdom (Pele's + text measurement has a no-DOM fallback) — closing, for the pele family, + the correlator-seam coverage gap TESTING.md documents +5. Existing mermaid behavior byte-identical: all current suites green + without modification + +## Non-goals + +- Obsidian integration (separate design after the PoC) +- Pele correlators beyond flowchart (state/class/ER/sequence are the next + wave) +- In-place contenteditable editing over SVG text (PoC uses the overlay + editor; SVG-text in-place is a later UX upgrade) +- Pele hard dependency in any Visimer package (apps add it; packages treat + it as an optional peer, mirroring mermaid) +- Any change to core's parsing/ops behavior — one exception, below: + widening `Diagnostic.source` + +## Anti-goals + +- Duplicating `MermaidCanvasView` or any of the gesture/popover layer for + Pele — failure even if the demo works +- Correlating against undocumented Pele internals — `data-id` and the + `pele-*` classes are the documented contract (pele.run/theming); + scraping undocumented structure would recreate the "highest-risk seam" + the mermaid correlators already suffer from (TESTING.md:17) +- Breaking or reordering the mermaid path's behavior (its render/config/ + error conventions are load-bearing for existing consumers) + +## Constraints + +Facts from the Pele v1.13.0 source audit that shape the design: + +- `render(source, options?) → { svg, width, height, links }` is + **synchronous** and **throws** `PeleError { code, type, line, column, + snippet }` on any syntax/semantic error — there is no partial render. + Keep-last-good (already the view's behavior: error badge + diagnostics + + prior SVG stays) covers mid-keystroke typing. +- Flowchart SVG structure (src/diagrams/flowchart/render.ts): role groups + `g.pele-clusters`, `g.pele-edges`, `g.pele-cluster-titles`, + `g.pele-edge-labels`, `g.pele-nodes`. Nodes: `g.pele-node + pele-shape-` with `data-id=""` (escaped) and + `transform="translate(x,y)"`, shapes centered on the group origin. + Clusters: `g.pele-cluster[data-id=""]`. Edges: `g.pele-edge[data-id]` + where data-id is the user's `@id` when given, else generated + `L___`. Edge labels: `g.pele-edge-label[data-id]` with + the same id scheme. +- Node/cluster data-ids are source ids (stable); edge data-ids are + count-based (unstable when a duplicate edge is inserted). +- Labels are plain SVG `` — no foreignObject + HTML. The view's `editInPlace` already returns false when no HTMLElement + label is found and falls back to the overlay editor (view.ts:1403) — no + code change needed for the PoC editing UX. +- Pele's text measurement falls back to a baked-in metrics table when no + canvas exists, so `render()` works under jsdom — correlator tests get + real output. +- Pele targets Mermaid 12.1.0 syntax; Visimer apps pin mermaid ^11.16.1 + and core tracks v11 grammar. The flowchart grammar subset in scope is + stable across both (core already preserves `@{ shape: ... }` attrs raw, + flowchart/parse.ts:222). If a source uses syntax core can't project, it + degrades to view-only exactly as today (editor.ts:69-71). +- `Diagnostic.source` in core is `'core' | 'mermaid'` + (core/src/types.ts:55); a pele render error needs an honest source. + **Widen the union to `'core' | 'mermaid' | 'pele'`** — the single core + change, type-only, additive. + +## Approaches + +Three were considered (agreed in conversation): + +1. **Renderer adapter inside `@visimer/dom`** ✅ picked — generalize the + seam, add a Pele correlator set next to the existing eight, selected by + adapter id. Proves the exact swappable-seam property the Obsidian goal + needs, least scaffolding, no new packages. Cost: dom carries a second + correlator family. +2. **Separate `@visimer/pele` package** — extraction + injection hooks + before anything renders; premature packaging before the adapter shape is + proven; revisit for the next wave (sequence/state/class/ER correlators) + once the seam settles. +3. **Playground-only hack** — proves nothing that carries toward Obsidian; + dropped. + +## The design + +### 1. Renderer seam (packages/dom/src/renderer.ts, new file) + +`MermaidLike` stays exported for compatibility. New, minimal, +renderer-agnostic adapter: + +```ts +export interface RendererAdapter { + /** correlator family + capability selector */ + readonly id: 'mermaid' | 'pele' + /** render source to a full SVG document string */ + render(code: string): Promise<{ svg: string }> + /** validate source; null when no diagnostic */ + validate?(code: string): Promise<{ message: string } | null> +} +``` + +- `mermaidAdapter(mermaid: MermaidLike, config?): RendererAdapter` — + wraps today's exact behavior: initialize-per-render (module-global config + compensation, comment preserved), id generation `mw-render-N`, + `parse` → `validate`. +- `peleAdapter(pele: PeleLike, options?): RendererAdapter` — wraps + `pele.render(code, options)` (sync → resolved promise), `PeleError` → + `{ message }` including line/column. `PeleLike` is a one-method interface + (`render(source, options?)`), so dom never imports pele. + +View integration (packages/dom/src/view.ts): + +- `ViewOptions`: `mermaid` becomes optional; new `renderer?: + RendererAdapter`. Exactly one must be provided (constructor throws a + clear error otherwise). When `mermaid` is given, the view wraps it in + `mermaidAdapter` internally — every existing consumer keeps working + unchanged, and the internal call sequence for mermaid is byte-identical. +- `render()`: calls `this.renderer.render(code)` instead of + `this.mermaid.render(id, code)`. The `d${id}`/`${id}` error-element + cleanup (view.ts:848-850) is mermaid-specific → it moves behind adapter + identity (`if (this.renderer.id === 'mermaid')`) and must not run for + pele. Implementer's choice: adapter-identity check in the view or a + `cleanupErrorDom?(id)` hook on the adapter. +- `bindSvg()`: under `renderer.id === 'pele'`, only the flowchart slot + correlates (`correlatePeleFlowchart`); every other slot → `null` + correlation (view-only) until the next wave. The mermaid chain is + untouched. +- `validate()`: delegates to `this.renderer.validate?.(code)`. +- Diagnostic source: `'mermaid'` when `renderer.id === 'mermaid'` else + `'pele'`. +- `setMermaidConfig`: under pele it no-ops with a console warning (pele + theming is CSS variables). +- Font-settle re-render stays unconditional: Pele is also text-measured, + so re-render-on-fonts-settle is equally desirable. + +### 2. Pele flowchart correlator (packages/dom/src/correlate.pele.ts, new file) + +Same return shape as the mermaid family — the existing `Correlation` +(correlate.ts:12-18) — so `data-mw-entity` tagging, hit overlays, popovers, +and gestures work untouched. Strategy: **data-id first, degrade on miss** +(the inverse of mermaid's convention-scraping): + +- Nodes: `g.pele-node[data-id=""]` per core node (`node:A` → `A`). + Pele escapes ids when writing data-id (`escText`); normalize core's id + with the same rules before matching (port them into the correlator; + verify against src/diagrams/flowchart/render.ts:220). +- Clusters/subgraphs: `g.pele-cluster[data-id=""]`, with + `.pele-cluster-label` for the label. +- Edges: core knows (source, target) and declaration order per pair. + Match by user `@id` (`data-id` exact) when present; else group + `g.pele-edge[data-id^="L__"]` in DOM order against core's + per-pair declaration order. Never trust count-based ids globally. +- Edge labels: `g.pele-edge-label[data-id=""]`. +- Everything unmatched lands in `failed` → view-only, same philosophy as + today. + +### 3. Editing UX over pele (mostly free) + +- Double-click rename: `editInPlace` finds no HTMLElement label in a pele + node → falls back to `openOverlayEditor` (already renderer-agnostic, + positioned off the entity bbox). Zero changes; the PoC accepts + overlay-editor UX. +- Drag-to-connect ghost, add-node, delete, popovers, selection styles, + hit-walking (`data-mw-entity`) — renderer-agnostic already. +- `addEdgeHitOverlay` clones visible `` elements; pele edges are + `` children of `g.pele-edge` (stroke set on the group). Verify + stroke inheritance produces a sane 14px hit overlay; if not, have the + clone read effective stroke. + +### 4. React plumbing (packages/react/src/index.tsx) + +- `MermaidWysiwyg` and `MermaidCanvas`: `mermaid` becomes optional, new + optional `renderer` prop; exactly one required at runtime. Making + `mermaid` optional is safe for existing callers (they still pass it) — + the constructor throws a clear error if neither is given. +- View-recreation effect deps gain `renderer` next to `mermaid`. + +### 5. Playground demo (apps/playground) + +- Toolbar: renderer toggle `Mermaid | Pele` (default Mermaid, persisted in + localStorage). +- Pele path: `renderer={peleAdapter(pele, { fontFamily: })}`; + theme via a `--pele-*` CSS-variable block eyeball-matched to INK_THEME + (pele's theming docs list the variables). +- Same editor, source, CodeMirror pane, and undo stack across the toggle + (the editor is renderer-independent — core only). +- Under Pele: non-flowchart types render but are view-only (correlator + returns null); zenuml samples show a "not supported by pele" notice (pele + supports no external diagrams). + +### 6. Tests + +- `packages/dom/test/correlate-pele.test.ts` — the big win: jsdom + real + `pele.render()` output (metrics fallback makes it deterministic in + Node). Fixtures: standard flowchart, subgraphs, `@id` edges, duplicate + A→B edges, special-char ids, multi-line labels. Assert every entity + correlates to the right element; assert `failed` on a synthetic id + mismatch. +- `packages/dom/test/renderer.test.ts` — adapter contracts: + `mermaidAdapter` preserves the call sequence (initialize before every + render); `peleAdapter` maps `PeleError` → diagnostic; view over a fake + pele renderer: keep-last-good on throw, overlay fallback, toggle + re-render. +- Existing suites green unmodified. + +### 7. Sequencing (implementation order, one commit each) + +1. Renderer seam + `mermaidAdapter` (no behavior change; existing tests + prove it) +2. `correlate.pele.ts` + correlator tests +3. View wiring: bindSvg dispatch, validate, diagnostic source, error-DOM + gating (+ core `Diagnostic.source` widening) +4. React optional `renderer` prop +5. Playground toggle + theme + +## What's left to the builder + +- Exact pele id-escape normalization (port `escText` rules; verify + special-char ids) +- Edge per-pair DOM-order matching details (document order of + `L_src_tgt_*` groups vs core declaration order) +- Whether `addEdgeHitOverlay` needs an effective-stroke fix for pele's + group-level stroke +- Pele CSS-variable theme values for the playground (eyeball-match INK) +- zenuml/non-flowchart picker behavior details under pele From 47ce87adb5aa23c5e79282ac9141fcf4e4d3ef88 Mon Sep 17 00:00:00 2001 From: Casey Gollan Date: Sun, 1 Nov 2026 11:35:26 -0500 Subject: [PATCH 02/21] docs: fold builder-check findings into pele PoC design --- .../2026-11-01-pele-renderer-poc-design.md | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md b/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md index 9c962a2..d85be48 100644 --- a/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md +++ b/docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md @@ -96,6 +96,23 @@ Facts from the Pele v1.13.0 source audit that shape the design: the same id scheme. - Node/cluster data-ids are source ids (stable); edge data-ids are count-based (unstable when a duplicate edge is inserted). +- **Pele does not render invisible (`~~~`) edges at all** (the + `g.pele-edge` group is gated on `thickness !== 'invisible'`; a labeled + invisible edge still gets its `g.pele-edge-label`). Mermaid renders + invisible edges as real clickable paths, so under pele they are + genuinely view-only — and a naive order-match would pair the wrong core + edge with a rendered path (mis-correlation that edits the wrong line). +- **Pele filters style declarations through a fixed allowlist** + (`SHAPE_PROPS`/`TEXT_PROPS`, src/svg/theme.ts): `animation-*` + declarations are silently dropped, so the edge-animation popover action + is a silent no-op under pele. +- **Subgraph titles crossed by edges are drawn outside their cluster**, + in a top-level `g.pele-cluster-titles` as + `g.pele-cluster-title[data-id=""]`. +- **Core cannot parse edge-id syntax today** (`A e1@--> B` fails at `e1@` + and the line degrades to unknown, for both renderers equally) — so edge + `@id` matching is not implementable without a core parse change, which + the non-goals exclude. Pre-existing limitation, out of PoC scope. - Labels are plain SVG `` — no foreignObject HTML. The view's `editInPlace` already returns false when no HTMLElement label is found and falls back to the overlay editor (view.ts:1403) — no @@ -193,14 +210,25 @@ and gestures work untouched. Strategy: **data-id first, degrade on miss** with the same rules before matching (port them into the correlator; verify against src/diagrams/flowchart/render.ts:220). - Clusters/subgraphs: `g.pele-cluster[data-id=""]`, with - `.pele-cluster-label` for the label. -- Edges: core knows (source, target) and declaration order per pair. - Match by user `@id` (`data-id` exact) when present; else group - `g.pele-edge[data-id^="L__"]` in DOM order against core's - per-pair declaration order. Never trust count-based ids globally. -- Edge labels: `g.pele-edge-label[data-id=""]`. + `.pele-cluster-label` for the label. Struck titles in + `g.pele-cluster-titles` (`g.pele-cluster-title[data-id=""]`) also + correlate to the subgraph entity, so crossed titles stay clickable and + renamable. +- Edges: core knows (source, target) and declaration order per pair; + pele never exposes user edge-ids core could use (core can't parse + `@id` syntax — see constraints). Match **per (source, target) pair, + count-checked**: group `g.pele-edge[data-id^="L__"]` in DOM + order against core's per-pair declaration order — but only when the + pair's rendered count equals the core count. If pele rendered fewer + elements than core has edges for a pair (e.g. invisible `~~~` edges, + which pele does not render), degrade that pair to `failed` rather than + blind-order-match: a mis-paired edge would edit or delete the wrong + source line, the worst failure mode for an editor. Invisible edges are + accepted as view-only under pele. +- Edge labels: `g.pele-edge-label[data-id=""]`, with the + same per-pair count check. - Everything unmatched lands in `failed` → view-only, same philosophy as - today. + today. Degrade, never mis-correlate. ### 3. Editing UX over pele (mostly free) @@ -210,6 +238,13 @@ and gestures work untouched. Strategy: **data-id first, degrade on miss** overlay-editor UX. - Drag-to-connect ghost, add-node, delete, popovers, selection styles, hit-walking (`data-mw-entity`) — renderer-agnostic already. +- **One popover action is gated under pele**: the edge-animation picker + emits `linkStyle N animation-*`, which pele's style allowlist silently + drops — so under `renderer.id === 'pele'` the animation action is + hidden and `injectAnimationKeyframes` is skipped. Silent feature loss + is this codebase's documented highest-risk failure mode + (TESTING.md:17); gate it instead. Node colors, shapes, arrows, + thickness, and curve all round-trip under pele and stay enabled. - `addEdgeHitOverlay` clones visible `` elements; pele edges are `` children of `g.pele-edge` (stroke set on the group). Verify stroke inheritance produces a sane 14px hit overlay; if not, have the @@ -240,10 +275,12 @@ and gestures work untouched. Strategy: **data-id first, degrade on miss** - `packages/dom/test/correlate-pele.test.ts` — the big win: jsdom + real `pele.render()` output (metrics fallback makes it deterministic in - Node). Fixtures: standard flowchart, subgraphs, `@id` edges, duplicate - A→B edges, special-char ids, multi-line labels. Assert every entity - correlates to the right element; assert `failed` on a synthetic id - mismatch. + Node). Fixtures: standard flowchart, subgraphs (including a title + struck by an edge), duplicate A→B edges, invisible `~~~` edges + (asserting degradation, not mis-pairing), special-char ids, and + multi-line labels. Assert every entity correlates to the right + element; assert `failed` on a synthetic id mismatch and on + count-mismatched pairs. - `packages/dom/test/renderer.test.ts` — adapter contracts: `mermaidAdapter` preserves the call sequence (initialize before every render); `peleAdapter` maps `PeleError` → diagnostic; view over a fake @@ -265,9 +302,13 @@ and gestures work untouched. Strategy: **data-id first, degrade on miss** - Exact pele id-escape normalization (port `escText` rules; verify special-char ids) -- Edge per-pair DOM-order matching details (document order of - `L_src_tgt_*` groups vs core declaration order) +- Verify pele emits a pair's `L_src_tgt_*` groups in declaration order + (the count-checked order-match assumes it) +- Store the inner ``, not the `g.pele-edge` group, in + `Correlation.edges` — the overlay editor's `SVGPathElement` midpoint + branch and the hover-CSS sibling rule depend on it - Whether `addEdgeHitOverlay` needs an effective-stroke fix for pele's group-level stroke - Pele CSS-variable theme values for the playground (eyeball-match INK) -- zenuml/non-flowchart picker behavior details under pele +- Memoize the adapter in the playground so the view isn't recreated per + React render; zenuml/non-flowchart picker behavior details under pele From 82f86694f701286e2404445843b0c30646472e32 Mon Sep 17 00:00:00 2001 From: Casey Gollan Date: Sun, 1 Nov 2026 11:45:13 -0500 Subject: [PATCH 03/21] docs: implementation plan for pele renderer PoC --- .../plans/2026-11-01-pele-renderer-poc.md | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-01-pele-renderer-poc.md diff --git a/docs/superpowers/plans/2026-11-01-pele-renderer-poc.md b/docs/superpowers/plans/2026-11-01-pele-renderer-poc.md new file mode 100644 index 0000000..45cf1b2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-pele-renderer-poc.md @@ -0,0 +1,297 @@ +# Pele Renderer PoC 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:** Let `@visimer/dom` render and fully WYSIWYG-edit flowcharts through Obsidian's Pele renderer (user-supplied instance), behind a generalized renderer seam, demoed by a playground toggle. + +**Architecture:** Generalize dom's private `MermaidLike` usage into a `RendererAdapter` (new `renderer.ts`); the view routes render/validate through it and picks correlators by `adapter.id`. A new `correlate.pele.ts` maps Pele's documented `data-id`/`pele-*` SVG contract onto the existing `Correlation` shape, so gestures/popovers/undo work untouched. Core, CodeMirror, and Monaco are untouched except one type-union widening. + +**Tech Stack:** TypeScript (strict, ESM), vitest + jsdom, pnpm workspace, pele v1.13.0 (devDep of `@visimer/dom`, dep of playground only). + +**Spec:** `docs/superpowers/specs/2026-11-01-pele-renderer-poc-design.md` — the plan argues from the spec; read both. + +## Global Constraints + +- Packages never depend on pele: `PeleLike` is a hand-written structural interface in dom; `pele` appears only as a devDependency of `@visimer/dom` (tests) and a dependency of `apps/playground` (demo). Spec anti-goal. +- The mermaid path keeps byte-identical behavior (initialize-at-creation and per-render, temp-element cleanup, validate, config re-init). Existing suites stay green, unmodified. Spec goal 5. +- Never mis-correlate: when a match is ambiguous, the entity degrades to view-only (`failed`) instead of pairing a wrong element. Spec anti-goal. +- Core's only change: `Diagnostic.source` widens to `'core' | 'mermaid' | 'pele'` (packages/core/src/types.ts:55). Spec constraint. +- Entity-id formats from core (flowchart/graph.ts): nodes `node:`, edges `edge:->#`, subgraphs `subgraph:`. +- Test files start with `// @vitest-environment jsdom` and polyfill `CSS.escape` exactly like packages/dom/test/fonts.test.ts:1-10. +- Commands: single file `pnpm --filter @visimer/dom exec vitest run test/`; package suite `pnpm --filter @visimer/dom test` / `--filter @visimer/core test`; everything `pnpm test` / `pnpm typecheck`. +- Commits: one per task, repo style (`feat(dom): …`), on branch `pele-renderer-poc`. + +## Review Focus + +1. **A pair mixing visible and invisible edges** (pele omits `g.pele-edge` for `~~~`): blind order-matching would pair the wrong core edge with the rendered path and edit/delete the wrong source line. → Task 3, Fixture 4 asserts both edges degrade and the rendered path carries no `data-mw-entity`. +2. **Ids containing underscores** corrupt `L___` parsing (`L_a_b_c_0` is pair `a→b_c` or `a_b→c`). → Task 3, Fixture 5 asserts correct correlation via the leftover rule. +3. **Entity-coded ids** (`A"q`): pele writes `data-id` entity-decoded, so raw comparison silently degrades nodes to view-only. → Task 3, Fixture 6 + `decodeEntities` helper tests. +4. **Silent no-op of the animation picker under pele** (pele's style allowlist drops `animation-*`). → Task 4 asserts the animation section is absent from edge popover actions and keyframes are not injected. +5. **Pele throws on any mid-keystroke syntax error** (no partial render) — a blank canvas would break the error-tolerance promise. → Task 4 asserts keep-last-good: prior SVG stays, badge shows, diagnostics carry `source: 'pele'`. + +--- + +### Task 1: Renderer adapter module (`renderer.ts`) + +**Files:** +- Create: `packages/dom/src/renderer.ts` +- Modify: `packages/dom/src/index.ts` (add exports) +- Modify: `packages/dom/package.json` (devDependency `pele`) +- Test: `packages/dom/test/renderer.test.ts` + +**Interfaces:** +- Produces (later tasks rely on exactly these): + - `interface RendererAdapter { readonly id: 'mermaid' | 'pele'; render(code: string): Promise<{ svg: string }>; validate?(code: string): Promise<{ message: string } | null> }` + - `interface PeleLike { render(source: string, options?: Record): { svg: string; [key: string]: unknown }; parse?(text: string): unknown }` (method syntax — bivariance makes the real `pele` module structurally assignable) + - `mermaidAdapter(mermaid: MermaidLike, config?: Record): RendererAdapter` + - `peleAdapter(pele: PeleLike, options?: Record): RendererAdapter` + +- [ ] **Step 1: Write failing tests in `packages/dom/test/renderer.test.ts`** + + jsdom pragma + `CSS.escape` polyfill per Global Constraints. Cases: + + 1. `mermaidAdapter` initializes at creation and before every render — fake mermaid records `'initialize'` / `'render:'` into a `calls` array; render twice; assert `calls.filter(c => c.startsWith('render')).length === 2` and every `render` entry is immediately preceded by an `initialize`. + 2. `mermaidAdapter` cleans mermaid's temp error elements — fake `render(id)` appends `
` to `document.body` then throws; `await expect(adapter.render('bad')).rejects.toThrow()`; assert `document.querySelectorAll('[id^="mw-render"], [id^="dmw-render"]').length === 0`. + 3. `mermaidAdapter.validate` resolves `null` on parse success, `{ message }` (first line of the error) on parse throw. + 4. `peleAdapter` wraps synchronous render: fake pele `{ render: (src) => ({ svg: `${src}` }) }` → `await adapter.render('x')` resolves `{ svg }` containing `pele`. + 5. `peleAdapter.validate` maps a pele error to a diagnostic message. Fake `parse` throws `Object.assign(new Error('Parse error on line 2:\n…'), { type: 'flowchart', line: 2, column: 13 })`. Pinned message format: `` `${type} error: ${firstLine}` + (line ? ` (line ${line}, column ${column})` : '') `` → assert `expect.stringContaining('flowchart error: Parse error on line 2:')` and `expect.stringContaining('(line 2, column 13)')`. When the fake pele has no `parse`, `adapter.validate` is `undefined`. + 6. Real-pele smoke: `import { render as peleRender } from 'pele'`; build the `PeleLike` as `{ render: (src, o) => peleRender(src, o) }`; `const adapter = peleAdapter(peleLike)`; `const { svg } = await adapter.render('flowchart LR\n A --> B')`; assert `svg` contains `class="pele-node` and `data-id="A"`. + +- [ ] **Step 2: Run to verify failure** + + Run: `pnpm --filter @visimer/dom exec vitest run test/renderer.test.ts` + Expected: FAIL — cannot resolve `../src/renderer`. + +- [ ] **Step 3: Implement `packages/dom/src/renderer.ts`** + + Module-global render-id counter moved from view.ts (`mw-render-${++n}`, keep the "concurrent views never collide" comment). `mermaidAdapter`: call `initialize(config ?? {})` once at creation and before each `render(id, code)`; on throw, remove `d${id}` and `${id}` elements then rethrow; `validate` wraps `parse` (null on success, `{ message: firstLine }` on throw). `peleAdapter`: `render` returns `{ svg: pele.render(code, options).svg }` (sync → promise); `validate` present iff `pele.parse` exists, message format pinned in Step 1.5. No pele import. + +- [ ] **Step 4: Run tests to verify pass** + + Run: `pnpm --filter @visimer/dom exec vitest run test/renderer.test.ts` + Expected: PASS (all 6 cases) + +- [ ] **Step 5: Wire exports + devDep, run full gates** + + `packages/dom/src/index.ts`: export `RendererAdapter`, `PeleLike`, `mermaidAdapter`, `peleAdapter` alongside the existing view exports. Add devDep: `pnpm --filter @visimer/dom add -D pele` (resolves ^1.13.0). Run `pnpm --filter @visimer/dom test` and `pnpm typecheck`. + Expected: all pass. + +- [ ] **Step 6: Commit** + + ```bash + git add packages/dom/src/renderer.ts packages/dom/src/index.ts packages/dom/package.json packages/dom/test/renderer.test.ts pnpm-lock.yaml + git commit -m "feat(dom): renderer adapter seam with mermaid and pele adapters" + ``` + +### Task 2: View routes through `RendererAdapter` + +**Files:** +- Modify: `packages/dom/src/view.ts` (ViewOptions:155-173, constructor, render():793-858, validate():2713-2722, setMermaidConfig():529-534; delete `MermaidLike` usage in those paths — keep the type export) +- Modify: `packages/core/src/types.ts:55` (`Diagnostic.source` widening) +- Test: `packages/dom/test/renderer.test.ts` (extend) + +**Interfaces:** +- Consumes: `RendererAdapter`, `mermaidAdapter` (Task 1), `MermaidLike` (existing export, unchanged). +- Produces: `ViewOptions { editor; container; mermaid?: MermaidLike; renderer?: RendererAdapter; mermaidConfig?; … }` — exactly one of `mermaid`/`renderer` required (constructor throws `Error('MermaidCanvasView: pass exactly one of `mermaid` or `renderer`')`); view keeps `private renderer: RendererAdapter` and `private mermaidInstance: MermaidLike | null`; render-error and validate diagnostics carry `source: this.renderer.id`. + +- [ ] **Step 1: Write failing tests (extend `renderer.test.ts`)** + + 1. View renders through a custom adapter: fake `RendererAdapter` (`id: 'pele'`, render returns `…`); construct `MermaidCanvasView` with `renderer` and no `mermaid`; assert container's `svgHost` contains the fake svg after the initial render (await the view's `'render'` event). + 2. Exactly-one enforcement: constructor without either option throws; with both throws. + 3. Mermaid call-order preserved: fake mermaid (fonts.test.ts pattern) records calls; construct view (initialize #1 at construction), then `editor.setCode('flowchart LR\n A --> B\n A --> C\n', 'external')` and await render → initialize #2 precedes render. + 4. Diagnostics source follows the adapter: fake adapter with `id: 'pele'` whose `render` rejects → after render settles, `view.renderError` truthy and `editor` diagnostics contain `{ source: 'pele' }` (read via the view's render error path; assert through `editor`'s diagnostics getter or the `'render'` event payload `{ ok: false }`). + 5. `setMermaidConfig` no-ops under a non-mermaid adapter: fake `id: 'pele'` adapter records render calls; `view.setMermaidConfig({ theme: 'dark' })` → `console.warn` spy called once, no additional render, `view.renderError` unchanged. + +- [ ] **Step 2: Run to verify failure** + + Run: `pnpm --filter @visimer/dom exec vitest run test/renderer.test.ts` + Expected: FAIL — `renderer` not a known `ViewOptions` key / type errors. + +- [ ] **Step 3: Widen core's `Diagnostic.source`** + + `packages/core/src/types.ts:55`: `'core' | 'mermaid'` → `'core' | 'mermaid' | 'pele'`. Run `pnpm --filter @visimer/core test`. + Expected: PASS (widening is additive). + +- [ ] **Step 4: Implement the view seam** + + Constructor: exactly-one check; `this.mermaidInstance = options.mermaid ?? null`; `this.renderer = options.renderer ?? mermaidAdapter(options.mermaid!, this.mermaidConfig)` (config defaults at view.ts:428 unchanged); drop the constructor's direct `mermaid.initialize` (the adapter initializes at creation). `render()`: delete the id-generation and `this.mermaid.initialize` lines; `const { svg } = await this.renderer.render(code)`; in the catch, delete the `d${id}`/`${id}` cleanup (Task 1 moved it into `mermaidAdapter`) and set `source: this.renderer.id`. `validate()`: `const d = await this.renderer.validate?.(this.editor.code); if (!d) return true; setDiagnostics([{ message: d.message, span: null, severity: 'error', source: this.renderer.id }]); return false`. `setMermaidConfig`: keep today's mermaid behavior exactly (rebuild `this.renderer = mermaidAdapter(this.mermaidInstance, this.mermaidConfig)`, clear `lastRenderedCode`, re-render); under `renderer.id !== 'mermaid'` warn once and return. Update `ViewOptions` JSDoc: `mermaid` optional when `renderer` is provided. + +- [ ] **Step 5: Run all gates** + + Run: `pnpm --filter @visimer/dom test && pnpm --filter @visimer/core test && pnpm typecheck` + Expected: PASS — including the pre-existing suites (fonts, inplace, visibility-retry, correlate-hidden-mount) unmodified. + +- [ ] **Step 6: Commit** + + ```bash + git add packages/dom/src/view.ts packages/core/src/types.ts packages/dom/test/renderer.test.ts + git commit -m "refactor(dom): route the canvas view through RendererAdapter" + ``` + +### Task 3: Pele flowchart correlator + dispatch + +**Files:** +- Create: `packages/dom/src/correlate.pele.ts` +- Modify: `packages/dom/src/correlate.ts:32` (`addEdgeHitOverlay` becomes `export function`) +- Modify: `packages/dom/src/view.ts` (`bindSvg()`:898-917 dispatch) +- Modify: `packages/dom/src/index.ts` (export `correlatePeleFlowchart`) +- Test: `packages/dom/test/correlate-pele.test.ts` + +**Interfaces:** +- Consumes: `Correlation` (correlate.ts:12-18), `FlowGraph`/`FlowNode`/`FlowEdge`/`FlowSubgraph` from `@visimer/core`, `addEdgeHitOverlay(path, entityId)`. +- Produces: `correlatePeleFlowchart(svg: SVGSVGElement, graph: FlowGraph): Correlation`, selected in `bindSvg` when `this.renderer.id === 'pele'` (flowchart slot only; all other slots → `null` correlation, `seqCorrelation` `null`, `armVisibilityRetry` passed `null`). + +- [ ] **Step 1: Write failing tests in `packages/dom/test/correlate-pele.test.ts`** + + Harness: pragma + polyfill; `import { render as peleRender } from 'pele'`; helper `correlate(code)` → `new MermaidWysiwygEditor({ code })`, `graph = editor.result.flowchart`, `host.innerHTML = peleRender(code).svg`, `correlatePeleFlowchart(host.querySelector('svg')!, graph)`. Real pele works under jsdom (no canvas → its `metricsMeasurer` fallback). + + Fixtures (exact code strings): + 1. Standard — `'flowchart TD\n A[Start] --> B{OK?}\n B -->|yes| C[Ship]\n'`: `nodes` has `node:A`/`node:B`/`node:C` mapped to `g.pele-node` elements (each tagged `data-mw-entity`), `edges` has `edge:A->B#0`/`edge:B->C#0` mapped to the **inner ``** of the right `g.pele-edge` (assert path is inside the group whose `data-id` starts `L_A_B_` / `L_B_C_`, and that the path's `data-mw-hit-overlay` sibling clone exists), `edgeLabels` has `edge:B->C#0` mapped to `g.pele-edge-label[data-id="L_B_C_0"]`, `failed` empty. + 2. Subgraph — `'flowchart TD\n subgraph SG [Group]\n A[Start] --> B\n end\n B --> C\n'`: `nodes` includes `subgraph:SG` → `g.pele-cluster[data-id="SG"]`. Then inject synthetically `Group` into the svg and re-correlate: the `g.pele-cluster-title` element is tagged `data-mw-entity="subgraph:SG"` (struck titles live outside their cluster; pele src/svg/cluster.ts:67). + 3. Duplicate pair — `'flowchart TD\n A -->|one| B\n A -->|two| B\n'`: `edge:A->B#0` and `edge:A->B#1` both correlate, each to a distinct path, first-declared → first-in-DOM for the pair; `edgeLabels` maps both. + 4. Invisible member — `'flowchart TD\n A --> B\n A ~~~ B\n'`: pele renders one `g.pele-edge`; assert `edges` is empty, `failed` contains `edge:A->B#0` **and** `edge:A->B#1`, and the rendered path carries no `data-mw-entity` (degrade, never mis-pair). + 5. Special ids — `'flowchart TD\n a-b --> c\n a --> b_c\n'`: `node:a-b`, `node:b_c`, `edge:a-b->c#0` all correlate (underscore target `b_c` makes pele's `data-id` `L_a_b_c_0`; the leftover rule must pair it correctly). + 6. Entity-coded id — synthetic: `svg.insertAdjacentHTML` a `…` (DOM attribute value `A"x`) and a hand-built minimal graph node `{ entityId: 'node:x', id: 'A"x', … }` as `FlowNode`; assert it correlates. Plus unit tests for the helper: `decodeEntities('A"x') === 'A"x'`, `decodeEntities('a&b') === 'a&b'`, `decodeEntities('A') === 'A'`, unknown entity passes through. + 7. Multi-line label — `'flowchart TD\n A -->|"line1
line2"| B\n'`: `edgeLabels` has `edge:A->B#0`. + 8. View dispatch — `MermaidCanvasView` + `peleAdapter({ render: (src) => peleRender(src) })` on fixture 1: after `'render'`, container svg contains `[data-mw-entity="node:A"]`. Same view on a pie (`'pie title Pets\n "cat": 40\n "dog": 60\n'`): renders (svg exists) but contains zero `data-mw-entity` attributes. + +- [ ] **Step 2: Run to verify failure** + + Run: `pnpm --filter @visimer/dom exec vitest run test/correlate-pele.test.ts` + Expected: FAIL — cannot resolve `../src/correlate.pele`. + +- [ ] **Step 3: Implement `correlate.pele.ts` + dispatch** + + Algorithm (data-id first, degrade on miss): + - `decodeEntities(s)`: decode `& < > " '` and `&#NN;`/`&#xHH;`; leave unknown codes as-is. + - Nodes: iterate all `g.pele-node`; match when `el.getAttribute('data-id') === node.id || === decodeEntities(node.id)`; tag + claim. Leftovers on both sides: if counts equal, zip in DOM order; else all unmatched → `failed`. + - Clusters: for each subgraph, `g.pele-cluster` by the same data-id rule → tag + `nodes.set`; additionally tag any `g.pele-cluster-title` with matching data-id (don't store in the map — selection styling queries the live DOM). + - Edge groups: for every `g.pele-edge`, require `data-id` starting `L_`; parse from the right: last `_`-segment must be numeric (count), second-last is target, remainder is source; bucket DOM elements by `source->target`. Core edges bucketed by `${source}->${target}` in `edges`-array order (declaration order). Per pair: equal counts → zip DOM order to declaration order, storing the group's inner `` with `data-mw-entity` + `addEdgeHitOverlay(path, entityId)`; unequal counts → the whole pair's core edges go to `failed` and its DOM elements stay unclaimed. + - Leftover reconciliation (handles underscore ids): unclaimed DOM edge elements vs still-unmatched core edges — equal counts → zip in document order; else all degrade. + - Edge labels: same parse over `g.pele-edge-label`; per pair, zip against that pair's **labeled** core edges when counts are equal; leftovers reconciled the same way; store the `g` element tagged. + - `bindSvg` (view.ts:898-917): `const isPele = this.renderer.id === 'pele'`; under pele `this.correlation = flowchart ? correlatePeleFlowchart(svg, flowchart) : null`; `this.seqCorrelation = !isPele && sequence ? correlateSequence(svg, sequence) : null`; pass `null` (not `sequence`) to `armVisibilityRetry` under pele. The mermaid chain stays byte-identical. + - `correlate.ts:32`: `function addEdgeHitOverlay` → `export function` (comment moves with it). + +- [ ] **Step 4: Run tests to verify pass** + + Run: `pnpm --filter @visimer/dom exec vitest run test/correlate-pele.test.ts` + Expected: PASS (all fixtures) + +- [ ] **Step 5: Full gates + export** + + `packages/dom/src/index.ts`: export `correlatePeleFlowchart` next to `correlateFlowchart`. Run `pnpm --filter @visimer/dom test && pnpm typecheck`. + Expected: PASS. + +- [ ] **Step 6: Commit** + + ```bash + git add packages/dom/src/correlate.pele.ts packages/dom/src/correlate.ts packages/dom/src/view.ts packages/dom/src/index.ts packages/dom/test/correlate-pele.test.ts + git commit -m "feat(dom): pele flowchart correlator" + ``` + +### Task 4: Pele view behaviors (gating + integration) + +**Files:** +- Modify: `packages/dom/src/view.ts` (edge-popover animation section gated; `injectAnimationKeyframes` call skipped) +- Test: `packages/dom/test/pele-view.test.ts` + +**Interfaces:** +- Consumes: `RendererAdapter` (Task 1), view seam (Task 2), `peleAdapter` over real pele. +- Produces: no new exports — behavior gates only. + +- [ ] **Step 1: Write failing tests in `packages/dom/test/pele-view.test.ts`** + + Harness: real pele through `peleAdapter`; `MermaidCanvasView` in a jsdom container; await the view's `'render'` event. + + 1. Keep-last-good: render `'flowchart TD\n A[Start] --> B\n'`; then `editor.setCode('flowchart TD\n A[unclosed', 'external')` and await the next `'render'` event → `{ ok: false }`; assert container still shows an svg containing `data-mw-entity="node:A"` (the previous render), `view.renderError` is truthy, and editor diagnostics contain `source: 'pele'`. Then `editor.setCode` back to the valid source → next render `{ ok: true }`, `renderError` null. + 2. Overlay-editor fallback: render the standard fixture; call `view.editEntityLabel('node:A')`; assert no element inside the svg has `contenteditable` (pele labels are SVG ``), and an overlay editor element (input/textarea, per `openOverlayEditor`'s implementation) appears in the container showing `Start`. Commit a new value following the commit path used by packages/dom/test/inplace.test.ts (Enter/blur per the overlay editor's own contract); assert `editor.code` contains the new label. Read `openOverlayEditor` (view.ts:1597+) first and follow its actual commit event. + 3. Animation gating: with `'flowchart TD\n A --> B\n'` rendered under pele, call `actionsFor` via bracket access (`(view as unknown as { actionsFor(id: string): PopoverAction[] }).actionsFor('edge:A->B#0')`); assert no item title contains `'Slow flow'` (the `anims` section, view.ts:1959-1963). Then repeat with a fake mermaid view on the same code: the animation items ARE present. + 4. Keyframes skipped: after rendering under pele, the svg contains no `