# F9 Authorship Preview Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an **Authorship** mode to the F7 rendered preview — a header toggle that re-renders the current markdown doc with each span colored by its F3 author (Claude blue / human green), inline and char-precise — so a writer can see what Claude composed even after the F6 baseline absorbs it. **Architecture:** A new pure render engine `renderAuthorship(currentText, authorSpans)` in `src/trackChangesModel.ts` (vscode-free, unit-tested) injects Private-Use-Area sentinels at attribution-span boundaries through markdown-it, then post-processes them into ``; code/mermaid fences stay atomic with a block-level author badge. `AttributionController.spansFor(document)` supplies the spans (resolving the URI-vs-keyOf key mismatch). `TrackChangesPreviewController` gains a per-panel mode and an attribution dependency; the webview header gets a segmented `[ Track changes | Authorship ]` toggle that posts `setMode` back. **Tech Stack:** TypeScript, VS Code webview API, `markdown-it`, vitest (unit), `@vscode/test-electron` + mocha (host E2E). Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`. **Conventions (read first):** - The render engine is **vscode-free** and **deterministic** (INV-22/28) — spans are passed as data, tested under vitest like `test/trackChangesModel.test.ts`. - Avoid `*/` inside block comments (an earlier F8 hiccup: a `**/` glob closed a comment early). Keep glob examples out of JSDoc. - Attribution offsets are char offsets into `document.getText()` — the same string passed to the renderer as `currentText`, so they align. - Run unit: `npm test`. Typecheck: `npm run typecheck`. Build: `npm run build`. Host E2E: `npm run test:e2e`. - After each task: `npm test` green + `npm run typecheck` clean, then commit. --- ## File Structure **Modify:** - `src/trackChangesModel.ts` — add `AuthorKind`/`AuthorSpan`, `splitBlocksWithRanges`, `renderAuthorship`. - `src/attributionController.ts` — add `spansFor(document): AuthorSpan[]`. - `src/trackChangesPreview.ts` — attribution dependency; per-panel mode; `setMode` handling; render branch; post `mode`/`legend`. - `src/extension.ts` — reorder so attribution exists before the F7 controller; pass it in. - `media/preview.ts` — segmented toggle + `acquireVsCodeApi` postMessage; legend vs summary per mode. - `media/preview.css` — `.cw-by-claude` / `.cw-by-human`, toggle, legend, fence-badge styles. - `README.md` — F9 note. **Create:** - `test/e2e/suite/authorship.test.ts` — host E2E. - `docs/MANUAL-SMOKE-F9.md` — manual smoke runbook. **Untouched:** `renderTrackChanges`/`diffBlocks` behavior (track-changes mode), F6 baseline, F3 capture, the seam, persistence. --- ## Task 1: `splitBlocksWithRanges` (block offsets) **Files:** - Modify: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing test (append to `test/trackChangesModel.test.ts`)** ```typescript import { splitBlocksWithRanges } from "../src/trackChangesModel"; describe("splitBlocksWithRanges — block offsets align with the source string", () => { it("each block's [start,end) slices back to its raw from the source", () => { const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n"; const blocks = splitBlocksWithRanges(text); expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]); for (const b of blocks) { expect(text.slice(b.start, b.end)).toBe(b.raw); } expect(blocks[1].raw).toBe("A prose paragraph."); }); it("marks a mermaid fence and preserves its source offsets", () => { const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n"; const blocks = splitBlocksWithRanges(text); expect(blocks[1].type).toBe("mermaid"); expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npm test -- trackChangesModel` Expected: FAIL (`splitBlocksWithRanges` is not exported). - [ ] **Step 3: Implement `splitBlocksWithRanges` in `src/trackChangesModel.ts`** Add after `splitBlocks`: ```typescript export interface BlockWithRange extends Block { /** char offset of the block's first char in the source string. */ start: number; /** char offset one past the block's last char (source.slice(start,end) === raw). */ end: number; } /** * Like splitBlocks, but each block carries its [start,end) char range in the * SOURCE string (offsets align with document.getText(), so F3 attribution * offsets map directly). Lines are tracked with their true source offsets * (a trailing CR stays in the line, so \r\n sources keep correct offsets). */ export function splitBlocksWithRanges(text: string): BlockWithRange[] { // line raw (excluding the \n terminator) + its source start offset. const lines: { raw: string; start: number }[] = []; let from = 0; for (let i = 0; i <= text.length; i++) { if (i === text.length || text[i] === "\n") { lines.push({ raw: text.slice(from, i), start: from }); from = i + 1; if (i === text.length) break; } } const out: BlockWithRange[] = []; const range = (lo: number, hi: number): { start: number; end: number } => { const start = lines[lo].start; const end = lines[hi].start + lines[hi].raw.length; return { start, end }; }; let buf: number[] = []; // indices of buffered prose lines const flushProse = () => { if (buf.length) { const { start, end } = range(buf[0], buf[buf.length - 1]); const raw = text.slice(start, end); if (raw.trim()) out.push({ ...makeBlockRange(raw, "prose"), start, end }); } buf = []; }; let i = 0; while (i < lines.length) { const line = lines[i].raw; const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); if (fence) { flushProse(); const marker = fence[2][0]; const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); const open = i; i++; while (i < lines.length) { const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); i++; if (closed) break; } const close = i - 1; const { start, end } = range(open, close); const raw = text.slice(start, end); out.push({ ...makeBlockRange(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); continue; } if (line.trim() === "") { flushProse(); i++; continue; } buf.push(i); i++; } flushProse(); return out; } function makeBlockRange(raw: string, type: BlockType): Block { return makeBlock(raw, type); } ``` (`makeBlockRange` is a thin alias so the spread `{ ...makeBlockRange(...), start, end }` reads clearly; it reuses the existing `makeBlock`.) - [ ] **Step 4: Run to verify it passes** Run: `npm test -- trackChangesModel` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f9): splitBlocksWithRanges — block offsets aligned to the source" ``` --- ## Task 2: `renderAuthorship` — inline spans + atomic fences **Files:** - Modify: `src/trackChangesModel.ts` - Test: `test/trackChangesModel.test.ts` - [ ] **Step 1: Write the failing tests (append)** ```typescript import { renderAuthorship, type AuthorSpan } from "../src/trackChangesModel"; const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => { const start = text.indexOf(sub); return { start, end: start + sub.length, author }; }; describe("renderAuthorship", () => { it("empty spans → plain render (no author wrappers)", () => { const text = "# Hi\n\nplain paragraph.\n"; const html = renderAuthorship(text, []); expect(html).not.toContain("cw-by-claude"); expect(html).not.toContain("cw-by-human"); expect(html).toContain("plain paragraph."); }); it("wraps a single Claude span inline", () => { const text = "The cat sat on the mat.\n"; const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]); expect(html).toContain('cat sat'); }); it("marks two authors in one paragraph at exact boundaries", () => { const text = "Alpha beta gamma.\n"; const html = renderAuthorship(text, [ spanAt(text, "Alpha", "human"), spanAt(text, "gamma", "claude"), ]); expect(html).toContain('Alpha'); expect(html).toContain('gamma'); }); it("clips a span to its block (does not bleed across blocks)", () => { const text = "Para one.\n\nPara two.\n"; // a span covering the whole text; each block wraps only its own slice const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); expect(html).toContain('Para one.'); expect(html).toContain('Para two.'); }); it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => { const text = "```js\nconst x = 1;\n```\n"; const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); expect(html).toContain("cw-by-claude"); expect(html).toContain("cw-badge"); expect(html).not.toContain(""); // no sentinel leaked into the code expect(html).toContain("const x = 1;"); }); it("a mermaid fence authored by Claude renders as a diagram with a badge", () => { const text = "```mermaid\ngraph TD; A-->B;\n```\n"; const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]); expect(html).toContain('pre class="mermaid"'); expect(html).toContain("cw-by-claude"); expect(html).toContain("cw-badge"); }); it("is deterministic", () => { const text = "Stable input paragraph.\n"; const spans: AuthorSpan[] = [spanAt(text, "input", "claude")]; expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans)); }); }); ``` - [ ] **Step 2: Run to verify it fails** Run: `npm test -- trackChangesModel` Expected: FAIL (`renderAuthorship`/`AuthorSpan` not exported). - [ ] **Step 3: Implement in `src/trackChangesModel.ts`** ```typescript export type AuthorKind = "claude" | "human"; export interface AuthorSpan { start: number; end: number; author: AuthorKind; } // Private-Use-Area sentinels (never appear in real content; markdown-it passes // them through as plain text). Paired open/close per author. const SENT = { claude: { open: "", close: "" }, human: { open: "", close: "" }, } as const; function authorBadge(authors: Set): { cls: string; label: string } | null { if (authors.size === 0) return null; if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" }; const only = [...authors][0]; return only === "claude" ? { cls: "cw-by-claude", label: "Claude" } : { cls: "cw-by-human", label: "You" }; } /** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { // Build insertions as (localOffset, marker); apply right-to-left so offsets stay valid. const inserts: { at: number; marker: string }[] = []; for (const s of spans) { const lo = Math.max(0, s.start - blockStart); const hi = Math.min(raw.length, s.end - blockStart); if (hi <= lo) continue; inserts.push({ at: lo, marker: SENT[s.author].open }); inserts.push({ at: hi, marker: SENT[s.author].close }); } // Close markers must come BEFORE open markers at the same offset to keep nesting // tidy; otherwise sort by offset descending and, at equal offset, close first. inserts.sort((a, b) => (b.at - a.at) || (isClose(a.marker) ? -1 : 1)); let out = raw; for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at); return out; } function isClose(m: string): boolean { return m === SENT.claude.close || m === SENT.human.close; } /** Replace the rendered sentinels with author tags. */ function sentinelsToSpans(html: string): string { return html .split(SENT.claude.open).join('') .split(SENT.claude.close).join("") .split(SENT.human.open).join('') .split(SENT.human.close).join(""); } /** * Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed * span colored by author. Prose blocks get inline ``; * code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a * block-level author badge, never inner sentinels. Deterministic. */ export function renderAuthorship( currentText: string, spans: AuthorSpan[], opts: RenderOptions = {}, ): string { const render = opts.render ?? defaultRender; const safe = (src: string): string => { try { return render(src); } catch (err) { return chip(err instanceof Error ? err.message : String(err)); } }; return splitBlocksWithRanges(currentText) .map((b) => { const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end); if (b.type !== "prose") { const badge = authorBadge(new Set(overlapping.map((s) => s.author))); const inner = safe(b.raw); if (!badge) return `
${inner}
`; return `
${badge.label}${inner}
`; } const injected = injectSentinels(b.raw, b.start, overlapping); return `
${sentinelsToSpans(safe(injected))}
`; }) .join("\n"); } ``` - [ ] **Step 4: Run to verify it passes** Run: `npm test -- trackChangesModel` Expected: PASS. If the inline-span test fails because markdown-it wraps prose in `

`, the assertion still holds (`cat sat` appears inside the `

`). If a sentinel survives in output, check `sentinelsToSpans` ordering. - [ ] **Step 5: Commit** ```bash git add src/trackChangesModel.ts test/trackChangesModel.test.ts git commit -m "feat(f9): renderAuthorship — inline author spans + atomic fence badges (INV-26/27/28)" ``` --- ## Task 3: `AttributionController.spansFor` **Files:** - Modify: `src/attributionController.ts` - [ ] **Step 1: Add the method** In `src/attributionController.ts`, import the type and add a public method near `getSpans`: ```typescript import type { AuthorSpan } from "./trackChangesModel"; ``` ```typescript /** * F9: the document's live attribution as authorship spans for the preview — * current-buffer char ranges mapped to author kind (agent→claude). Computes * the document key internally (so callers pass a TextDocument, not the key). */ spansFor(document: vscode.TextDocument): AuthorSpan[] { return this.getSpans(this.keyOf(document)).map((s) => ({ start: s.range.start, end: s.range.end, author: s.authorKind === "agent" ? "claude" : "human", })); } ``` - [ ] **Step 2: Verify typecheck** Run: `npm run typecheck` Expected: clean. - [ ] **Step 3: Commit** ```bash git add src/attributionController.ts git commit -m "feat(f9): AttributionController.spansFor — authorship spans for the preview" ``` --- ## Task 4: Wire mode + attribution into the preview controller **Files:** - Modify: `src/trackChangesPreview.ts`, `src/extension.ts` - [ ] **Step 1: Controller — attribution dep, mode state, render branch, setMode** In `src/trackChangesPreview.ts`: Imports + fields: ```typescript import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; import type { AttributionController } from "./attributionController"; ``` Add a per-panel mode map field: ```typescript private readonly mode = new Map(); ``` Constructor — add the attribution param (after `extensionUri`): ```typescript constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, ) { ``` In `show(...)`, after creating the panel, wire incoming messages (the toggle). Add to the `panel.onDidDispose` registration area: ```typescript panel.webview.onDidReceiveMessage( (m: { type?: string; mode?: "changes" | "authorship" }) => { if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { this.mode.set(key, m.mode); this.refresh(document); } }, null, this.disposables, ); ``` Rewrite `refresh(document)` to branch on mode: ```typescript refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; const mode = this.mode.get(key) ?? "changes"; const current = document.getText(); if (mode === "authorship") { const spans = this.attribution.spansFor(document); void panel.webview.postMessage({ type: "render", mode, html: renderAuthorship(current, spans), legend: { claude: spans.some((s) => s.author === "claude"), human: spans.some((s) => s.author === "human") }, }); this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); return; } const baseline = this.diffView.getBaseline(key); const baselineText = baseline?.text ?? current; const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); const summary = { added: ops.filter((o) => o.kind === "added").length, removed: ops.filter((o) => o.kind === "removed").length, changed: ops.filter((o) => o.kind === "changed").length, }; void panel.webview.postMessage({ type: "render", mode, html: renderTrackChanges(baselineText, current), epoch: this.epochLabel(baseline), summary, }); } ``` Add a test seam for the current mode (used by E2E): ```typescript getMode(uriString: string): "changes" | "authorship" { return this.mode.get(uriString) ?? "changes"; } setMode(uriString: string, mode: "changes" | "authorship"): void { this.mode.set(uriString, mode); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (doc) this.refresh(doc); } ``` - [ ] **Step 2: extension.ts — reorder + pass attribution** In `src/extension.ts`, the F7 controller is currently constructed in the F6/F7 block (before the authoring stack). Move the `TrackChangesPreviewController` construction to AFTER `attributionController` is created, and pass it: 1. Delete the existing block: ```typescript const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, ); context.subscriptions.push(trackChangesPreviewController); ``` 2. Re-create it right after `const attributionController = new AttributionController(...)` + its `context.subscriptions.push(attributionController);`: ```typescript // --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode --- // Constructed after attribution so the authorship view can read F3 spans. const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, attributionController, ); context.subscriptions.push(trackChangesPreviewController); ``` (The `CowritingApi` return already includes `trackChangesPreviewController` — unchanged. The F6 `diffViewController` stays where it is.) - [ ] **Step 3: Verify typecheck + unit** Run: `npm run typecheck && npm test` Expected: clean + 150+ tests green (Tasks 1–3 added tests). - [ ] **Step 4: Commit** ```bash git add src/trackChangesPreview.ts src/extension.ts git commit -m "feat(f9): preview gains authorship mode + attribution dep + setMode wiring" ``` --- ## Task 5: Webview toggle, legend, and CSS **Files:** - Modify: `src/trackChangesPreview.ts` (shell HTML), `media/preview.ts`, `media/preview.css` - [ ] **Step 1: Shell HTML — add the segmented toggle + legend slot** In `src/trackChangesPreview.ts` `shellHtml(...)`, replace the `

` with: ```html
Track changes
``` - [ ] **Step 2: `media/preview.ts` — post setMode; render per mode** Replace the message interface + handler and add the toggle wiring: ```typescript interface RenderMessage { type: "render"; mode: "changes" | "authorship"; html: string; epoch?: string; summary?: { added: number; removed: number; changed: number }; legend?: { claude: boolean; human: boolean }; } const vscodeApi = acquireVsCodeApi(); const body = document.getElementById("cw-body")!; const header = document.getElementById("cw-epoch")!; const summary = document.getElementById("cw-summary")!; const legend = document.getElementById("cw-legend")!; const segs = Array.from(document.querySelectorAll(".cw-seg")); for (const seg of segs) { seg.addEventListener("click", () => { vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode }); }); } window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg?.type !== "render") return; body.innerHTML = msg.html; for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode); const authorship = msg.mode === "authorship"; header.hidden = authorship; summary.hidden = authorship; legend.hidden = !authorship; if (authorship) { const parts: string[] = []; if (msg.legend?.claude) parts.push('Claude'); if (msg.legend?.human) parts.push('You'); legend.innerHTML = parts.join(" ") || "no attribution yet"; } else { header.textContent = `Track changes since ${msg.epoch ?? ""}`; summary.innerHTML = `+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)} ` + `−${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}`; } void renderMermaid(); }); ``` (`acquireVsCodeApi` is a webview global; add `declare function acquireVsCodeApi(): { postMessage(m: unknown): void };` near the top of the file if the type isn't already present.) - [ ] **Step 3: `media/preview.css` — author + toggle + legend styles** Append: ```css .cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; } .cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; } /* Block-level author badges reuse .cw-badge; tint the whole fence block. */ .cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; } .cw-seg { background: transparent; color: var(--vscode-foreground); border: 1px solid var(--vscode-panel-border); padding: 0 0.5em; cursor: pointer; font-size: 0.9em; } .cw-seg:first-child { border-radius: 3px 0 0 3px; } .cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; } .cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } #cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; } ``` (Background tints use translucent fallbacks so they read against any theme; the inline spans are intentionally subtle.) - [ ] **Step 4: Build + verify** Run: `npm run build && npm run typecheck && npm test` Expected: build emits `out/media/preview.js`; typecheck clean; unit green. - [ ] **Step 5: Commit** ```bash git add src/trackChangesPreview.ts media/preview.ts media/preview.css git commit -m "feat(f9): webview segmented mode toggle + authorship legend + author CSS" ``` --- ## Task 6: Host E2E **Files:** - Create: `test/e2e/suite/authorship.test.ts` - [ ] **Step 1: Write the suite** ```typescript import * as assert from "assert"; import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; import type { CowritingApi } from "../../../src/extension"; const WS = process.env.E2E_WORKSPACE!; const settle = () => new Promise((r) => setTimeout(r, 300)); async function getApi(): Promise { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); return api; } // F9 host E2E (no LLM): authorship mode marks Claude's landed span. Owns its own // markdown doc, disjoint from the other suites' fixtures. suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { const DOC_REL = "docs/f9authorship.md"; const TARGET = "The sentence Claude will compose over."; test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { const abs = path.join(WS, DOC_REL); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); await settle(); const api = await getApi(); const key = uri.toString(); // open the preview (track-changes mode by default) await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open"); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes"); // Claude composes via the seam (propose → accept) const start = doc.getText().indexOf(TARGET); const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { uri: key, start, end: start + TARGET.length, newText: "The sentence CLAUDE COMPOSED.", model: "sonnet", sessionId: "e2e-f9", turnId: "turn-f9", }); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); await settle(); // attribution has a Claude span now const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent"); assert.ok(claudeSpan, "Claude span recorded by F3"); // flip to authorship mode → the model should reflect Claude authorship api.trackChangesPreviewController.setMode(key, "authorship"); await settle(); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship"); const spans = api.attributionController.spansFor(doc); assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview"); // back to track-changes — still functional (regression) api.trackChangesPreviewController.setMode(key, "changes"); await settle(); assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes"); assert.ok((api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, "track-changes model still computed"); }); }); ``` - [ ] **Step 2: Run E2E** Run: `npm run test:e2e` Expected: both EDH passes green (the new F9 suite + all prior suites). Debug per `superpowers:systematic-debugging` if red; do not weaken assertions. - [ ] **Step 3: Commit** ```bash git add test/e2e/suite/authorship.test.ts git commit -m "test(f9): host E2E — authorship mode marks Claude's landed span" ``` --- ## Task 7: Docs **Files:** - Create: `docs/MANUAL-SMOKE-F9.md` - Modify: `README.md` - [ ] **Step 1: `docs/MANUAL-SMOKE-F9.md`** ```markdown # Manual smoke — F9 authorship view in the preview Confirms the rendered preview's Authorship mode colors Claude's vs your text. One live turn hits the SDK. ## Prereqs - `npm run build`; launch the Extension Development Host (F5) with `sandbox/` open. ## Steps 1. Open a markdown file. Open **Cowriting: Open Track-Changes Preview** (`Ctrl+Alt+R`). 2. The header shows a `[ Track changes | Authorship ]` toggle. It opens in **Track changes** (unchanged behavior). 3. Select a sentence → **Ask Claude to Edit Selection** → instruct → accept. 4. Click **Authorship**. Expect: Claude's accepted text is tinted (blue) and your own typing is tinted (green); the header shows a legend (● Claude / ● You). Text you never touched (original content) is plain. 5. Type a few words yourself, mixing into a Claude sentence. Expect: the colors split mid-paragraph at the exact boundaries. 6. If the doc has a mermaid or code fence Claude authored, expect the whole fence to carry a small author badge (not per-character). 7. Flip back to **Track changes**. Expect: the diff view is exactly as before. ``` - [ ] **Step 2: README F9 note** — add after the F8 section (match heading style): ```markdown ## F9 — Authorship view in the preview (Feature ~#27) The rendered preview (F7) gains a second mode, switched by a `[ Track changes | Authorship ]` toggle in its header. **Authorship** mode re-renders the current document with each span colored by its F3 author — Claude (blue) vs you (green), inline and char-precise — with a legend. Unlike track-changes (which diffs against the F6 baseline, and so hides Claude's text once the baseline advances past a landing), authorship reads F3 attribution directly, so Claude's contributions stay visible. Code/mermaid fences carry a block-level author badge (atomic). Read-only, sealed webview, no new persistence (INV-26..28). Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`. Live smoke: [`docs/MANUAL-SMOKE-F9.md`](docs/MANUAL-SMOKE-F9.md). ``` - [ ] **Step 3: Verify + commit** ```bash npm run typecheck && npm test git add docs/MANUAL-SMOKE-F9.md README.md git commit -m "docs(f9): manual smoke + README authorship-mode note" ``` --- ## Self-Review checklist (run before PR) - **Spec coverage:** §2 modes → Tasks 4/5; §3.1 render engine → Tasks 1/2; §3.2 wiring → Tasks 3/4/5; §3.3 INV-26/27/28 → Tasks 2/4; §5 testing → Tasks 1/2/6 + smoke; §6 slices → Tasks 1–7. - **Untouched:** `renderTrackChanges`/`diffBlocks` logic, F6 baseline, F3 capture, the seam, persistence (`git diff --stat` should show no behavioral change to those). - **Done (spec §6):** authorship mode marks Claude (blue) + you (green) inline; header toggle flips modes; fences get a block badge; track-changes unchanged; unit + host E2E green; smoke performed once. ```