diff --git a/.gitignore b/.gitignore index a034613..7c99bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ test/e2e/fixtures/workspace/.threads/ # Sandbox playground churn (the EDH workspace - play freely, commit nothing) sandbox/.threads/ + +# brainstorming visual-companion scratch +.superpowers/ diff --git a/docs/superpowers/plans/2026-06-26-author-colored-track-changes.md b/docs/superpowers/plans/2026-06-26-author-colored-track-changes.md new file mode 100644 index 0000000..3778426 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-author-colored-track-changes.md @@ -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 `` (author looked up at the run's `afterStart`-relative landed offset) and each removed run is `` (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('brave '); + 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('light'); + expect(html).toContain('dark'); + }); + test("a standalone deletion (no paired insertion) is neutral", () => { + const html = wordDiffByAuthor("keep this word", "keep word", 0, []); + expect(html).toContain('this '); + 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(`${p.value}`); + afterOff += p.value.length; + } else if (p.removed) { + out.push(`${p.value}`); + } 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 `/` 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 `
${changedHtml}
`; + } + if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src); + return `
${colored(op.block.raw)}
`; +} +``` + +**(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 ? `${safe(p.replaced)}` : ""; + const after = `${safe(p.replacement)}`; +``` + +- [ ] **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 `` nested in `` 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 `` 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.) diff --git a/docs/superpowers/specs/2026-06-26-author-colored-track-changes-design.md b/docs/superpowers/specs/2026-06-26-author-colored-track-changes-design.md new file mode 100644 index 0000000..e9a06a8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-author-colored-track-changes-design.md @@ -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 ``/`` 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.