From 3347a77fde91cae1fcadae020895f6e527df784b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:40:56 -0700 Subject: [PATCH 01/12] docs(f7): implementation plan for rendered track-changes preview (#21) Co-Authored-By: Claude Opus 4.8 --- ...06-11-f7-rendered-track-changes-preview.md | 1458 +++++++++++++++++ 1 file changed, 1458 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-f7-rendered-track-changes-preview.md diff --git a/docs/superpowers/plans/2026-06-11-f7-rendered-track-changes-preview.md b/docs/superpowers/plans/2026-06-11-f7-rendered-track-changes-preview.md new file mode 100644 index 0000000..f031d79 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f7-rendered-track-changes-preview.md @@ -0,0 +1,1458 @@ +# F7 Rendered Track-Changes Preview 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:** Add a read-only webview that renders a markdown document beside its source editor and marks what changed since the F6 baseline (prose ``/``, atomic code/mermaid "changed" badge), updating live as the human and Claude edit. + +**Architecture:** A pure, vscode-free render engine (`trackChangesModel.ts`) turns `(baselineText, currentText)` into annotated HTML via a block-level diff (LCS over normalized blocks) + word-level prose refinement, with code/mermaid fences kept atomic. A vscode-layer controller (`trackChangesPreview.ts`) owns one sealed webview panel per markdown document, reads the F6 baseline from the reused `DiffViewController`, and posts HTML on open / debounced edit / baseline-epoch change. The webview asset (`media/preview.ts` → `out/media/preview.js`, a separate esbuild bundle) swaps in the HTML and runs `mermaid.run()` over `.mermaid` blocks. The document, sidecar, and baseline are never mutated (INV-20). + +**Tech Stack:** TypeScript, VS Code extension API (webview), `markdown-it` + `diff` (host bundle), `mermaid` (webview bundle only), esbuild (two entries), vitest (unit), `@vscode/test-electron` + mocha (host E2E). No LLM anywhere; no network/CDN (INV-21). + +**Spec:** `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md` (F7, #21). Invariants INV-20..23. + +--- + +## File Structure + +- **Create** `src/trackChangesModel.ts` — pure render engine (INV-22/23). Exports `splitBlocks`, `diffBlocks`, `renderTrackChanges`, types `Block`/`BlockOp`. No vscode, no DOM. +- **Create** `test/trackChangesModel.test.ts` — vitest unit suite for the engine. +- **Create** `media/preview.ts` — webview client: receives `{type:"render", html, epoch, summary}`, swaps `innerHTML`, runs `mermaid.run()`, per-block error chips. Bundles `mermaid`. +- **Create** `media/preview.css` — theme-aware marks (`ins`, `del`, `.cw-added/removed/changed`, `.cw-badge`, `.cw-error`). +- **Create** `src/trackChangesPreview.ts` — `TrackChangesPreviewController` (vscode layer): one panel per markdown doc, sealed CSP shell, debounced refresh, test seam. +- **Create** `test/e2e/suite/trackChangesPreview.test.ts` — host E2E (auto-globbed by `runTest.ts`). +- **Create** `test/e2e/fixtures/workspace/docs/preview.md` — markdown fixture (prose + mermaid + code + a `TARGET` sentence). +- **Create** `test/e2e/fixtures/workspace/docs/notes.txt` — non-markdown fixture (markdown-guard test). +- **Create** `docs/MANUAL-SMOKE-F7.md` — manual smoke script (§6.8). +- **Modify** `src/diffViewController.ts` — additive `onDidChangeBaseline` event fired on every `capture()`. +- **Modify** `src/extension.ts` — construct `TrackChangesPreviewController` (workspace-independent, like F6), add to `CowritingApi`. +- **Modify** `esbuild.mjs` — second entry building `media/preview.ts` → `out/media/preview.js` (IIFE, browser, bundles mermaid). +- **Modify** `package.json` — `markdown-it` + `diff` + `mermaid` deps, `@types/markdown-it` + `@types/diff` devDeps, command + keybinding contributions. +- **Modify** `README.md` — F7 section. + +--- + +## Task 1: Add dependencies and types + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add runtime + dev dependencies** + +Edit `package.json`. In `"dependencies"` add `markdown-it`, `diff`, `mermaid`; in `"devDependencies"` add `@types/markdown-it`, `@types/diff`. Result: + +```json + "dependencies": { + "@cline/sdk": "0.0.46", + "diff": "^7.0.0", + "markdown-it": "^14.1.0", + "mermaid": "^11.0.0" + }, + "devDependencies": { + "@types/diff": "^7.0.0", + "@types/markdown-it": "^14.1.0", + "@types/mocha": "^10.0.7", + "@types/node": "^22.0.0", + "@types/vscode": "^1.90.0", + "@vscode/test-electron": "^2.4.0", + "ajv": "^8.20.0", + "esbuild": "^0.23.0", + "glob": "^11.0.0", + "mocha": "^10.7.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +``` + +- [ ] **Step 2: Install** + +Run: `npm install` +Expected: lockfile updates, `node_modules/markdown-it`, `node_modules/diff`, `node_modules/mermaid` present, exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "feat(f7): add markdown-it + diff + mermaid deps (#21)" +``` + +--- + +## Task 2: Block splitting (`splitBlocks`) + +**Files:** +- Create: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `test/trackChangesModel.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { splitBlocks } from "../src/trackChangesModel"; + +describe("splitBlocks", () => { + it("splits prose paragraphs on blank lines, dropping empties", () => { + const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n"); + expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]); + expect(blocks[0].raw).toBe("# Heading"); + expect(blocks[2].raw).toBe("Second para."); + }); + + it("keeps a fenced code block whole and tags it `code`", () => { + const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n"; + const blocks = splitBlocks(text); + expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]); + expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```"); + }); + + it("tags a mermaid fence `mermaid`", () => { + const blocks = splitBlocks("```mermaid\nflowchart LR\n a-->b\n```\n"); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("mermaid"); + }); + + it("normalizes the key (whitespace-collapsed) for matching", () => { + const blocks = splitBlocks("Hello world\n"); + expect(blocks[0].key).toBe("hello world"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: FAIL — `splitBlocks` not exported / module missing. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/trackChangesModel.ts`: + +```ts +/** + * trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23). + * + * `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body + * for the preview webview: a block-level diff (LCS over normalized blocks) with + * word-level ``/`` refinement inside changed PROSE blocks, and CODE / + * MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially + * rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no + * DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview. + */ +import MarkdownIt from "markdown-it"; +import { diffArrays, diffWords } from "diff"; + +export type BlockType = "prose" | "code" | "mermaid"; + +export interface Block { + /** Verbatim source of the block (no surrounding blank lines). */ + raw: string; + /** Normalized match key: lowercased, whitespace-collapsed. */ + key: string; + type: BlockType; +} + +function normalize(raw: string): string { + return raw.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function makeBlock(raw: string, type: BlockType): Block { + return { raw, key: normalize(raw), type }; +} + +/** Split markdown into top-level blocks; fenced code/mermaid stay whole. */ +export function splitBlocks(text: string): Block[] { + const lines = text.split(/\r?\n/); + const blocks: Block[] = []; + let buf: string[] = []; + const flushProse = () => { + const raw = buf.join("\n"); + if (raw.trim()) blocks.push(makeBlock(raw, "prose")); + buf = []; + }; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushProse(); + const marker = fence[2][0]; // ` or ~ + const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); + const fenceLines = [line]; + i++; + while (i < lines.length) { + fenceLines.push(lines[i]); + const closed = lines[i].trim().startsWith(marker.repeat(3)); + i++; + if (closed) break; + } + blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code")); + continue; + } + if (line.trim() === "") { + flushProse(); + i++; + continue; + } + buf.push(line); + i++; + } + flushProse(); + return blocks; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f7): block splitter for the render engine (#21)" +``` + +--- + +## Task 3: Block diff (`diffBlocks`) + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `test/trackChangesModel.test.ts`: + +```ts +import { diffBlocks } from "../src/trackChangesModel"; + +describe("diffBlocks", () => { + const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind); + + it("unchanged doc → all unchanged", () => { + const doc = "Alpha.\n\nBravo.\n"; + expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]); + }); + + it("a pure addition → an added op", () => { + const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n"); + expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]); + }); + + it("a pure deletion → a removed op", () => { + const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n"); + expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]); + }); + + it("a prose modification → a non-atomic changed op", () => { + const ops = diffBlocks("The quick fox.\n", "The slow fox.\n"); + expect(ops).toHaveLength(1); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false); + }); + + it("a code-fence change → an ATOMIC changed op (INV-23)", () => { + const base = "```ts\nconst a = 1;\n```\n"; + const cur = "```ts\nconst a = 2;\n```\n"; + const ops = diffBlocks(base, cur); + expect(ops).toHaveLength(1); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); + }); + + it("a mermaid-fence change → an ATOMIC changed op", () => { + const base = "```mermaid\nflowchart LR\n a-->b\n```\n"; + const cur = "```mermaid\nflowchart LR\n a-->c\n```\n"; + const ops = diffBlocks(base, cur); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); + }); + + it("a reorder → removed + added (not changed)", () => { + const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n"); + // Two. matches (unchanged), One. moves: one removed + one added. + const k = ops.map((o) => o.kind).sort(); + expect(k).toContain("unchanged"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: FAIL — `diffBlocks` not exported. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/trackChangesModel.ts`: + +```ts +export type BlockOp = + | { kind: "unchanged"; block: Block } + | { kind: "added"; block: Block } + | { kind: "removed"; block: Block } + | { kind: "changed"; block: Block; before: Block; atomic: boolean }; + +/** + * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`); + * adjacent removed-then-added runs are paired element-wise into `changed` ops, the + * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when + * either side is a code/mermaid fence — rendered whole, never word-refined. + */ +export function diffBlocks(baselineText: string, currentText: string): BlockOp[] { + const before = splitBlocks(baselineText); + const after = splitBlocks(currentText); + const changes = diffArrays( + before.map((b) => b.key), + after.map((b) => b.key), + ); + + const ops: BlockOp[] = []; + let bi = 0; // index into `before` + let ci = 0; // index into `after` + for (let n = 0; n < changes.length; n++) { + const ch = changes[n]; + const count = ch.count ?? ch.value.length; + if (!ch.added && !ch.removed) { + for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] }); + bi += count; + continue; + } + if (ch.removed) { + const next = changes[n + 1]; + const addCount = next?.added ? (next.count ?? next.value.length) : 0; + const paired = Math.min(count, addCount); + for (let k = 0; k < paired; k++) { + const beforeBlk = before[bi++]; + const afterBlk = after[ci++]; + const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose"; + ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic }); + } + for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] }); + for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] }); + if (next?.added) n++; // consumed the paired added run + continue; + } + // a lone added run (no preceding removed) + for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] }); + } + return ops; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (all diffBlocks tests + Task 2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f7): block-level diff with atomic fences (#21)" +``` + +--- + +## Task 4: Render to annotated HTML (`renderTrackChanges`) + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `test/trackChangesModel.test.ts`: + +```ts +import { renderTrackChanges } from "../src/trackChangesModel"; + +describe("renderTrackChanges", () => { + it("wraps each block in a cw-blk div with its kind class", () => { + const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n"); + expect(html).toContain('class="cw-blk cw-unchanged"'); + expect(html).toContain('class="cw-blk cw-added"'); + }); + + it("emits inline / for a prose modification", () => { + const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n"); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain("cw-changed"); + }); + + it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => { + const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n"); + expect(html).toContain("cw-changed"); + expect(html).not.toContain(""); + expect(html).not.toContain(""); + expect(html).toContain("cw-badge"); + }); + + it("emits
 for a mermaid fence + a changed badge", () => {
+    const html = renderTrackChanges(
+      "```mermaid\nflowchart LR\n a-->b\n```\n",
+      "```mermaid\nflowchart LR\n a-->c\n```\n",
+    );
+    expect(html).toContain('
');
+    expect(html).toContain("a-->c"); // current source, escaped
+    expect(html).toContain("cw-badge");
+  });
+
+  it("unchanged doc renders with no marks", () => {
+    const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
+    expect(html).not.toContain("cw-added");
+    expect(html).not.toContain("cw-removed");
+    expect(html).not.toContain("cw-changed");
+  });
+
+  it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
+    const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
+    const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
+    expect(a).toBe(b);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/trackChangesModel.test.ts`
+Expected: FAIL — `renderTrackChanges` not exported.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `src/trackChangesModel.ts`:
+
+```ts
+const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
+// mermaid fences → 
SRC
for client-side rendering; all +// other fences fall through to markdown-it's default (escaped
).
+const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
+md.renderer.rules.fence = (tokens, idx, options, env, self) => {
+  const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
+  if (info === "mermaid") {
+    return `
${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}
\n`; + } + return defaultFence(tokens, idx, options, env, self); +}; + +/** Build a markdown string with inline / from a word-level prose diff. */ +function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string { + return diffWords(beforeRaw, afterRaw) + .map((part) => { + if (part.added) return `${part.value}`; + if (part.removed) return `${part.value}`; + return part.value; + }) + .join(""); +} + +function safeRender(src: string): string { + try { + return md.render(src); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; + } +} + +function renderOp(op: BlockOp): string { + let cls: string; + let inner: string; + let badge = ""; + switch (op.kind) { + case "unchanged": + cls = "cw-unchanged"; + inner = safeRender(op.block.raw); + break; + case "added": + cls = "cw-added"; + inner = safeRender(op.block.raw); + if (op.block.type !== "prose") badge = 'added'; + break; + case "removed": + cls = "cw-removed"; + inner = safeRender(op.block.raw); + break; + case "changed": + cls = "cw-changed"; + if (op.atomic) { + inner = safeRender(op.block.raw); // the NEW block, whole (INV-23) + badge = 'changed'; + } else { + inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw)); + } + break; + } + return `
${badge}${inner}
`; +} + +export interface RenderOptions { + /** Counts of changed/added/removed blocks, for the header summary. */ + // (reserved; callers derive counts from diffBlocks) +} + +/** Pure entry point: annotated HTML body for the preview (INV-22). */ +export function renderTrackChanges(baselineText: string, currentText: string): string { + return diffBlocks(baselineText, currentText).map(renderOp).join("\n"); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (all render tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f7): render annotated track-changes HTML (#21)" +``` + +--- + +## Task 5: Error-chip fallback (PUC-6) made testable + +**Files:** +- Modify: `src/trackChangesModel.ts` +- Test: `test/trackChangesModel.test.ts` + +**Why:** `safeRender` already catches render errors, but `markdown-it` almost never throws on real input, so the catch is untested. Add a tiny, honest seam: an exported `renderBlockHtml(op, render)` that takes the per-block markdown renderer as a parameter (defaulting to the module's `safeRender`). The unit test injects a renderer that throws for one block to prove the chip appears AND the rest still renders. + +- [ ] **Step 1: Write the failing test** + +Append to `test/trackChangesModel.test.ts`: + +```ts +import { renderTrackChanges as renderWith } from "../src/trackChangesModel"; + +describe("error chip (PUC-6)", () => { + it("a block whose render throws becomes an error chip; the rest still renders", () => { + const throwing = (src: string) => { + if (src.includes("BOOM")) throw new Error("kaboom"); + return `

${src}

`; + }; + const html = renderWith("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", { render: throwing }); + expect(html).toContain("cw-error"); + expect(html).toContain("kaboom"); + expect(html).toContain("

Good one.

"); // the healthy block still rendered + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: FAIL — `renderTrackChanges` takes only 2 args / `opts.render` ignored. + +- [ ] **Step 3: Write minimal implementation** + +In `src/trackChangesModel.ts`, replace the `RenderOptions` block and the `safeRender`/`renderOp`/`renderTrackChanges` definitions so the per-block renderer is injectable: + +```ts +export interface RenderOptions { + /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ + render?: (src: string) => string; +} + +function defaultRender(src: string): string { + return md.render(src); +} + +function chip(message: string): string { + return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; +} + +function renderOp(op: BlockOp, render: (src: string) => string): string { + const safe = (src: string): string => { + try { + return render(src); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } + }; + let cls: string; + let inner: string; + let badge = ""; + switch (op.kind) { + case "unchanged": + cls = "cw-unchanged"; + inner = safe(op.block.raw); + break; + case "added": + cls = "cw-added"; + inner = safe(op.block.raw); + if (op.block.type !== "prose") badge = 'added'; + break; + case "removed": + cls = "cw-removed"; + inner = safe(op.block.raw); + break; + case "changed": + cls = "cw-changed"; + if (op.atomic) { + inner = safe(op.block.raw); + badge = 'changed'; + } else { + inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw)); + } + break; + } + return `
${badge}${inner}
`; +} + +export function renderTrackChanges( + baselineText: string, + currentText: string, + opts: RenderOptions = {}, +): string { + const render = opts.render ?? defaultRender; + return diffBlocks(baselineText, currentText).map((op) => renderOp(op, render)).join("\n"); +} +``` + +Delete the now-unused standalone `safeRender` function. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (error-chip test + all prior tests). + +- [ ] **Step 5: Typecheck + full unit suite** + +Run: `npm run typecheck && npm test` +Expected: PASS, no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f7): per-block error-chip fallback (PUC-6, #21)" +``` + +--- + +## Task 6: Webview asset (CSS + client JS) and second esbuild entry + +**Files:** +- Create: `media/preview.css` +- Create: `media/preview.ts` +- Modify: `esbuild.mjs` + +- [ ] **Step 1: Create the stylesheet** + +Create `media/preview.css`: + +```css +/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */ +body { + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + padding: 0 1.2rem 2rem; + line-height: 1.5; +} +#cw-header { + position: sticky; + top: 0; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border); + padding: 0.5rem 0; + font-size: 0.85em; + opacity: 0.85; + display: flex; + gap: 1rem; +} +#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); } +#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); } +.cw-blk { position: relative; } +ins, .cw-added { + background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15)); + text-decoration: none; +} +del, .cw-removed { + background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15)); + text-decoration: line-through; + opacity: 0.7; +} +.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; } +.cw-badge { + position: absolute; + top: 0; + right: 0; + font-size: 0.7em; + padding: 0 0.4em; + border-radius: 3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} +.cw-error { + border: 1px solid var(--vscode-inputValidation-errorBorder); + background: var(--vscode-inputValidation-errorBackground); + color: var(--vscode-errorForeground); + padding: 0.3rem 0.6rem; + border-radius: 3px; + font-size: 0.85em; +} +pre.mermaid { text-align: center; background: transparent; } +pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } +``` + +- [ ] **Step 2: Create the client script** + +Create `media/preview.ts`: + +```ts +/** + * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML + * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks + * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as + * a standalone IIFE → out/media/preview.js, so mermaid never enters the host + * bundle. No network, no LLM. + */ +import mermaid from "mermaid"; + +interface RenderMessage { + type: "render"; + html: string; + epoch: string; + summary: { added: number; removed: number; changed: number }; +} + +const body = document.getElementById("cw-body")!; +const header = document.getElementById("cw-epoch")!; +const summary = document.getElementById("cw-summary")!; + +function themeFor(): "dark" | "default" { + return document.body.classList.contains("vscode-dark") || + document.body.classList.contains("vscode-high-contrast") + ? "dark" + : "default"; +} + +async function renderMermaid(): Promise { + const nodes = Array.from(body.querySelectorAll("pre.mermaid")); + if (nodes.length === 0) return; + mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" }); + try { + await mermaid.run({ nodes }); + } catch { + // mermaid.run already marks failed nodes; ensure a visible chip per failure. + for (const n of nodes) { + if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); + } + } +} + +window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + if (msg?.type !== "render") return; + body.innerHTML = msg.html; + header.textContent = `Track changes since ${msg.epoch}`; + summary.innerHTML = + `+${msg.summary.added + msg.summary.changed} ` + + `−${msg.summary.removed + msg.summary.changed}`; + void renderMermaid(); +}); +``` + +- [ ] **Step 3: Add the second esbuild entry** + +In `esbuild.mjs`, add a third options object after `liveTurnOptions`, and build it. Insert: + +```js +/** @type {import('esbuild').BuildOptions} */ +const previewOptions = { + entryPoints: ["media/preview.ts"], + outfile: "out/media/preview.js", + bundle: true, + // The webview is a browser context; mermaid is bundled IN (and ONLY in) this + // asset so it never bloats the extension-host bundle (the @cline/sdk size + // discipline). No externals — everything is shipped to the sealed webview. + platform: "browser", + format: "iife", + target: "es2020", + sourcemap: true, + logLevel: "info", +}; +``` + +Then update the watch and build blocks to include it: + +```js +if (watch) { + const ctx = await context(options); + const ctxLive = await context(liveTurnOptions); + const ctxPreview = await context(previewOptions); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); + console.log("esbuild: watching…"); +} else { + await build(options); + await build(liveTurnOptions); + await build(previewOptions); + console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); +} +``` + +- [ ] **Step 4: Build to verify the webview bundle compiles** + +Run: `npm run build` +Expected: build complete; `out/media/preview.js` exists (mermaid bundled, a few MB). + +- [ ] **Step 5: Commit** + +```bash +git add media/preview.ts media/preview.css esbuild.mjs +git commit -m "feat(f7): sealed webview asset + esbuild preview bundle (#21)" +``` + +--- + +## Task 7: Additive `onDidChangeBaseline` event on the F6 controller + +**Files:** +- Modify: `src/diffViewController.ts` + +- [ ] **Step 1: Add the emitter and public event** + +In `src/diffViewController.ts`, after the existing `onDidChangeEmitter` field (line ~28), add: + +```ts + /** F7 (additive): fires on every baseline capture (open / advance / pin) so + * the track-changes preview refreshes without polling. Mirrors the internal + * content-provider change signal but carries the real document URI. */ + private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); + readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; +``` + +- [ ] **Step 2: Fire it on capture, and dispose it** + +In the constructor's `this.disposables.push(...)` list, add `this.onDidChangeBaselineEmitter,` alongside `this.onDidChangeEmitter,`. + +In `capture(...)`, after the existing `this.onDidChangeEmitter.fire(this.baselineUri(document));` line, add: + +```ts + this.onDidChangeBaselineEmitter.fire({ uri: key }); +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/diffViewController.ts +git commit -m "feat(f7): additive onDidChangeBaseline event on DiffViewController (#21)" +``` + +--- + +## Task 8: `TrackChangesPreviewController` (vscode layer) + +**Files:** +- Create: `src/trackChangesPreview.ts` + +- [ ] **Step 1: Write the controller** + +Create `src/trackChangesPreview.ts`: + +```ts +/** + * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one + * sealed webview panel per markdown document, beside the source editor. On open / + * debounced edit / F6 baseline-epoch change it reads the baseline (from the + * reused DiffViewController) + the live buffer, runs the pure render engine, and + * posts the HTML. Pure read-only: never mutates the document, sidecar, or + * baseline (INV-20). The webview is sealed: local assets only, strict CSP, + * per-load nonce, no network (INV-21). + */ +import * as path from "node:path"; +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; +import type { DiffViewController } from "./diffViewController"; +import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel"; + +const VIEW_TYPE = "cowriting.trackChangesPreview"; +const DEBOUNCE_MS = 150; + +export class TrackChangesPreviewController implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private readonly panels = new Map(); + private readonly lastModel = new Map(); + private readonly debounces = new Map(); + + constructor( + private readonly diffView: DiffViewController, + private readonly extensionUri: vscode.Uri, + ) { + this.disposables.push( + vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => + this.show(vscode.window.activeTextEditor?.document), + ), + vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), + this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), + ); + } + + private isMarkdown(document: vscode.TextDocument): boolean { + return document.languageId === "markdown"; + } + + /** Open or reveal the preview for a markdown document (PUC-1). */ + show(document: vscode.TextDocument | undefined): void { + if (!document || !this.isMarkdown(document)) { + void vscode.window.showWarningMessage( + "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", + ); + return; + } + const key = document.uri.toString(); + const existing = this.panels.get(key); + if (existing) { + existing.reveal(vscode.ViewColumn.Beside); + this.refresh(document); + return; + } + const name = path.basename(document.uri.path) || "untitled"; + const panel = vscode.window.createWebviewPanel( + VIEW_TYPE, + `Track changes: ${name}`, + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: false, + localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], + }, + ); + panel.webview.html = this.shellHtml(panel.webview); + panel.onDidDispose(() => { + this.panels.delete(key); + this.lastModel.delete(key); + }, null, this.disposables); + this.panels.set(key, panel); + this.refresh(document); + } + + private onEdit(document: vscode.TextDocument): void { + const key = document.uri.toString(); + if (!this.panels.has(key)) return; + const pending = this.debounces.get(key); + if (pending) clearTimeout(pending); + this.debounces.set( + key, + setTimeout(() => { + this.debounces.delete(key); + this.refresh(document); + }, DEBOUNCE_MS), + ); + } + + private refreshByUri(uri: string): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) this.refresh(doc); + } + + /** Recompute the model + post HTML to the panel (no-op if no panel). */ + refresh(document: vscode.TextDocument): void { + const key = document.uri.toString(); + const panel = this.panels.get(key); + if (!panel) return; + const baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks + const current = document.getText(); + const ops = diffBlocks(baselineText, current); + this.lastModel.set(key, ops); + const summary = { + added: ops.filter((o) => o.kind === "added").length, + removed: ops.filter((o) => o.kind === "removed").length, + changed: ops.filter((o) => o.kind === "changed").length, + }; + const epoch = this.epochLabel(baseline); + void panel.webview.postMessage({ + type: "render", + html: renderTrackChanges(baselineText, current), + epoch, + summary, + }); + } + + private epochLabel( + baseline: { reason: string; capturedAt: string } | undefined, + ): string { + if (!baseline) return "opened (no baseline yet)"; + const time = new Date(baseline.capturedAt).toLocaleTimeString(); + switch (baseline.reason) { + case "machine-landing": + return `Claude landed ${time}`; + case "pinned": + return `pinned ${time}`; + default: + return `opened ${time}`; + } + } + + private shellHtml(webview: vscode.Webview): string { + const nonce = randomBytes(16).toString("base64"); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), + ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), + ); + // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for + // mermaid's dynamically injected