diff --git a/specs/2026-06-11-authorship-preview-design.md b/specs/2026-06-11-authorship-preview-design.md new file mode 100644 index 0000000..50ed832 --- /dev/null +++ b/specs/2026-06-11-authorship-preview-design.md @@ -0,0 +1,186 @@ +--- +status: graduated +--- +# Solution Design: Authorship view in the rendered preview (F9) + +| | | +| --- | --- | +| **Author(s)** | Ben Stull (with Claude) | +| **Status** | `draft` | +| **Version** | v0.1.0 | +| **Anchor** | Feature **F9** (to be captured, ~#27) — builds on F7 `#21` (rendered preview) + F3 `#6` (live attribution). Surfaced as friction during F8 (`#25`) testing: "the preview doesn't show the Claude-composed annotations." | + +--- + +## 1. Problem & context + +The F7 rendered track-changes preview (`src/trackChangesPreview.ts` + +`src/trackChangesModel.ts`) answers **"what changed since the F6 baseline?"** It +diffs `baselineText` vs the live buffer and renders author-*agnostic* +`added`/`removed`/`changed` marks. It has never read F3 attribution. + +Two facts combine into the friction: + +1. The F6 baseline **advances on every machine landing** (INV-18): when a Claude + proposal is accepted, its text is folded *into* the baseline, so it then reads + as **unchanged** in track-changes. +2. The preview has **no authorship axis** — it cannot say "Claude composed this + span" vs "you did." + +So a writer who accepts Claude's edits cannot *see* what Claude contributed in the +preview. F3 attribution already records exactly that (Claude=`agent` vs human +spans, char-precise), and F8 made attribution available on **any** authorable doc +(in-folder / out-of-folder / untitled) — the data exists; the preview just doesn't +show it. + +**Goal:** add an **Authorship** mode to the preview that renders the current +document with each span colored by its F3 author — Claude (blue) vs you (green) — +inline and char-precise, reading attribution directly (baseline-independent), so +Claude's contributions are visible even after the baseline absorbs them. + +## 2. Solution overview + +The preview gains a **second mode**, switched by a **segmented control in the +webview header** (`[ Track changes | Authorship ]`); mode is remembered per panel, +default **Track changes** (today's behavior, unchanged). + +- **Track changes mode** — unchanged (`renderTrackChanges(baselineText, current)`). +- **Authorship mode** — renders the **current buffer** (no baseline diff) via a new + pure engine `renderAuthorship(currentText, authorSpans)`, wrapping each + attributed span in `` / ``. + A header **legend** (● Claude / ● You) appears in this mode. Text with no + attribution record renders plain. + +The two modes are distinct renderings of different axes; authorship mode does **not** +combine with the baseline diff (no `ins`/`del`, no removed text — there is no diff). + +## 3. Technical design + +### 3.1 The render engine (pure, vscode-free — extends `trackChangesModel.ts`) + +New export `renderAuthorship(currentText: string, spans: AuthorSpan[]): string`, +where `AuthorSpan = { start: number; end: number; author: "claude" | "human" }` +(char offsets into `currentText`, non-overlapping). Deterministic (INV-22 extends +to it): same inputs → identical HTML; no vscode, no DOM. + +Algorithm: + +1. **Block split with offsets.** Reuse `splitBlocks` but track each block's + `[start, end)` char range in `currentText` (add the offsets to the `Block` + shape, or a parallel `splitBlocksWithRanges`). Blank-line gaps between blocks + are outside any block range (consistent with today's whitespace handling). +2. **Per block:** + - **Prose block:** clip `spans` to the block's range, translate to block-local + offsets, and inject paired **sentinel markers** at the local boundaries into + the block's raw markdown — four Unicode Private-Use Area code points: `U+E000` + (Claude-open) / `U+E001` (Claude-close), `U+E002` (human-open) / `U+E003` + (human-close). PUA code points never appear in real content and markdown-it + passes them through as plain text. Render the block with the existing + markdown-it instance, then **post-process** the HTML: string-replace + `U+E000`→``, `U+E001`→``, + `U+E002`→``, `U+E003`→``. + - **Code / mermaid fence (atomic, INV-23 extends → INV-27):** never inject + sentinels inside a fence. If any span overlaps the fence, wrap the rendered + fence in a block div carrying the author class + a small badge + (`Claude` / `You` / `mixed` when both authors overlap); else render plain. +3. Concatenate block HTML (same join as `renderTrackChanges`). Each block keeps + the existing per-block `try/catch` → error chip, so a render failure degrades + to a visible chip, never a throw. + +**Known edge case (documented):** a sentinel placed immediately adjacent to active +emphasis markers (`**`, `_`) can perturb markdown-it's inline parsing for that +span. Spans from F3 fall on real edit boundaries, so this is rare; v1 accepts it +(the per-block chip prevents any hard failure) and covers the common cases with +tests. Snapping span boundaries to token-safe positions is a deferred refinement. + +### 3.2 Data source & wiring + +- **Spans come from F3.** `AttributionController` already maintains live spans as + current-buffer char ranges with author kind (`getSpans(key)` → `RenderedSpan[]`, + `authorKind: "human" | "agent"`). Add `spansFor(document): AuthorSpan[]` to + `AttributionController` — it computes the document key internally + (`this.keyOf(document)` via the F8 router) and maps `agent→"claude"`, + `human→"human"`. This resolves the key mismatch (the preview keys panels by + `document.uri.toString()`; attribution keys by `keyOf`). +- **Preview gains an attribution dependency.** `TrackChangesPreviewController` + takes `AttributionController` in its constructor. In `extension.ts`, construct + the authoring stack (router, guard, attribution) **before** the F7 controller + so the dependency is available (a small, safe reorder — F8 already removed the + no-root early return, so all controllers are constructed unconditionally). +- **Mode state & toggle.** The controller holds `mode: Map` + (default `"changes"`). `refresh(document)` renders the panel's current mode: + track-changes (existing path) or authorship (`renderAuthorship(current, + attribution.spansFor(document))`), posting `{type:"render", html, mode, epoch?, + summary?, legend?}`. The webview header shows the segmented toggle; clicking + posts `{type:"setMode", mode}` back; the controller updates the map and + re-renders. Edits/baseline-changes re-render in the current mode. +- **Webview (`media/`):** add the segmented toggle to `#cw-header` and a + `setMode` message; render the posted HTML into `#cw-body` as today; show/hide + the epoch+summary (track-changes) vs the legend (authorship) per mode. CSS adds + `.cw-by-claude` (blue) / `.cw-by-human` (green) + the badge/legend styles, + themed via VS Code CSS variables (consistent with the existing preview CSS). + +### 3.3 Invariants (additions) + +- **INV-26 (authorship mode is baseline-independent):** authorship mode renders + the **current buffer** colored by F3 author, reading `AttributionController`, + never the F6 baseline. Track-changes mode is unchanged. The two are distinct + modes, never combined. +- **INV-27 (fences stay atomic in authorship too):** a code/mermaid fence is never + marked inline; an overlapping author span yields a **block-level** author badge + (extends INV-23). +- **INV-28 (pure authorship render):** `renderAuthorship(currentText, spans)` is + vscode-free and deterministic (extends INV-22) — spans are passed as data, so it + unit-tests with no editor. +- INV-19/20/21 (read-only preview, sealed webview, no persistence/network) carry + over: authorship mode reads attribution, never mutates the document, sidecar, or + baseline; no new LLM/network/credential surface. + +## 4. Scope + +**In scope:** the Authorship mode + header toggle; `renderAuthorship` engine; +`AttributionController.spansFor`; the preview↔attribution wiring + `extension.ts` +reorder; webview toggle/legend/CSS; unit + host E2E + a manual-smoke addendum. + +**Out of scope / non-goals:** combining authorship with the diff in one view +(rejected in design — two modes); marking removed text (no diff in authorship +mode); the "untracked / always-there" third author class (rejected — only Claude & +human are colored, unattributed renders plain); intra-emphasis sentinel-safety +hardening (deferred); any change to F3 attribution capture, the seam, persistence, +or the cross-rung contract. + +## 5. Testing strategy + +- **Unit (vitest, vscode-free):** `renderAuthorship` — + - single Claude span / single human span → correct wrapper class; + - two authors in one prose paragraph → exact inline boundaries; + - span clipped at a block boundary (spans one block only); + - a code fence and a mermaid fence overlapping a span → block-level badge, + no inner sentinels (atomic); + - adjacent same-author spans; a span covering a whole block; + - empty `spans` → plain render (equals markdown-it of the source); + - determinism (same inputs → identical HTML). +- **Host E2E (`@vscode/test-electron`, no LLM, extends the F7 suite):** open the + preview on a markdown doc → land a Claude edit via the `proposeAgentEdit` seam + + accept → set authorship mode → assert the posted model marks Claude's span as + Claude-authored (and a human span as human). Assert track-changes mode still + renders as before (regression). +- **Manual smoke (`docs/MANUAL-SMOKE-F9.md`):** the webview visuals — blue/green + colors, the header toggle, the legend, a mixed-author paragraph, a Claude-authored + mermaid fence badge — verified by eye (the sealed webview's rendering isn't + covered by the E2E). + +## 6. Delivery slices + +- **SLICE-1** `renderAuthorship` + block-offset split in `trackChangesModel.ts` + (the sentinel inject/post-process; atomic fences) + unit tests. +- **SLICE-2** `AttributionController.spansFor` + wire it into + `TrackChangesPreviewController` (constructor dep, `extension.ts` reorder) + + per-panel mode state + the `setMode` message. +- **SLICE-3** webview header toggle + legend + `.cw-by-*` CSS (`media/`). +- **SLICE-4** host E2E (authorship-mode assertion + track-changes regression) + + `docs/MANUAL-SMOKE-F9.md` + README F9 note. + +**Done =** authorship mode shows Claude's spans (blue) and yours (green) inline on +a markdown doc; the header toggle flips modes; code/mermaid fences get a block +badge; track-changes mode unchanged; unit + host E2E green; smoke performed once.