Files
vscode-cowriting-plugin-con…/plans/2026-06-11-f7-rendered-track-changes-preview.md
T

1459 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<ins>`/`<del>`, 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 `<ins>`/`<del>` 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 <ins>/<del> for a prose modification", () => {
const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
expect(html).toContain("<ins>");
expect(html).toContain("<del>");
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("<ins>");
expect(html).not.toContain("<del>");
expect(html).toContain("cw-badge");
});
it("emits <pre class=\"mermaid\"> 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('<pre class="mermaid">');
expect(html).toContain("a--&gt;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 → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).
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 `<pre class="mermaid">${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}</pre>\n`;
}
return defaultFence(tokens, idx, options, env, self);
};
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
return diffWords(beforeRaw, afterRaw)
.map((part) => {
if (part.added) return `<ins>${part.value}</ins>`;
if (part.removed) return `<del>${part.value}</del>`;
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 `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
}
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 = '<span class="cw-badge">added</span>';
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 = '<span class="cw-badge">changed</span>';
} else {
inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw));
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}
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 `<p>${src}</p>`;
};
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("<p>Good one.</p>"); // 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 `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
}
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 = '<span class="cw-badge">added</span>';
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 = '<span class="cw-badge">changed</span>';
} else {
inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
}
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<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("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<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
header.textContent = `Track changes since ${msg.epoch}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary.added + msg.summary.changed}</span> ` +
`<span class="cw-del">${msg.summary.removed + msg.summary.changed}</span>`;
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<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
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 <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<span id="cw-epoch">Track changes</span>
<span id="cw-summary"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}
```
- [ ] **Step 2: Typecheck**
Run: `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/trackChangesPreview.ts
git commit -m "feat(f7): TrackChangesPreviewController (sealed webview, test seam) (#21)"
```
---
## Task 9: Wire into the extension + package.json contributions
**Files:**
- Modify: `src/extension.ts`
- Modify: `package.json`
- [ ] **Step 1: Add the command + keybinding to package.json**
In `package.json``contributes.commands`, add (after `cowriting.pinDiffBaseline`):
```json
{
"command": "cowriting.showTrackChangesPreview",
"title": "Cowriting: Open Track-Changes Preview",
"category": "Cowriting"
}
```
In `contributes.keybindings`, add (after the `toggleDiffView` binding):
```json
{
"command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r",
"when": "editorLangId == markdown"
}
```
- [ ] **Step 2: Construct and export the controller**
In `src/extension.ts`:
(a) Add the import after the `DiffViewController` import:
```ts
import { TrackChangesPreviewController } from "./trackChangesPreview";
```
(b) Extend the `CowritingApi` interface:
```ts
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
}
```
(c) Right after `context.subscriptions.push(diffViewController);` (the F6 construction, ~line 57), construct F7 (workspace-independent, like F6 — its command must exist with or without a folder):
```ts
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
// is constructed regardless of an open folder and its command is always live.
// It reuses the F6 baseline (INV-20) and adds no persistence.
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
);
context.subscriptions.push(trackChangesPreviewController);
```
(d) In the with-root `return { ... }` statement at the end of `activate`, add the controller:
```ts
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
};
```
(The no-root early `return undefined;` stays as-is — the command is already registered by the controller constructed above, so it exists without a folder.)
- [ ] **Step 3: Typecheck + build**
Run: `npm run typecheck && npm run build`
Expected: PASS; `out/extension.cjs` and `out/media/preview.js` rebuilt.
- [ ] **Step 4: Commit**
```bash
git add src/extension.ts package.json
git commit -m "feat(f7): register showTrackChangesPreview command + keybinding, wire controller (#21)"
```
---
## Task 10: Host E2E fixtures + suite
**Files:**
- Create: `test/e2e/fixtures/workspace/docs/preview.md`
- Create: `test/e2e/fixtures/workspace/docs/notes.txt`
- Create: `test/e2e/suite/trackChangesPreview.test.ts`
- [ ] **Step 1: Create the markdown fixture**
Create `test/e2e/fixtures/workspace/docs/preview.md`:
```markdown
# Preview fixture
A target sentence Claude will rewrite via the seam.
Some stable prose that does not change during the test run.
```mermaid
flowchart LR
a --> b
```
```ts
const stable = true;
```
```
(Note: the fixture file literally contains the nested ``` fences shown above.)
- [ ] **Step 2: Create the non-markdown fixture**
Create `test/e2e/fixtures/workspace/docs/notes.txt`:
```
Plain text, not markdown — F7 must refuse this (F6 covers it instead).
```
- [ ] **Step 3: Write the E2E suite**
Create `test/e2e/suite/trackChangesPreview.test.ts`:
```ts
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
// sanity: the active tab is a webview of our view type
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
// append a brand-new paragraph at the end
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target sentence");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + TARGET.length,
newText: REPLACEMENT,
model: "sonnet",
sessionId: "e2e-f7",
turnId: "turn-f7-1",
});
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
});
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, every block is unchanged (baseline == buffer)",
);
void doc;
});
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
const txt = await openDoc("docs/notes.txt");
const api = await getApi();
const key = txt.uri.toString();
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
});
});
```
- [ ] **Step 4: Build E2E + run**
Run: `npm run pretest:e2e && npm run test:e2e`
Expected: the new suite passes alongside the existing F2F6 suites; both workspace and no-workspace passes complete (the no-workspace pass exercises the F2 stubs only — F7's command exists but isn't invoked there). Exit 0.
If the E2E electron download/run is not available in this environment, build at minimum (`npm run pretest:e2e`) to confirm the suite compiles, and note the run in the transcript for the manual smoke / CI.
- [ ] **Step 5: Commit**
```bash
git add test/e2e/suite/trackChangesPreview.test.ts test/e2e/fixtures/workspace/docs/preview.md test/e2e/fixtures/workspace/docs/notes.txt
git commit -m "test(f7): host E2E for the track-changes preview (#21)"
```
---
## Task 11: Manual smoke doc + README section
**Files:**
- Create: `docs/MANUAL-SMOKE-F7.md`
- Modify: `README.md`
- [ ] **Step 1: Write the manual smoke script**
Create `docs/MANUAL-SMOKE-F7.md` following the §6.8 "Live smoke" script. Content:
```markdown
# Manual smoke — F7 rendered track-changes preview (#21)
The webview's *visual* rendering (mermaid, theming) is verified here, not in the
automated host E2E (the webview is a sealed sandbox — §6.8). Run once per change
that touches F7.
## Setup
1. `npm run build`
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel).
3. Open a folder and a markdown document containing prose, a `mermaid` fenced
diagram, and a `ts` code fence (e.g. copy `test/e2e/fixtures/workspace/docs/preview.md`).
## Steps
1. **Open the preview.** Run **"Cowriting: Open Track-Changes Preview"** (or
`Ctrl+Alt+R`). A preview opens beside the editor, rendering the document.
Header reads `Track changes since opened …`, summary `+0 0`. The mermaid
diagram renders as a diagram; the code fence renders as highlighted code.
2. **Edit prose.** In the source editor, change a word in a paragraph. The
preview updates (≈150 ms): the old word struck (`<del>`), the new word
highlighted (`<ins>`); summary increments.
3. **Edit the mermaid.** Change `a --> b` to `a --> c`. The preview re-renders the
**new** diagram with a **"changed"** badge at its top-right.
4. **Ask Claude + accept.** Select the target sentence → "Ask Claude to Edit
Selection" → accept the proposal. The accepted text **drops its marks** (the
baseline advanced; PUC-3).
5. **Pin.** Run "Cowriting: Pin Diff Baseline to Now". All marks clear (baseline
== now; PUC-4).
6. **Theme.** Toggle light/dark (`Ctrl+K Ctrl+T`). Marks and the diagram restyle
to the theme.
7. **Cleanliness.** `git status` shows nothing changed by the preview (INV-20).
## Pass criteria
All seven steps behave as described; no console errors in the webview devtools;
nothing written to the document, sidecar, or repo.
```
- [ ] **Step 2: Add a README F7 section**
In `README.md`, add a section describing F7 (mirroring how F6 is documented):
```markdown
## F7 — Rendered track-changes preview (#21)
`Ctrl+Alt+R` (or "Cowriting: Open Track-Changes Preview") opens a read-only
webview beside a **Markdown** editor that renders the document and marks what
changed since the F6 baseline: prose additions highlighted / deletions struck,
and code/mermaid fences shown whole with a "changed" badge (intra-diagram mermaid
diffing is deferred — #22). It updates live as you and Claude edit, and re-bases
when Claude lands an edit or you pin (reusing the F6 baseline — it adds no
persistence). Mermaid renders fully. F7 is **markdown-only**; for any other file,
use F6's diff toggle (`Ctrl+Alt+D`). Manual smoke: `docs/MANUAL-SMOKE-F7.md`.
```
- [ ] **Step 3: Commit**
```bash
git add docs/MANUAL-SMOKE-F7.md README.md
git commit -m "docs(f7): manual smoke script + README section (#21)"
```
---
## Task 12: Full verification
**Files:** (none — verification only)
- [ ] **Step 1: Typecheck**
Run: `npm run typecheck`
Expected: PASS, no errors.
- [ ] **Step 2: Unit suite**
Run: `npm test`
Expected: all vitest suites pass, including `trackChangesModel.test.ts`.
- [ ] **Step 3: Build**
Run: `npm run build`
Expected: `out/extension.cjs`, `out/liveTurn.mjs`, `out/media/preview.js` all produced.
- [ ] **Step 4: Host E2E**
Run: `npm run test:e2e`
Expected: all suites (F2F7) green. (If electron is unavailable locally, record that the suite compiles and defer the run to CI / manual smoke — note it in the transcript.)
- [ ] **Step 5: Confirm the repo is clean of preview side effects**
Run: `git status`
Expected: only intended source/doc changes; nothing written under `.threads/` or the workspace by the preview (INV-20).
---
## Self-Review (completed during planning)
**Spec coverage:**
- Rendered preview beside editable source (PUC-1) → Task 8 (`show`, `ViewColumn.Beside`), Task 9 (command).
- Marks since F6 baseline (PUC-2/3), epoch follows machine / pin → Task 7 (`onDidChangeBaseline`), Task 8 (`refresh`), Task 10 (E2E PUC-3/4).
- Full mermaid support (PUC-5) → Task 4 (`<pre class="mermaid">`), Task 6 (`mermaid.run()`).
- Mermaid change = whole-diagram badge (INV-23) → Task 3 (atomic), Task 4 (badge).
- Prose inline ins/del (PUC-2) → Task 4 (`wordMergedMarkdown`).
- Live update on edit + epoch (PUC-2/3/4) → Task 8 (debounce + event sub).
- Pure read-only, no sidecar/contract impact (INV-20) → Task 8 (reads only), Task 12 step 5.
- Sealed webview, no network/LLM, local mermaid (INV-21) → Task 6 (browser bundle), Task 8 (CSP/nonce/localResourceRoots).
- Deterministic testable model (INV-22) → Tasks 25 (vscode-free, determinism test).
- Atomic non-prose blocks (INV-23) → Task 3 (atomic flag), Task 4 (no word diff for atomic).
- Graceful edges (PUC-6): non-markdown warn → Task 8/10; error chip → Task 5; no baseline → Task 8 (`baselineText ?? getText()`).
- Unit + host E2E, no LLM in CI (§6.8) → Tasks 25 (unit), Task 10 (E2E).
**Placeholder scan:** none — every code step shows complete code.
**Type consistency:** `Block`/`BlockOp` defined in Task 2/3 and consumed unchanged in Tasks 4/5/8; `renderTrackChanges(baselineText, currentText, opts?)`, `diffBlocks`, `getBaseline(uriString)` (existing F6), `onDidChangeBaseline({uri})`, `isOpen`/`getLastModel` consistent across tasks.
```