diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts
index 84dbfa8..57668e9 100644
--- a/src/trackChangesModel.ts
+++ b/src/trackChangesModel.ts
@@ -828,6 +828,17 @@ export function renderReview(
return curOff + delta;
};
+ // F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
+ // landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
+ // colored block sitting AFTER a length-changing pending proposal would otherwise
+ // be mis-colored (the offsets diverge by the revert delta). Map the spans into
+ // landedText coordinates once so authorship coloring stays aligned.
+ const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
+ ...s,
+ start: toLanded(s.start),
+ end: toLanded(s.end),
+ }));
+
// Associate each resolved proposal with the landedText block index whose range
// it anchors into: the largest block with start <= anchorStart (the containing
// block, or the nearest preceding block when the anchor sits in a gap). A
@@ -857,7 +868,7 @@ export function renderReview(
const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string =>
- blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
+ blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts
index 800c590..85efc0d 100644
--- a/test/trackChangesModel.test.ts
+++ b/test/trackChangesModel.test.ts
@@ -392,6 +392,26 @@ describe("renderReview", () => {
// the applied paragraph is NOT also emitted as a word-merged changed block
expect(html).not.toContain("brown"); // no double-render of the change as a baseline diff
});
+
+ test("renderReview keeps author coloring aligned on a block AFTER a length-changing pending proposal (INV-49/50)", () => {
+ // block 1 has a pending proposal whose applied text is MUCH LONGER than its
+ // original; block 2 carries a Claude authorship span (in currentText coords).
+ const baseline = "one short.\n\ntwo stable here.\n";
+ const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
+ const proposals: ProposalView[] = [{
+ id: "pr_1",
+ anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
+ anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
+ replaced: "one short.",
+ replacement: "one MUCH LONGER REPLACED TEXT.",
+ original: "one short.",
+ }];
+ const sStart = current.indexOf("stable");
+ const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
+ const html = renderReview(baseline, current, authorSpans, proposals);
+ // the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
+ expect(html).toMatch(/stable<\/span>/);
+ });
});
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";