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:
@@ -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.
|
||||
Reference in New Issue
Block a user