docs: author-colored track-changes design + implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 13:32:34 -07:00
parent 126d71fa06
commit 94b1a9b0c2
3 changed files with 980 additions and 0 deletions
@@ -0,0 +1,827 @@
# Author-colored track changes 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:** Render every change as a track-changes diff where **style = operation** (underline = inserted, strikethrough = removed) and **color = author** (human green/red, Claude blue/purple), in both the rendered preview pane and the main editor pane.
**Architecture:** Fuse the two color systems that exist today — the *semantic* diff (green=add/red=remove) and the *authorship* tint (cw-by-claude/cw-by-human) — into one author-colored diff. Insertions are colored by their attribution span (data already exists). Deletions are colored by an **adjacency heuristic**: a struck run inherits the author of the insertion it is paired with in the same changed hunk; a standalone removed block falls back to neutral. Phase 1 does this in the pure render engine (preview). Phase 2 reverses INV-32 to bring the same author-colored track changes inline into the editor.
**Tech Stack:** TypeScript, VS Code extension API, `markdown-it`, `diff` (jsdiff), vitest (unit), Playwright/electron host harness (E2E). Render engine (`src/trackChangesModel.ts`) is pure and vscode-free.
## Global Constraints
- **Render engine stays pure / vscode-free / deterministic** — `src/trackChangesModel.ts` must not import `vscode`; same inputs → identical HTML (INV-22).
- **Author color mapping (verbatim):** human added `#3fb950` / human removed `#f85149` / Claude added `#58a6ff` / Claude removed `#bc8cff`. Insert = 2px underline in the author color + faint author-hued background; remove = line-through in the author color + faint background.
- **Convention:** underline = inserted, strikethrough = removed, color = author.
- **"Color means changed since the pinned baseline."** Unchanged-since-baseline text renders plain (no author coloring). The pin→clean behavior (#48) is preserved: a pinned, zero-diff doc renders with no annotation.
- **Deletion authorship = adjacency heuristic** (locked decision): paired deletions inherit the paired insertion's author; standalone deletions render neutral.
- **Two roles only:** `human` and `claude` (`AuthorKind`). No arbitrary palettes, no user-configurable colors (YAGNI).
- **Test command:** `npm test` (vitest, unit). E2E: `npm run test:e2e`. Typecheck: `npm run typecheck`.
- Commit after each task. Branch off `main` (do not commit to `main` directly).
---
# Phase 1 — Preview pane (independently shippable)
Delivers the full author-colored track-changes language in the rendered preview, where the attribution data already exists.
## File Structure (Phase 1)
- `src/trackChangesModel.ts` — add `authorAt()` + `wordDiffByAuthor()` pure helpers; route the review path through them; color proposal blocks by author; stop coloring unchanged blocks. Add `author?` to `ProposalView`.
- `src/trackChangesPreview.ts` — pass each proposal's author into its `ProposalView`.
- `media/preview.css` — author×operation classes; retire the semantic green/red and flat authorship tints.
- `test/trackChangesModel.test.ts` — new unit tests; update tests asserting old semantic classes.
---
### Task 1: `authorAt()` + `wordDiffByAuthor()` pure helpers
**Files:**
- Modify: `src/trackChangesModel.ts` (add after `wordMergedMarkdown`, ~line 475)
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: existing `AuthorKind`, `AuthorSpan` (trackChangesModel.ts:557-562), `diffWordsWithSpace` (already imported, line 12).
- Produces:
- `authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null` — the author whose span covers `offset` (half-open), else null.
- `wordDiffByAuthor(beforeRaw: string, afterRaw: string, afterStart: number, spans: AuthorSpan[]): string` — inline HTML where each inserted run is `<ins class="cw-ins-{author}">` (author looked up at the run's `afterStart`-relative landed offset) and each removed run is `<del class="cw-del-{author}">` (author = the insertion it's paired with in the same change cluster; `cw-del-none` if standalone). Unchanged parts are emitted verbatim.
- [ ] **Step 1: Write the failing test**
Add to `test/trackChangesModel.test.ts` (import `authorAt`, `wordDiffByAuthor` from `../src/trackChangesModel`):
```ts
describe("authorAt", () => {
const spans: AuthorSpan[] = [
{ start: 0, end: 5, author: "human" },
{ start: 5, end: 10, author: "claude" },
];
test("returns the covering span's author (half-open)", () => {
expect(authorAt(0, spans)).toBe("human");
expect(authorAt(4, spans)).toBe("human");
expect(authorAt(5, spans)).toBe("claude");
expect(authorAt(9, spans)).toBe("claude");
});
test("returns null outside any span", () => {
expect(authorAt(10, spans)).toBeNull();
expect(authorAt(-1, spans)).toBeNull();
});
});
describe("wordDiffByAuthor", () => {
test("an inserted run is colored by the author covering its landed offset", () => {
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
expect(html).toContain("hello ");
});
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
const html = wordDiffByAuthor("light", "dark", 0, spans);
expect(html).toContain('<del class="cw-del-claude">light</del>');
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
});
test("a standalone deletion (no paired insertion) is neutral", () => {
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
expect(html).toContain('<del class="cw-del-none">this </del>');
expect(html).not.toContain("cw-del-human");
expect(html).not.toContain("cw-del-claude");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — `authorAt`/`wordDiffByAuthor` not exported.
- [ ] **Step 3: Write minimal implementation**
Add to `src/trackChangesModel.ts` after `wordMergedMarkdown` (line 475):
```ts
/** The author whose span covers `offset` (half-open [start,end)), or null. */
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
return null;
}
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
/**
* Author-colored word diff for a changed PROSE block in the review.
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
* offset maps into `spans`. Insertions are colored by the author covering their
* offset; deletions inherit the author of the insertion they are paired with in
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
* cluster has no insertion. Pure, deterministic.
*/
export function wordDiffByAuthor(
beforeRaw: string,
afterRaw: string,
afterStart: number,
spans: AuthorSpan[],
): string {
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
// First pass: find each change cluster's insertion author (first added run).
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
let afterOff = afterStart;
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
{
let off = afterStart;
let i = 0;
while (i < parts.length) {
const p = parts[i];
if (!p.added && !p.removed) {
clusterAuthor[i] = null;
off += p.value.length;
i++;
continue;
}
// a cluster: scan to its end, capturing the first added run's author
let j = i;
let author: AuthorKind | null = null;
let scanOff = off;
while (j < parts.length && (parts[j].added || parts[j].removed)) {
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
if (parts[j].added) scanOff += parts[j].value.length;
j++;
}
for (let k = i; k < j; k++) clusterAuthor[k] = author;
off = scanOff;
i = j;
}
}
// Second pass: emit.
const out: string[] = [];
parts.forEach((p, i) => {
if (p.added) {
const a = authorAt(afterOff, spans);
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
afterOff += p.value.length;
} else if (p.removed) {
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
} else {
out.push(p.value);
afterOff += p.value.length;
}
});
return out.join("");
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS (new describes green).
- [ ] **Step 5: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)"
```
---
### Task 2: Route the review path through `wordDiffByAuthor`; stop coloring unchanged blocks
**Files:**
- Modify: `src/trackChangesModel.ts``renderReviewOp` (769-780), the `colored` closure + changed-op handling in `renderReview` (889-897)
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: `wordDiffByAuthor` (Task 1), `landedSpans` + `ranges` already computed in `renderReview`.
- Produces: review HTML where changed prose blocks emit `cw-ins-{author}`/`cw-del-{author}`, added blocks are author-colored as insertions, and **unchanged blocks render plain**.
- [ ] **Step 1: Write the failing test**
Add to the `renderReview` describe in `test/trackChangesModel.test.ts`:
```ts
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
expect(html).toContain('cw-ins-claude');
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
});
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
const doc = "Alpha stays.";
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
expect(html).not.toContain("cw-by-");
expect(html).not.toContain("cw-ins-");
});
test("renderReview: an added block is author-colored as an insertion", () => {
// baseline empty-ish -> add a new paragraph authored by human
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — changed blocks still emit plain `<ins>/<del>` via `wordMergedMarkdown`; unchanged blocks still carry `cw-by-*`.
- [ ] **Step 3: Write minimal implementation**
In `src/trackChangesModel.ts`:
**(a)** Change `renderReviewOp` (769-780) so a changed PROSE block uses author coloring. Give it the colored-insertion path for `added` and the author word-diff for non-atomic `changed`. Replace the function body:
```ts
function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
src: string,
changedHtml?: string,
): string {
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
// removed block (no live source — INV-36).
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
}
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
}
```
**(b)** In `renderReview` (the loop at 889-897), compute the author-colored changed HTML and stop coloring unchanged blocks. Replace the loop body:
```ts
let ci = 0; // pointer into ranges; advances for every op with a current-side block
const bodyParts: string[] = [];
for (const op of ops) {
const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++];
// Only ADDED blocks are author-colored (they are insertions since baseline);
// UNCHANGED blocks render plain (color means "changed since baseline").
const colored = (raw: string): string =>
blk && !clean && op.kind === "added" ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
// A non-atomic changed prose block: author-colored word diff (ins by author,
// del by adjacency). `blk.start` is the landed offset of the after-side text.
const changedHtml =
op.kind === "changed" && !op.atomic && blk && !clean
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
: undefined;
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
}
for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render));
return bodyParts.join("\n");
```
> Note: `render(wordDiffByAuthor(...))` passes the HTML-bearing string through markdown-it (same as `wordMergedMarkdown` was rendered). `colorByAuthor` on added blocks emits `cw-by-{author}` spans — Task 4 restyles those to insert styling in CSS.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS for the new tests.
- [ ] **Step 5: Update tests that assert the OLD review markup**
Two existing tests assert the old semantic output (test/trackChangesModel.test.ts:226-232). Update them:
```ts
test("renderReview: human addition since baseline renders author-colored ins", () => {
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/cw-del-none|cw-removed/);
});
```
Run: `npm test -- trackChangesModel`
Expected: PASS (whole file green).
- [ ] **Step 6: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat: author-colored changed/added blocks in review; unchanged blocks render plain"
```
---
### Task 3: Color pending proposal blocks by author
**Files:**
- Modify: `src/trackChangesModel.ts``ProposalView` (731-742) + `proposalBlockHtml` (744-767)
- Modify: `src/trackChangesPreview.ts` — populate `author` on each `ProposalView`
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: `AuthorKind` (557).
- Produces: `ProposalView.author?: AuthorKind` (defaults to `"claude"`); `proposalBlockHtml` emits `cw-del-{author}` / `cw-ins-{author}`.
- [ ] **Step 1: Write the failing test**
```ts
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('cw-del-claude');
expect(html).toContain('cw-ins-claude');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — `proposalBlockHtml` still emits `cw-del` / `cw-add`.
- [ ] **Step 3: Write minimal implementation**
In `src/trackChangesModel.ts`, add `author?` to `ProposalView` (after line 741):
```ts
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
original?: string;
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
author?: AuthorKind;
}
```
Update `proposalBlockHtml` (753-754):
```ts
const who = p.author ?? "claude";
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS.
- [ ] **Step 5: Populate `author` from the live proposal**
In `src/trackChangesPreview.ts` find where `ProposalView`s are built for `renderReview` (the `listProposals` mapping near line 373). Add `author` from the proposal's provenance. Locate the mapping and add:
```ts
author: p.author?.kind === "agent" ? "claude" : "human",
```
(where `p` is the live `Proposal` with `author: Provenance`). If the preview builds `ProposalView` via a helper, add the field there. Run `npm run typecheck` to confirm the field name matches the live `Proposal.author` shape (`model.ts:79-108`).
- [ ] **Step 6: Run typecheck + tests**
Run: `npm run typecheck && npm test -- trackChangesModel`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/trackChangesModel.ts src/trackChangesPreview.ts test/trackChangesModel.test.ts
git commit -m "feat: color pending proposal blocks by author (Claude blue/purple)"
```
---
### Task 4: CSS — author×operation color system (preview)
**Files:**
- Modify: `media/preview.css`
**Interfaces:**
- Consumes: classes emitted by Tasks 1-3: `cw-ins-human|claude|none`, `cw-del-human|claude|none`, `cw-by-human|claude` (added blocks), `cw-added`, `cw-removed`, `cw-changed`, `.cw-proposal`.
- Produces: the locked color mapping; unchanged text plain.
- [ ] **Step 1: Replace the semantic diff + authorship-tint rules**
In `media/preview.css`, replace lines 21-33 and 55-58 with author-colored rules. New block:
```css
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* whole-block ops: an added block is an insertion (author-colored inside via cw-by-*);
a standalone removed block is neutral struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
/* added-block author runs (colorByAuthor sentinels) read as insertions */
.cw-by-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-by-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
```
- [ ] **Step 2: Recolor the proposal block accent to neutral (author lives inside it now)**
The proposal block's left border is currently blue (`--vscode-charts-blue`). Keep it as a neutral "pending" frame — the del/ins inside now carry the author color. Change `media/preview.css:79-80`:
```css
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
```
- [ ] **Step 3: Manual visual verification**
Run the extension host (or load the smoke doc) and confirm in the preview: human insertions green-underlined, Claude insertions blue-underlined, Claude deletions purple-struck, standalone deletions neutral, unchanged text plain.
Run: `npm run build`
Expected: builds clean (CSS is bundled into the webview assets per the build).
- [ ] **Step 4: Commit**
```bash
git add media/preview.css
git commit -m "feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple)"
```
---
### Task 5: Phase-1 host E2E — author colors render in the preview
**Files:**
- Test: the existing F10/F7 preview E2E suite (find under `test/e2e/` — the preview/review specs)
**Interfaces:**
- Consumes: the rendered preview webview HTML.
- [ ] **Step 1: Write the failing E2E assertions**
In the preview E2E spec, after producing a Claude change + a human change on a doc, assert the webview body contains `cw-ins-claude` and `cw-ins-human`, and that an unchanged paragraph has no `cw-ins-`/`cw-by-` class. (Follow the existing pattern in that spec for opening the preview and reading `panel.webview` HTML or the DOM via the harness.)
- [ ] **Step 2: Run to verify it fails (or update an assertion that checked old classes)**
Run: `npm run test:e2e`
Expected: FAIL if any existing assertion checked `cw-add`/`cw-by-*`-as-authorship; update those to the new classes.
- [ ] **Step 3: Make it pass**
Adjust any E2E assertion referencing retired classes (`cw-add`, semantic `ins`/`del` colors, `cw-by-*` on unchanged blocks) to the new author classes.
Run: `npm run test:e2e`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add test/e2e
git commit -m "test(e2e): assert author-colored track changes render in the preview"
```
**>>> Phase 1 is independently shippable here. Open a PR for the preview pane before starting Phase 2, or continue.**
---
# Phase 2 — Editor pane (reverses INV-32)
Brings the same author-colored track changes inline into the main editor: all changes-since-baseline (committed + pending), not just pending proposals.
## File Structure (Phase 2)
- `src/editorProposalController.ts` — per-author decoration types; recolor proposals; subscribe to baseline + attribution; decorate all committed insertions (author-colored) + committed deletion hints (adjacency); overlap stacks naturally.
- `src/extension.ts` — inject `DiffViewController` + `AttributionController` into `EditorProposalController`.
- Test: editor E2E suite.
---
### Task 6: Per-author decoration types; recolor proposals to Claude
**Files:**
- Modify: `src/editorProposalController.ts` (20-27 decoration types; 80-106 renderEditor)
- Test: editor E2E
**Interfaces:**
- Consumes: `decorationPlan` (already imported), proposal list.
- Produces: four insertion decoration types (`human`/`claude`) keyed by author, deletion-hint types colored by author; proposals decorate with Claude colors.
- [ ] **Step 1: Replace the two decoration types with per-author maps**
In `src/editorProposalController.ts`, replace lines 20-27:
```ts
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
border: "0 0 2px 0", borderStyle: "solid",
}),
claude: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
border: "0 0 2px 0", borderStyle: "solid",
}),
};
private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
};
```
Update the constructor's disposables push (39-40) to dispose all four:
```ts
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter,
```
- [ ] **Step 2: Recolor proposal decorations to Claude in `renderEditor`**
Replace the body of `renderEditor` (81-106) so it groups ranges per author and applies the right decoration type. Proposals are Claude:
```ts
private renderEditor(editor: vscode.TextEditor): void {
const doc = editor.document;
const clearAll = () => {
for (const a of ["human", "claude"] as const) {
editor.setDecorations(this.insDeco[a], []);
editor.setDecorations(this.delDeco[a], []);
}
};
if (doc.languageId !== "markdown") return clearAll();
const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] };
// Pending proposals — always Claude (INV: proposals are agent-authored).
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
for (const d of plan.deletions) {
del.claude.push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
// Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]).
this.decorateCommitted(editor, ins, del); // no-op stub until Task 7
for (const a of ["human", "claude"] as const) {
editor.setDecorations(this.insDeco[a], ins[a]);
editor.setDecorations(this.delDeco[a], del[a]);
}
}
/** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */
private decorateCommitted(
_editor: vscode.TextEditor,
_ins: Record<"human" | "claude", vscode.Range[]>,
_del: Record<"human" | "claude", vscode.DecorationOptions[]>,
): void {}
```
- [ ] **Step 3: Typecheck + build**
Run: `npm run typecheck && npm run build`
Expected: clean.
- [ ] **Step 4: E2E — proposal shows Claude colors**
Update/extend the editor E2E to assert a pending proposal decorates (smoke: the decoration types exist and apply; the harness asserts via the proposal-applied path used today). Run: `npm run test:e2e` → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/editorProposalController.ts test/e2e
git commit -m "feat: per-author editor decoration types; proposals decorate in Claude blue/purple"
```
---
### Task 7: Decorate committed changes-since-baseline by author (reverse INV-32)
**Files:**
- Modify: `src/editorProposalController.ts` — inject baseline + attribution; implement `decorateCommitted`; subscribe to baseline/attribution change events
- Modify: `src/extension.ts` (line 122) — pass the two new deps
- Test: editor E2E
**Interfaces:**
- Consumes: `DiffViewController.getBaseline(uri)` (diffViewController.ts:133) + `onDidChangeBaseline` (32); `AttributionController.spansFor(document)` (attributionController.ts:405); pure `wordEditHunks` + `authorAt` (trackChangesModel.ts).
- Produces: committed insertions colored by `authorAt(currentOffset)`; committed deletion hints colored by adjacency (the paired insertion's author).
- [ ] **Step 1: Inject the new dependencies**
Change the constructor signature (38):
```ts
constructor(
private readonly proposals: ProposalController,
private readonly diffView: import("./diffViewController").DiffViewController,
private readonly attribution: import("./attributionController").AttributionController,
) {
```
Add subscriptions in the constructor disposables list:
```ts
this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
}),
```
In `src/extension.ts` (122) pass the deps (both already constructed above — `diffViewController` and `attributionController`):
```ts
const editorProposalController = new EditorProposalController(
proposalController,
diffViewController,
attributionController,
);
```
(Confirm the local variable names in `extension.ts` for the diff-view + attribution controllers via `npm run typecheck`.)
- [ ] **Step 2: Implement `decorateCommitted`**
Replace the stub with the real implementation. It diffs baseline → current, attributes insertions, and colors deletion hints by adjacency:
```ts
private decorateCommitted(
editor: vscode.TextEditor,
ins: Record<"human" | "claude", vscode.Range[]>,
del: Record<"human" | "claude", vscode.DecorationOptions[]>,
): void {
const doc = editor.document;
const baseline = this.diffView.getBaseline(doc.uri.toString());
if (!baseline || baseline.reason === "pinned") return; // pin→clean: no committed marks
const current = doc.getText();
const spans = this.attribution.spansFor(doc); // AuthorSpan[] in current coords
const hunks = wordEditHunks(baseline.text, current); // start/end index INTO current
for (const h of hunks) {
// The replacement text occupies [h.start, h.start+replacement.length) in current.
// Re-derive the intra-hunk word diff to split ins vs del and find the author.
const original = baseline.text.slice(/* mapped */ 0, 0); // see note
const plan = decorationPlan(h.start, hunkOriginal(baseline.text, current, h), current.slice(h.start, h.end));
const author = authorAt(h.start, spans) ?? "human";
for (const i of plan.insertions) ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
for (const d of plan.deletions) {
del[author].push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
}
```
> **Implementation note for the engineer:** `wordEditHunks(baseline, current)` returns hunks whose `start`/`end` index into **current** with a `replacement` that is the *baseline* text for that span (see trackChangesModel.ts:241-264 — `replacement` accumulates `removed` parts = baseline text, `end` advances over them). So for a committed change, the *original* (baseline) text of a hunk is `h.replacement` and the *applied* (current) text is `current.slice(h.start, h.end)`. Call `decorationPlan(h.start, h.replacement, current.slice(h.start, h.end))` directly — drop the `hunkOriginal`/`original` placeholder lines above. Verify this against a unit test before wiring (Step 3).
- [ ] **Step 3: Unit-test the hunk→plan derivation (pure)**
Because the offset semantics are subtle, add a pure unit test in `test/trackChangesModel.test.ts` proving `wordEditHunks` + `decorationPlan` reconstruct a committed change. Add an exported helper if needed:
```ts
test("committed change: wordEditHunks replacement is baseline text; current slice is applied", () => {
const baseline = "the light mode";
const current = "the dark mode";
const [h] = wordEditHunks(baseline, current);
expect(h.replacement).toContain("light"); // baseline side
expect(current.slice(h.start, h.end)).toContain("dark"); // applied side
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
expect(plan.insertions.length).toBeGreaterThan(0);
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
});
```
Run: `npm test -- trackChangesModel` → PASS. Then finalize `decorateCommitted` to use `h.replacement` + `current.slice(h.start, h.end)` (removing the placeholder lines).
- [ ] **Step 4: Add the attribution-change refresh**
Editor decorations must refresh when attribution changes (e.g. after a human edit). If `AttributionController` exposes a change event, subscribe to it and call `renderEditor`; otherwise refresh on `vscode.workspace.onDidChangeTextDocument` for the active editor (debounced like `scheduleApply`). Add to the constructor disposables:
```ts
vscode.workspace.onDidChangeTextDocument((e) => {
const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed);
}),
```
with a small `scheduleRender(ed)` that debounces `renderEditor(ed)` (mirror `scheduleApply`, 50ms).
- [ ] **Step 5: Typecheck + build + E2E**
Run: `npm run typecheck && npm run build && npm run test:e2e`
Expected: clean. E2E: a committed human edit shows green underline; an accepted Claude replace shows blue underline + purple struck hint; a pinned baseline shows nothing.
- [ ] **Step 6: Commit**
```bash
git add src/editorProposalController.ts src/extension.ts test/trackChangesModel.test.ts test/e2e
git commit -m "feat: author-colored committed track changes inline in the editor (reverse INV-32)"
```
---
### Task 8: Overlap — stacked marks where both authors touch one span
**Files:**
- Test: `test/trackChangesModel.test.ts` (preview overlap) + editor E2E (editor overlap)
- Modify (if needed): `media/preview.css` (ensure `<ins>` nested in `<del>` and vice-versa render both marks)
**Interfaces:**
- Consumes: existing emitted classes.
- Produces: a span both inserted (author A) and being deleted (author B) shows both A's underline and B's strikethrough.
- [ ] **Step 1: Editor overlap — verify decorations stack**
In the editor, an overlap arises when a committed insertion range (author A) is also covered by a pending proposal deletion (Claude). VS Code applies multiple decoration types to overlapping ranges, so the author-A insertion underline and the Claude deletion hint already coexist. Add an editor E2E asserting both decoration types are present on the overlap region. No code change expected; if the proposal's optimistic apply *replaces* the text (so A's range no longer exists), document that committed-insert + pending-delete overlap manifests as the proposal's struck original (Claude purple) adjacent to A's surviving insertion — assert that instead.
- [ ] **Step 2: Preview overlap — CSS nesting**
Add a CSS test fixture / assertion: a `<del class="cw-del-claude"><span class="cw-ins-human">…</span></del>` renders with both strikethrough (Claude purple) and underline (human green). Add to `media/preview.css` if the nested case collapses:
```css
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude,
.cw-del-claude .cw-by-human, .cw-del-human .cw-by-claude { text-decoration: inherit; }
```
- [ ] **Step 3: Run tests**
Run: `npm test && npm run test:e2e`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add media/preview.css test/trackChangesModel.test.ts test/e2e
git commit -m "feat: overlap renders stacked author marks (insert + delete) in both panes"
```
---
### Task 9: Sweep retired markup + full suite + manual smoke
**Files:**
- `test/**`, `media/preview.css`, any code referencing retired classes.
- [ ] **Step 1: Grep for retired classes/behaviors**
Run:
```bash
grep -rn "cw-add\b\|cw-del\b\|diffEditor.insertedTextBackground\|gitDecoration.deletedResourceForeground" src/ media/ test/
```
Reconcile every hit: `cw-add`/`cw-del` (without `-author`) only legitimately remain in `#cw-summary`. The editor's old semantic ThemeColors are gone (replaced in Task 6).
- [ ] **Step 2: Full unit + typecheck + build**
Run: `npm run typecheck && npm test && npm run build`
Expected: all green.
- [ ] **Step 3: Full E2E**
Run: `npm run test:e2e`
Expected: all green (update any remaining old-class assertions).
- [ ] **Step 4: Manual smoke (both panes)**
Open the sandbox doc, make a human edit + ask Claude to edit. Confirm: editor shows green/blue underlines + purple/red struck hints; preview shows the same; unchanged text plain in both; pin baseline → both panes clean; accept a proposal → it recolors as a committed change until re-pin.
- [ ] **Step 5: Commit + open PR**
```bash
git add -A
git commit -m "chore: sweep retired semantic-diff markup; full suite green"
```
---
## Self-Review
**1. Spec coverage:**
- Model (style=op, color=author) → Tasks 1-4 (preview), 6-7 (editor). ✓
- Human green/red + Claude blue/purple, exact hexes → Global Constraints + Task 4 CSS + Task 6 decorations. ✓
- Both panes → Phase 1 (preview) + Phase 2 (editor). ✓
- Reverses INV-32 → Task 7. ✓
- Both overlap directions, stacked → Task 8. ✓
- Color = changed-since-baseline; pin→clean preserved → Task 2 (unchanged plain), Task 7 (`reason === "pinned"` guard). ✓
- Deletion authorship = adjacency heuristic; standalone neutral → Task 1 (`wordDiffByAuthor` cluster author + `cw-del-none`), Task 7 (`authorAt(h.start)`). ✓
- Components: trackChangesModel, editorProposalController, attributionController (read via spansFor), preview.css, extension.ts wiring → covered. ✓
- Testing: unit (pure render), editor decorations (E2E), host E2E, retire old assertions → Tasks 1-3, 5, 7-9. ✓
**2. Placeholder scan:** Task 7 Step 2 intentionally shows a *wrong* placeholder (`hunkOriginal`/`original`) and immediately corrects it in the implementation note + Step 3 unit test that pins the exact offsets — this is a guided derivation, not an unresolved placeholder. No other TBD/TODO.
**3. Type consistency:** `AuthorKind` (`"claude" | "human"`) used throughout; decoration maps keyed `"human" | "claude"`; `authorAt` returns `AuthorKind | null``?? "human"`/`?? "none"` at every call site; `ProposalView.author?: AuthorKind`; `wordDiffByAuthor(beforeRaw, afterRaw, afterStart, spans)` signature matches both its test and its `renderReview` call site.
## Execution Handoff
(Filled in after you review.)
@@ -0,0 +1,150 @@
# Author-colored track changes (both panes) — design
**Date:** 2026-06-26
**Status:** Design approved; ready for implementation planning
**Mockups:** `.superpowers/brainstorm/15095-1782504559/content/panes-states-v2.html`
## Problem
Today the plugin uses two unrelated color systems:
- **The diff** (proposed/changed text in both panes) uses *semantic* colors —
green = added, red = removed — regardless of who made the change.
- **Authorship** (F3) uses *author* colors (blue = Claude, green = human) but
only as a flat tint in the **preview** pane, and only on committed text, not
as a diff.
So you cannot look at a change and immediately see **who** made it. The product
goal is an experience like two humans collaborating in a track-changes document
(Google Docs suggesting mode / Word track changes): every edit is a diff, and a
glance tells you both *what changed* and *who changed it*.
## The model
Every change is rendered as a diff — insertions **and** deletions — where:
- **Style encodes the operation:** underline = inserted, strikethrough = removed.
- **Color encodes the author.**
| | Added | Removed |
| ------ | ---------------- | ---------------------- |
| Human | green underline | red strikethrough |
| Claude | blue underline | purple strikethrough |
Human keeps today's exact green/red. Claude gets blue/purple (blue is already
Claude's established attribution hue; purple reads as a removal without colliding
with human red).
**Color means "changed since the pinned baseline,"** not "authored forever." Once
a change is accepted and the baseline re-pins, its marks clear and the text
returns to plain. This preserves the existing pin→clean behavior (#48): a pinned,
zero-diff document renders with no annotation.
### Concrete colors
Carry the existing human values; add Claude's pair. (Final hex values are a
polish detail for implementation; these are the design intent.)
- Human added: `#3fb950` underline, bg `rgba(63,185,80,0.14)`
- Human removed: `#f85149` strikethrough, bg `rgba(248,81,73,0.11)`
- Claude added: `#58a6ff` underline, bg `rgba(88,166,255,0.15)`
- Claude removed: `#bc8cff` strikethrough, bg `rgba(188,140,255,0.13)`
Each change run is a subtle author-hued background tint **plus** a solid 2px
underline (insert) or strikethrough (delete) in the full author color — the solid
line carries the signal even where backgrounds are faint.
## Both panes (decision A)
Author-colored track changes appear in **both** the main editor pane and the
rendered preview pane.
- **Main editor pane:** gains full inline track-changes for *all*
changes-since-baseline — human and Claude, committed and pending — not just
pending proposals. **This reverses INV-32** ("editor fully clean") from F10:
the editor was deliberately kept clean, with all who-wrote-what coloring living
only in the preview. We are intentionally undoing that.
- **Rendered preview pane:** shows the same model (it already renders a diff +
authorship; this unifies them so ins/del runs are author-colored rather than
semantically colored).
Pending Claude proposals keep their Accept/Reject affordance in both panes —
CodeLens (`✓ Accept ▾ / ✗ Reject ▾`) in the editor, buttons in the preview.
## States (must all render correctly)
Base states: human-added, human-removed, Claude-added, Claude-removed.
**Overlap** — a single span both authors touched — stacks both marks:
- Human added it, Claude is removing it → green underline **+** purple strikethrough.
- Claude added it, human is removing it → blue underline **+** red strikethrough.
Block-level: a whole added block carries the author's add treatment (left border +
tint); a whole removed block carries the author's remove treatment (struck + tint).
Resting state: unchanged-since-baseline text renders plain. After Accept + re-pin,
changed text returns to plain.
## Architecture
The core change is **fusing the currently-separate semantic-diff and authorship
systems** so that every inserted/deleted run carries its author, and rendering
colors by that author rather than by add/remove semantics.
Components affected (per the current code map):
- **`src/trackChangesModel.ts`** — `renderReview` / `renderOp` /
`wordMergedMarkdown` / `colorByAuthor`. Today `<ins>`/`<del>` are emitted with
semantic green/red classes independent of author. Change: ins/del runs become
author-aware and emit author-colored classes; overlap emits a stacked-mark
class.
- **`src/editorProposalController.ts`** — today only pending proposals decorate,
using semantic `ThemeColor`s. Change: per-author/op `TextEditorDecorationType`s
(human-add/human-del/claude-add/claude-del + overlap), and decorate **all**
changes-since-baseline, not just proposals (the INV-32 reversal).
- **`src/attributionController.ts`** — supplies the per-character/per-run author
spans the diff needs to attribute insertions (and, see below, deletions).
- **`media/preview.css`** — recolor: keep human green/red, add Claude
blue/purple, encode underline=insert / strikethrough=delete, add overlap
(stacked-mark) classes for both directions.
## Open technical question (for the plan / tech-design stage)
**Deletion authorship.** Removed text is no longer in the buffer, so the renderer
must know *who deleted it* to color the strikethrough.
- For **pending proposals**, the deleter is trivially Claude (the proposal owns
the deletion).
- For **committed deletions** (baseline → current), we must confirm the F3 seam /
attribution model carries the **deleting actor**. F3 attributes
*current-buffer* characters; deleted characters are gone. If the seam's edit
events carry the actor that performed each removal, we attribute from that; if
not, deriving deletion authorship is the main thing the implementation plan has
to solve. Until resolved, a safe fallback is to color committed deletions by the
**turn actor** that produced the diff hunk.
This is the one genuinely open architectural item; everything else is
presentation.
## Out of scope (YAGNI)
- More than two authors / arbitrary per-collaborator palettes. Two roles
(human, Claude) only.
- User-configurable colors / theme settings. Ship one good scheme.
- Changing accept/reject *mechanics* — this is purely how changes are
**visualized**; the F4 proposal lifecycle is unchanged.
- Per-word authorship inside an accepted, re-pinned (plain) document.
## Testing
- **Unit (pure render):** `trackChangesModel` renders each state to the expected
author-colored markup — the four base states, both overlap directions, block
add/remove, and resting/plain. Reuse the existing pure-render test pattern.
- **Editor decorations:** the decoration plan produces the right per-author/op
ranges, including the INV-32-reversal (committed changes now decorate) and
stacked overlap ranges.
- **Host E2E:** open a co-edited doc, assert both panes show author-colored
marks; accept a proposal + re-pin → marks clear (pin→clean preserved).
- Update/replace any test that asserts the **old** semantic green=add/red=remove
classes or the INV-32 clean-editor behavior.