# F7.1 Intra-Diagram Mermaid Diffing 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:** Refine the F7 whole-diagram "changed" badge into a node/edge-level diff *inside* flowchart and sequence mermaid diagrams against the F6 baseline. **Architecture:** Three new **pure, vscode-free, DOM-free** host modules under `src/` parse a changed mermaid block's before/after source into a typed element model, diff it, and re-emit the *current* diagram source augmented with mermaid's own styling directives (flowchart: `classDef`/`class`/`linkStyle`; sequence: `rect rgb(...)` tinted runs), ghosting removed elements in place. The dispatcher falls back to the v1 whole-block badge for unsupported types or any parse failure. `renderOp`'s changed-mermaid branch calls the dispatcher; the webview is unchanged (styling rides inside the mermaid source); `media/preview.css` gains a legend. **Tech Stack:** TypeScript, `diff` (jsdiff) for sequence LCS, `markdown-it` (existing fence rule), vitest (unit), `@vscode/test-electron` + mocha (host E2E). **Spec:** `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md` §11 (INV-29..31). Task `benstull/vscode-cowriting-plugin#22`. --- ## File Structure - **Create `src/mermaidDiff.ts`** — dispatcher. `detectDiagramType(source)`, `diffMermaid(beforeSrc, currentSrc): MermaidDiffResult`. Wraps the typed differ in try/catch → `{kind:"fallback"}` on any throw or unsupported type. Owns the shared color palette constants. - **Create `src/mermaidFlowchartDiff.ts`** — `parseFlowchart(source): FlowGraph`, `diffFlowchart(beforeSrc, currentSrc): string`. - **Create `src/mermaidSequenceDiff.ts`** — `parseSequence(source): SeqDiagram`, `diffSequence(beforeSrc, currentSrc): string`. - **Modify `src/trackChangesModel.ts`** — in `renderOp`, the `changed`+`atomic` branch: if the block is a mermaid fence, call `diffMermaid` on the inner source; `augmented` → render the augmented fence + legend (no single badge); `fallback` → today's behavior. Add a `mermaidFenceBody(raw)` helper. - **Modify `media/preview.css`** — `.cw-mermaid-legend` swatch styles. - **Create `test/mermaidFlowchartDiff.test.ts`**, **`test/mermaidSequenceDiff.test.ts`**, **`test/mermaidDiff.test.ts`** — unit tests. - **Modify `test/trackChangesModel.test.ts`** — assert `renderTrackChanges` HTML augments a changed flowchart/sequence and falls back otherwise. - **Modify `test/e2e/suite/trackChangesPreview.test.ts`** — host E2E for a changed flowchart. - **Create `docs/MANUAL-SMOKE-F7.1.md`** — manual webview-render smoke steps. **Shared palette (define once in `src/mermaidDiff.ts`, import elsewhere):** ```ts // Theme-neutral colors baked into emitted mermaid source (source can't read CSS vars). export const CW_COLORS = { added: "#2ea043", changed: "#d29922", removed: "#808080", } as const; ``` --- ## Task 1: Dispatcher skeleton — `detectDiagramType` + `diffMermaid` fallback **Files:** - Create: `src/mermaidDiff.ts` - Test: `test/mermaidDiff.test.ts` - [ ] **Step 1: Write the failing test** ```ts // test/mermaidDiff.test.ts import { describe, it, expect } from "vitest"; import { detectDiagramType, diffMermaid } from "../src/mermaidDiff"; describe("detectDiagramType", () => { it("detects flowchart from `flowchart` / `graph` headers", () => { expect(detectDiagramType("flowchart LR\n a-->b")).toBe("flowchart"); expect(detectDiagramType("graph TD\n a-->b")).toBe("flowchart"); expect(detectDiagramType(" \n%% c\ngraph TD")).toBe("flowchart"); }); it("detects sequence from `sequenceDiagram`", () => { expect(detectDiagramType("sequenceDiagram\n A->>B: hi")).toBe("sequence"); }); it("returns `other` for unsupported types", () => { expect(detectDiagramType("classDiagram\n class A")).toBe("other"); expect(detectDiagramType("stateDiagram-v2\n s1 --> s2")).toBe("other"); expect(detectDiagramType("")).toBe("other"); }); }); describe("diffMermaid", () => { it("falls back for an unsupported diagram type", () => { expect(diffMermaid("classDiagram\n class A", "classDiagram\n class B")).toEqual({ kind: "fallback" }); }); it("falls back (never throws) when the differ throws", () => { // A deliberately malformed flowchart still degrades gracefully. const r = diffMermaid("flowchart LR", "flowchart LR\n a -->"); expect(["augmented", "fallback"]).toContain(r.kind); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/mermaidDiff.test.ts` Expected: FAIL — `Cannot find module '../src/mermaidDiff'`. - [ ] **Step 3: Write minimal implementation** ```ts // src/mermaidDiff.ts /** * mermaidDiff — F7.1 (#22) dispatcher. Detects a mermaid diagram's type and * routes a baseline→current diff to the matching pure differ, which re-emits the * CURRENT diagram source augmented with mermaid styling directives (INV-29). Any * unsupported type or parser surprise degrades to the v1 whole-block badge * (INV-30) — this function never throws. vscode-free, DOM-free, deterministic. */ import { diffFlowchart } from "./mermaidFlowchartDiff"; import { diffSequence } from "./mermaidSequenceDiff"; export const CW_COLORS = { added: "#2ea043", changed: "#d29922", removed: "#808080", } as const; export type DiagramType = "flowchart" | "sequence" | "other"; export interface MermaidDiffAugmented { kind: "augmented"; source: string; } export interface MermaidDiffFallback { kind: "fallback"; } export type MermaidDiffResult = MermaidDiffAugmented | MermaidDiffFallback; export function detectDiagramType(source: string): DiagramType { for (const line of source.split(/\r?\n/)) { const t = line.trim(); if (t === "" || t.startsWith("%%")) continue; if (/^(flowchart|graph)\b/.test(t)) return "flowchart"; if (/^sequenceDiagram\b/.test(t)) return "sequence"; return "other"; } return "other"; } export function diffMermaid(beforeSrc: string, currentSrc: string): MermaidDiffResult { const type = detectDiagramType(currentSrc); try { if (type === "flowchart") return { kind: "augmented", source: diffFlowchart(beforeSrc, currentSrc) }; if (type === "sequence") return { kind: "augmented", source: diffSequence(beforeSrc, currentSrc) }; } catch { return { kind: "fallback" }; } return { kind: "fallback" }; } ``` Also create minimal stubs so the module imports resolve (real impl in Tasks 2–5): ```ts // src/mermaidFlowchartDiff.ts — stub, replaced in Tasks 2-3 export function diffFlowchart(_beforeSrc: string, currentSrc: string): string { return currentSrc; } ``` ```ts // src/mermaidSequenceDiff.ts — stub, replaced in Tasks 4-5 export function diffSequence(_beforeSrc: string, currentSrc: string): string { return currentSrc; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/mermaidDiff.test.ts` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/mermaidDiff.ts src/mermaidFlowchartDiff.ts src/mermaidSequenceDiff.ts test/mermaidDiff.test.ts git commit -m "feat(f7.1): mermaid diff dispatcher + type detection (#22)" ``` --- ## Task 2: Flowchart parser — `parseFlowchart` **Files:** - Modify: `src/mermaidFlowchartDiff.ts` (replace the stub) - Test: `test/mermaidFlowchartDiff.test.ts` - [ ] **Step 1: Write the failing test** ```ts // test/mermaidFlowchartDiff.test.ts import { describe, it, expect } from "vitest"; import { parseFlowchart } from "../src/mermaidFlowchartDiff"; describe("parseFlowchart", () => { it("captures the header and node declarations with labels + shapes", () => { const g = parseFlowchart("flowchart LR\n A[Start] --> B(Middle)\n B --> C{End}"); expect(g.header).toBe("flowchart LR"); expect(g.nodes.get("A")).toMatchObject({ id: "A", label: "Start", open: "[", close: "]" }); expect(g.nodes.get("B")).toMatchObject({ id: "B", label: "Middle", open: "(", close: ")" }); expect(g.nodes.get("C")).toMatchObject({ id: "C", label: "End", open: "{", close: "}" }); }); it("captures edges in declaration order with from/to and index", () => { const g = parseFlowchart("graph TD\n A-->B\n B-->C"); expect(g.edges.map((e) => [e.from, e.to, e.index])).toEqual([ ["A", "B", 0], ["B", "C", 1], ]); }); it("records a node referenced only in an edge (no explicit declaration)", () => { const g = parseFlowchart("flowchart LR\n A-->B"); expect(g.nodes.has("A")).toBe(true); expect(g.nodes.has("B")).toBe(true); expect(g.nodes.get("A")?.label).toBeUndefined(); }); it("handles a labelled edge `A -->|yes| B`", () => { const g = parseFlowchart("flowchart LR\n A -->|yes| B"); expect(g.edges[0]).toMatchObject({ from: "A", to: "B", label: "yes" }); }); it("ignores comments and blank lines", () => { const g = parseFlowchart("flowchart LR\n%% a comment\n\n A-->B"); expect(g.edges).toHaveLength(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` Expected: FAIL — `parseFlowchart` is not exported. - [ ] **Step 3: Write minimal implementation** ```ts // src/mermaidFlowchartDiff.ts /** * mermaidFlowchartDiff — F7.1 (#22). Pure flowchart parser + diff/emit. Parses a * `graph`/`flowchart` source into nodes (by id) and edges (by declaration order), * diffs baseline vs current, and re-emits the CURRENT source augmented with * `classDef`/`class`/`linkStyle` directives coloring added/changed elements and * ghosting removed ones (INV-29/31). Deterministic; no vscode, no DOM. */ import { CW_COLORS } from "./mermaidDiff"; export interface FlowNode { id: string; label?: string; open?: string; close?: string; /** Verbatim declaration token, e.g. `A[Start]`, for ghost re-injection. */ decl?: string; } export interface FlowEdge { from: string; to: string; label?: string; index: number; /** Verbatim edge statement for ghost re-injection. */ raw: string; } export interface FlowGraph { header: string; nodes: Map; edges: FlowEdge[]; } const NODE_TOKEN = /^([A-Za-z0-9_]+)(\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{[^}]*\}|>[^\]]*\])?/; // link operators: -->, ---, -.->, -.-, ==>, ===, --x, --o, ==x, ==o const LINK = /(-->|---|-\.->|-\.-|==>|===|--[xo]|==[xo])(\|([^|]*)\|)?/; function shapeOf(bracket: string): { label: string; open: string; close: string } { const open = bracket[0]; if (bracket.startsWith("[[")) return { label: bracket.slice(2, -2), open: "[[", close: "]]" }; if (bracket.startsWith("((")) return { label: bracket.slice(2, -2), open: "((", close: "))" }; const close = bracket[bracket.length - 1]; return { label: bracket.slice(1, -1), open, close }; } function recordNode(nodes: Map, token: string): string | null { const m = token.match(NODE_TOKEN); if (!m) return null; const id = m[1]; const existing = nodes.get(id) ?? { id }; if (m[2]) { const s = shapeOf(m[2]); existing.label = s.label; existing.open = s.open; existing.close = s.close; existing.decl = `${id}${m[2]}`; } nodes.set(id, existing); return id; } export function parseFlowchart(source: string): FlowGraph { const lines = source.split(/\r?\n/); let header = ""; const nodes = new Map(); const edges: FlowEdge[] = []; for (const rawLine of lines) { const line = rawLine.trim(); if (line === "" || line.startsWith("%%")) continue; if (!header && /^(flowchart|graph)\b/.test(line)) { header = line; continue; } // Walk a (possibly chained) edge statement: A[..] --> B -->|x| C let rest = line; let prevId: string | null = null; let consumedEdge = false; while (rest.length > 0) { const nodeM = rest.match(NODE_TOKEN); if (!nodeM) break; const id = recordNode(nodes, nodeM[0]); rest = rest.slice(nodeM[0].length).trimStart(); const linkM = rest.match(LINK); if (linkM && prevId === null) { // current token is the LHS; fall through to read RHS } if (linkM) { const fromId = id!; rest = rest.slice(linkM[0].length).trimStart(); const rhsM = rest.match(NODE_TOKEN); if (!rhsM) break; const toId = recordNode(nodes, rhsM[0]); edges.push({ from: fromId, to: toId!, label: linkM[3] || undefined, index: edges.length, raw: `${fromId} ${linkM[1]}${linkM[2] ? linkM[2] : ""} ${toId}`, }); rest = rest.slice(rhsM[0].length).trimStart(); prevId = toId; consumedEdge = true; // chained: the RHS may be the LHS of another link const more = rest.match(LINK); if (more) rest = `${toId} ${rest}`; continue; } break; } void consumedEdge; } return { header: header || "flowchart TD", nodes, edges }; } ``` > Note: the chained-edge handling (`A --> B --> C`) is intentionally simple. The unit tests in this task pin the contract for the single-edge and two-statement cases; if a chained test is added later, the `more`/re-prefix branch is the place to harden. Keep the parser total — never throw on odd input; return whatever was parsed. - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts git commit -m "feat(f7.1): flowchart parser (#22)" ``` --- ## Task 3: Flowchart diff + augmented emission — `diffFlowchart` **Files:** - Modify: `src/mermaidFlowchartDiff.ts` - Test: `test/mermaidFlowchartDiff.test.ts` - [ ] **Step 1: Write the failing test** ```ts // append to test/mermaidFlowchartDiff.test.ts import { diffFlowchart } from "../src/mermaidFlowchartDiff"; describe("diffFlowchart", () => { it("colors an added node with the cwAdded class", () => { const before = "flowchart LR\n A-->B"; const current = "flowchart LR\n A-->B\n B-->C"; const out = diffFlowchart(before, current); expect(out).toContain("classDef cwAdded"); expect(out).toMatch(/class\s+C\s+cwAdded/); }); it("colors a changed node (same id, new label) with cwChanged", () => { const before = "flowchart LR\n A[Old]-->B"; const current = "flowchart LR\n A[New]-->B"; const out = diffFlowchart(before, current); expect(out).toMatch(/class\s+A\s+cwChanged/); }); it("styles an added edge via linkStyle on its current index", () => { const before = "flowchart LR\n A-->B"; const current = "flowchart LR\n A-->B\n A-->C"; const out = diffFlowchart(before, current); // edge index 1 (A-->C) is the added one expect(out).toMatch(/linkStyle\s+1\s+stroke:#2ea043/); }); it("ghosts a removed node in place with cwRemoved (re-injected decl)", () => { const before = "flowchart LR\n A-->B\n B-->C[Gone]"; const current = "flowchart LR\n A-->B"; const out = diffFlowchart(before, current); expect(out).toContain("C[Gone]"); // re-injected expect(out).toMatch(/class\s+C\s+cwRemoved/); }); it("is deterministic (same inputs → identical output)", () => { const before = "flowchart LR\n A-->B"; const current = "flowchart LR\n A-->B\n B-->C\n A-->D"; expect(diffFlowchart(before, current)).toBe(diffFlowchart(before, current)); }); it("preserves the original current source as the prefix", () => { const current = "flowchart LR\n A-->B\n B-->C"; const out = diffFlowchart("flowchart LR\n A-->B", current); expect(out.startsWith(current)).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` Expected: FAIL — `diffFlowchart` returns the current source unchanged (stub), so `classDef`/`class` assertions fail. - [ ] **Step 3: Write the implementation** ```ts // append to src/mermaidFlowchartDiff.ts function nodeChanged(a: FlowNode, b: FlowNode): boolean { return (a.label ?? "") !== (b.label ?? "") || (a.open ?? "") !== (b.open ?? ""); } const edgeKey = (e: FlowEdge): string => `${e.from}${e.to}`; export function diffFlowchart(beforeSrc: string, currentSrc: string): string { const before = parseFlowchart(beforeSrc); const current = parseFlowchart(currentSrc); const addedNodes: string[] = []; const changedNodes: string[] = []; for (const [id, cur] of current.nodes) { const prev = before.nodes.get(id); if (!prev) addedNodes.push(id); else if (nodeChanged(prev, cur)) changedNodes.push(id); } const removedNodes: FlowNode[] = []; for (const [id, prev] of before.nodes) { if (!current.nodes.has(id)) removedNodes.push(prev); } const beforeEdgeKeys = new Set(before.edges.map(edgeKey)); const currentEdgeKeys = new Set(current.edges.map(edgeKey)); const addedEdgeIdx = current.edges.filter((e) => !beforeEdgeKeys.has(edgeKey(e))).map((e) => e.index); const removedEdges = before.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e))); // Ghost-edge indices follow the current edge count, in deterministic order. let nextIdx = current.edges.length; const ghostEdgeLines: string[] = []; const ghostEdgeIdx: number[] = []; for (const e of removedEdges) { ghostEdgeLines.push(` ${e.from} -.-> ${e.to}`); ghostEdgeIdx.push(nextIdx++); } const out: string[] = [currentSrc.replace(/\s+$/, "")]; // Ghost removed nodes: re-inject their declaration (or bare id) so they appear. for (const n of removedNodes) out.push(` ${n.decl ?? n.id}`); out.push(...ghostEdgeLines); // classDefs (always emit the three; harmless if a class is unused). out.push( ` classDef cwAdded fill:${CW_COLORS.added}22,stroke:${CW_COLORS.added},stroke-width:2px;`, ` classDef cwChanged fill:${CW_COLORS.changed}22,stroke:${CW_COLORS.changed},stroke-width:2px;`, ` classDef cwRemoved fill:${CW_COLORS.removed}11,stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3,color:${CW_COLORS.removed};`, ); if (addedNodes.length) out.push(` class ${addedNodes.join(",")} cwAdded;`); if (changedNodes.length) out.push(` class ${changedNodes.join(",")} cwChanged;`); if (removedNodes.length) out.push(` class ${removedNodes.map((n) => n.id).join(",")} cwRemoved;`); for (const i of addedEdgeIdx) out.push(` linkStyle ${i} stroke:${CW_COLORS.added},stroke-width:2px;`); for (const i of ghostEdgeIdx) out.push(` linkStyle ${i} stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3;`); return out.join("\n"); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` Expected: PASS (all flowchart tests). - [ ] **Step 5: Commit** ```bash git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts git commit -m "feat(f7.1): flowchart node/edge diff + styling emission (#22)" ``` --- ## Task 4: Sequence parser — `parseSequence` **Files:** - Modify: `src/mermaidSequenceDiff.ts` (replace the stub) - Test: `test/mermaidSequenceDiff.test.ts` - [ ] **Step 1: Write the failing test** ```ts // test/mermaidSequenceDiff.test.ts import { describe, it, expect } from "vitest"; import { parseSequence } from "../src/mermaidSequenceDiff"; describe("parseSequence", () => { it("captures explicit participants in order", () => { const d = parseSequence("sequenceDiagram\n participant A\n participant B\n A->>B: hi"); expect(d.participants).toEqual(["A", "B"]); }); it("captures message statements verbatim (trimmed)", () => { const d = parseSequence("sequenceDiagram\n A->>B: hi\n B-->>A: bye"); expect(d.statements).toEqual(["A->>B: hi", "B-->>A: bye"]); }); it("infers participants from messages when not declared", () => { const d = parseSequence("sequenceDiagram\n A->>B: hi\n B->>C: on"); expect(d.participants).toEqual(["A", "B", "C"]); }); it("ignores comments and blank lines", () => { const d = parseSequence("sequenceDiagram\n%% note\n\n A->>B: hi"); expect(d.statements).toEqual(["A->>B: hi"]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/mermaidSequenceDiff.test.ts` Expected: FAIL — `parseSequence` is not exported. - [ ] **Step 3: Write the implementation** ```ts // src/mermaidSequenceDiff.ts /** * mermaidSequenceDiff — F7.1 (#22). Pure sequence-diagram parser + diff/emit. * Mermaid sequence diagrams have NO per-message color directive; the only * per-message hook is the `rect rgb(...) … end` background block. So the emitter * rebuilds the message stream (ghosted-removed messages re-inserted at their * baseline position) wrapping each added/changed/removed message in a one-message * tinted rect, and re-declares removed participants (INV-29/31). Deterministic; * no vscode, no DOM. */ import { diffArrays } from "diff"; import { CW_COLORS } from "./mermaidDiff"; export interface SeqDiagram { header: string; participants: string[]; /** Message/other statement lines, verbatim & trimmed, in order. */ statements: string[]; } // `A->>B: text`, plus -->>, ->, -->, -x, --x, etc. Capture from/to. const MSG = /^([A-Za-z0-9_]+)\s*(-?-(?:>>|>|x|\))|--?(?:>>|>|x|\)))\s*([A-Za-z0-9_]+)\s*:/; const PARTICIPANT = /^(?:participant|actor)\s+([A-Za-z0-9_]+)/; export function parseSequence(source: string): SeqDiagram { const lines = source.split(/\r?\n/); let header = ""; const declared: string[] = []; const seen = new Set(); const statements: string[] = []; const addP = (p: string) => { if (!seen.has(p)) { seen.add(p); declared.push(p); } }; for (const rawLine of lines) { const line = rawLine.trim(); if (line === "" || line.startsWith("%%")) continue; if (!header && /^sequenceDiagram\b/.test(line)) { header = line; continue; } const pm = line.match(PARTICIPANT); if (pm) { addP(pm[1]); continue; } const mm = line.match(MSG); if (mm) { addP(mm[1]); addP(mm[3]); } statements.push(line); } return { header: header || "sequenceDiagram", participants: declared, statements }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/mermaidSequenceDiff.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts git commit -m "feat(f7.1): sequence-diagram parser (#22)" ``` --- ## Task 5: Sequence diff + augmented emission — `diffSequence` **Files:** - Modify: `src/mermaidSequenceDiff.ts` - Test: `test/mermaidSequenceDiff.test.ts` - [ ] **Step 1: Write the failing test** ```ts // append to test/mermaidSequenceDiff.test.ts import { diffSequence } from "../src/mermaidSequenceDiff"; const rectCount = (s: string, rgb: string) => s.split(`rect rgb(${rgb})`).length - 1; describe("diffSequence", () => { it("wraps an added message in a green rect", () => { const before = "sequenceDiagram\n A->>B: hi"; const current = "sequenceDiagram\n A->>B: hi\n B->>C: on"; const out = diffSequence(before, current); expect(rectCount(out, "46, 160, 67")).toBe(1); // CW_COLORS.added as rgb expect(out).toContain("B->>C: on"); }); it("ghosts a removed message back in a grey rect", () => { const before = "sequenceDiagram\n A->>B: hi\n B->>C: gone"; const current = "sequenceDiagram\n A->>B: hi"; const out = diffSequence(before, current); expect(out).toContain("B->>C: gone"); expect(rectCount(out, "128, 128, 128")).toBe(1); }); it("re-declares a removed participant so it still appears", () => { const before = "sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: x"; const current = "sequenceDiagram\n participant A\n participant B\n A->>B: y"; const out = diffSequence(before, current); expect(out).toMatch(/participant C/); }); it("keeps an unchanged message outside any rect", () => { const doc = "sequenceDiagram\n A->>B: hi"; const out = diffSequence(doc, doc); expect(out).not.toContain("rect rgb"); }); it("is deterministic", () => { const before = "sequenceDiagram\n A->>B: hi"; const current = "sequenceDiagram\n A->>B: hi\n B->>A: bye"; expect(diffSequence(before, current)).toBe(diffSequence(before, current)); }); }); ``` > Note: `CW_COLORS.added` is `#2ea043` → rgb `46, 160, 67`; `changed` `#d29922` → `210, 153, 34`; `removed` `#808080` → `128, 128, 128`. `diffSequence` must emit `rect rgb(r, g, b)` from the hex. Add a `hexToRgb` helper. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/mermaidSequenceDiff.test.ts` Expected: FAIL — `diffSequence` (stub) returns current unchanged; no `rect` emitted. - [ ] **Step 3: Write the implementation** ```ts // append to src/mermaidSequenceDiff.ts function hexToRgb(hex: string): string { const h = hex.replace("#", ""); const n = parseInt(h, 16); return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; } function rectWrap(stmt: string, hex: string): string[] { return [` rect rgb(${hexToRgb(hex)})`, ` ${stmt}`, ` end`]; } export function diffSequence(beforeSrc: string, currentSrc: string): string { const before = parseSequence(beforeSrc); const current = parseSequence(currentSrc); const out: string[] = [current.header]; // Participants: keep current declarations, then re-declare removed ones (ghosts). for (const p of current.participants) out.push(` participant ${p}`); const curSet = new Set(current.participants); for (const p of before.participants) { if (!curSet.has(p)) out.push(` participant ${p}`); } // Message stream diff via LCS over statements; pair adjacent removed+added as changed. const parts = diffArrays(before.statements, current.statements); for (let n = 0; n < parts.length; n++) { const ch = parts[n]; if (!ch.added && !ch.removed) { for (const s of ch.value) out.push(` ${s}`); continue; } if (ch.removed) { const next = parts[n + 1]; const addVals = next?.added ? next.value : []; const paired = Math.min(ch.value.length, addVals.length); for (let k = 0; k < paired; k++) out.push(...rectWrap(addVals[k], CW_COLORS.changed)); for (let k = paired; k < ch.value.length; k++) out.push(...rectWrap(ch.value[k], CW_COLORS.removed)); for (let k = paired; k < addVals.length; k++) out.push(...rectWrap(addVals[k], CW_COLORS.added)); if (next?.added) n++; continue; } // lone added run for (const s of ch.value) out.push(...rectWrap(s, CW_COLORS.added)); } return out.join("\n"); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/mermaidSequenceDiff.test.ts` Expected: PASS (all sequence tests). - [ ] **Step 5: Commit** ```bash git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts git commit -m "feat(f7.1): sequence participant/message diff + rect emission (#22)" ``` --- ## Task 6: Wire `diffMermaid` into `renderOp` + legend **Files:** - Modify: `src/trackChangesModel.ts` (the `changed` case in `renderOp`, ~line 256-264; add a `mermaidFenceBody` helper near the other helpers) - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing test** ```ts // append to test/trackChangesModel.test.ts import { renderTrackChanges as rtc2 } from "../src/trackChangesModel"; describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { it("augments a changed flowchart with styling directives (INV-29)", () => { const base = "```mermaid\nflowchart LR\n A-->B\n```\n"; const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n"; const html = rtc2(base, cur); expect(html).toContain("classDef cwAdded"); expect(html).toMatch(/class\s+C\s+cwAdded/); expect(html).toContain('class="cw-mermaid-legend"'); // no single whole-block "changed" badge when we augmented expect(html).not.toContain('changed'); }); it("augments a changed sequence diagram", () => { const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n"; const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n"; const html = rtc2(base, cur); expect(html).toContain("rect rgb(46, 160, 67)"); }); it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => { const base = "```mermaid\nclassDiagram\n class A\n```\n"; const cur = "```mermaid\nclassDiagram\n class B\n```\n"; const html = rtc2(base, cur); expect(html).toContain('changed'); expect(html).not.toContain("classDef cwAdded"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: FAIL — current changed-mermaid branch always emits the badge, never the directives. - [ ] **Step 3: Write the implementation** Add the import at the top of `src/trackChangesModel.ts` (with the other imports): ```ts import { diffMermaid } from "./mermaidDiff"; ``` Add a helper near `wordMergedMarkdown` (returns the inner mermaid source of a fence, or null if not a single mermaid fence): ```ts /** Inner source of a ```mermaid fence (drops the fence lines), or null. */ function mermaidFenceBody(raw: string): string | null { const lines = raw.split(/\r?\n/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); if (!open || open[3]?.toLowerCase() !== "mermaid") return null; const body = lines.slice(1); if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop(); return body.join("\n"); } ``` Replace the `changed` case's atomic branch (currently): ```ts if (op.atomic) { inner = safe(op.block.raw); // the NEW block, whole (INV-23) badge = 'changed'; } else { ``` with: ```ts if (op.atomic) { const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null; const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null; const md = curBody !== null && beforeBody !== null ? diffMermaid(beforeBody, curBody) : { kind: "fallback" as const }; if (md.kind === "augmented") { // Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge. inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND; } else { inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback) badge = 'changed'; } } else { ``` Add the legend constant near the top-level helpers: ```ts const MERMAID_LEGEND = '
' + 'added' + 'changed' + 'removed' + "
"; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run test/trackChangesModel.test.ts` Expected: PASS (existing + 3 new). Then run the whole unit suite: `npm test` — all green (no regressions to the existing `a mermaid-fence change → an ATOMIC changed op` test, which asserts the *op*, not the HTML). - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f7.1): render augmented mermaid diff in changed-block branch (#22)" ``` --- ## Task 7: Legend CSS **Files:** - Modify: `media/preview.css` - [ ] **Step 1: Add the legend styles** (append to `media/preview.css`) ```css /* F7.1 (#22) intra-diagram mermaid diff legend. */ .cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } .cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; } .cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; } .cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; } .cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; } ``` - [ ] **Step 2: Build to confirm esbuild emits the CSS** Run: `npm run build` Expected: build succeeds; `out/media/preview.css` exists and contains `cw-mermaid-legend`. Run: `grep -c cw-mermaid-legend out/media/preview.css` Expected: `1` or more. - [ ] **Step 3: Commit** ```bash git add media/preview.css git commit -m "feat(f7.1): mermaid diff legend styles (#22)" ``` --- ## Task 8: Host E2E — changed flowchart augments the emitted HTML **Files:** - Modify: `test/e2e/suite/trackChangesPreview.test.ts` - Check fixture: `test/e2e/fixtures/workspace/docs/preview.md` (read it first; the test edits a buffer in-place — follow the existing pattern in this file for opening the doc, showing the preview, capturing a baseline, applying a `WorkspaceEdit`, and reading `getLastModel`). - [ ] **Step 1: Read the existing E2E test + fixture to match the exact harness** Run: `sed -n '1,60p' test/e2e/suite/trackChangesPreview.test.ts` Note how `api`, `docUri()`, baseline capture (`cowriting.pinDiffBaseline` / proposal accept) and `applyEdit` are used; reuse them verbatim. - [ ] **Step 2: Write the failing test** (add inside the existing `suite`/`describe`) ```ts test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => { // Open a markdown doc containing a flowchart, show preview, pin baseline. const doc = await openPreviewDoc("```mermaid\nflowchart LR\n A-->B\n```\n"); await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); // Edit: add a node C. const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, new vscode.Position(2, 7), "\n B-->C"); assert.ok(await vscode.workspace.applyEdit(edit), "edit applied"); await waitForRefresh(); const model = api.trackChangesPreviewController.getLastModel(doc.uri.toString()) ?? []; const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid"); assert.ok(mermaidOp, "the flowchart block is a changed op"); const html = api.trackChangesPreviewController.renderHtmlFor(doc.uri.toString()); assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef"); assert.match(html, /class\s+C\s+cwAdded/, "node C colored added"); }); ``` > Note: `openPreviewDoc`, `waitForRefresh`, and `renderHtmlFor` may not exist yet. Use whatever the file already provides (it has a doc-open helper and uses `getLastModel`). If a direct HTML accessor is missing, add a tiny test-seam method `renderHtmlFor(uriString): string` to `TrackChangesPreviewController` that returns `renderTrackChanges(baselineText, current)` for the doc (mirror `getLastModel`), and export it in the §6.4 test-seam block. If the harness can only assert the model (not the HTML), assert on the model instead and move the HTML assertion to the unit test in Task 6 (already covered there) — do not block E2E on webview internals. - [ ] **Step 3: Run E2E to verify it fails** Run: `npm run test:e2e` Expected: FAIL on the new assertion (or on the missing seam — add it per the note). - [ ] **Step 4: Implement the seam if needed, then re-run** If you added `renderHtmlFor`, implement it in `src/trackChangesPreview.ts` test-seam section: ```ts /** F7.1 test seam: the track-changes HTML the panel would post for a doc. */ renderHtmlFor(uriString: string): string { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (!doc) return ""; const current = doc.getText(); const baseline = this.diffView.getBaseline(uriString); return renderTrackChanges(baseline?.text ?? current, current); } ``` Run: `npm run test:e2e` Expected: PASS (existing + new). - [ ] **Step 5: Commit** ```bash git add test/e2e/suite/trackChangesPreview.test.ts src/trackChangesPreview.ts git commit -m "test(f7.1): host E2E — changed flowchart augments emitted source (#22)" ``` --- ## Task 9: Manual-smoke doc + full verification + PR **Files:** - Create: `docs/MANUAL-SMOKE-F7.1.md` - [ ] **Step 1: Write the manual-smoke doc** (mirror the style of `docs/MANUAL-SMOKE-F7.md`) ```markdown # Manual smoke — F7.1 intra-diagram mermaid diffing (#22) Webview SVG rendering isn't covered by automated E2E (sealed sandbox, §6.8), so verify the rendered colors by hand once. 1. Open a markdown doc with a flowchart: ```mermaid flowchart LR A[Start] --> B[Parse] B --> C[Emit] ``` 2. Run **Cowriting: Show Track-Changes Preview** (`ctrl+alt+r`). Pin the baseline (`Cowriting: Pin Diff Baseline`). 3. Edit the diagram: change `B[Parse]` → `B[Parse+Lint]`, add `B --> D[Validate]`, delete the `B --> C` edge and the `C` node. 4. **Expect** in the preview: `B` outlined amber (changed), `D` green (added), `C` shown as a faded dashed ghost (removed), and a legend `added · changed · removed` under the diagram. 5. Repeat with a `sequenceDiagram`: add a message (green rect), remove one (grey ghost rect), confirm a removed participant still appears. 6. Change a diagram to a `classDiagram` and confirm it still shows the v1 whole-block "changed" badge (graceful fallback, INV-30). ``` - [ ] **Step 2: Full verification (run all gates)** ```bash npm run typecheck && npm test && npm run test:e2e ``` Expected: typecheck clean; full vitest suite green (≈164+ tests); E2E green (≈38 tests). Record the counts. - [ ] **Step 3: Commit the smoke doc** ```bash git add docs/MANUAL-SMOKE-F7.1.md git commit -m "docs(f7.1): manual webview-render smoke (#22)" ``` - [ ] **Step 4: Push the branch and open the PR** ```bash git push -u origin ``` Open a PR titled `F7.1: intra-diagram mermaid diffing (#22)` whose body cites spec §11, lists INV-29..31, the new modules, the unit/E2E counts, and links `Fixes #22`. (The session uses SSH remotes; PR via Gitea per `wgl-gitea-admin` / the session-finalize flow.) --- ## Self-Review **Spec coverage (§11):** - §11.1 supported types (flowchart, sequence) + total fallback → Tasks 1, 6 (dispatch + fallback), 3, 5. - §11.2 parsed-graph diff + ghost-in-place → Tasks 2-5. - §11.3 styling asymmetry (flowchart classDef/linkStyle vs sequence rect) → Tasks 3, 5. - §11.4 module tree + renderOp seam + webview-unchanged → Tasks 1, 6 (+ legend Task 7). - §11.5 INV-29 (augment), INV-30 (fallback total), INV-31 (ghost completeness) → asserted in Tasks 3 (ghost), 5 (ghost), 6 (augment + fallback). - §11.6 unit + host E2E → Tasks 2-6 (unit), Task 8 (E2E). - §11.7 out-of-scope types not implemented → confirmed (only flowchart + sequence). **Placeholder scan:** No TBD/TODO; every code step shows complete code; the one explicitly-flexible spot (E2E HTML seam, Task 8) gives a concrete fallback path. **Type consistency:** `MermaidDiffResult` (`augmented`|`fallback`) used identically in `diffMermaid` (Task 1) and `renderOp` (Task 6). `FlowGraph`/`FlowNode`/`FlowEdge` defined once (Task 2) and consumed in Task 3. `SeqDiagram` defined Task 4, consumed Task 5. `CW_COLORS` defined once (Task 1), imported by both differs and (as hex) the legend CSS uses the same literals. `diffFlowchart`/`diffSequence`/`parseFlowchart`/`parseSequence`/`detectDiagramType`/`diffMermaid` names consistent across tasks.