diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts
index 930772e..3326d39 100644
--- a/src/trackChangesModel.ts
+++ b/src/trackChangesModel.ts
@@ -474,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
.join("");
}
+/** 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("");
+}
+
export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts
index a6b42f4..87d8f13 100644
--- a/test/trackChangesModel.test.ts
+++ b/test/trackChangesModel.test.ts
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest";
-import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
+import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -209,6 +209,46 @@ describe("colorByAuthor", () => {
});
});
+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");
+ });
+});
+
import { renderPlain } from "../src/trackChangesModel";
describe("renderPlain", () => {