diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts
index 8e8929a..6c28726 100644
--- a/src/trackChangesModel.ts
+++ b/src/trackChangesModel.ts
@@ -410,6 +410,76 @@ export function renderPlain(currentText: string, opts: RenderOptions = {}): stri
}
}
+export interface ProposalView {
+ id: string;
+ /** resolved offsets in currentText; null when the anchor did not resolve. */
+ anchorStart: number | null;
+ anchorEnd: number | null;
+ /** the text the proposal would replace (fp.text), for the struck "before". */
+ replaced: string;
+ /** the proposed replacement text. */
+ replacement: string;
+}
+
+function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
+ const safe = (src: string): string => {
+ try {
+ return render(src);
+ } catch (err) {
+ return chip(err instanceof Error ? err.message : String(err));
+ }
+ };
+ const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
+ const before = p.replaced ? `${safe(p.replaced)}` : "";
+ const after = `${safe(p.replacement)}`;
+ const actions =
+ `` +
+ `` +
+ `` +
+ ``;
+ return `
${actions}${before}${after}
`;
+}
+
+function renderReviewOp(
+ op: BlockOp,
+ render: (src: string) => string,
+ colored: (raw: string) => string,
+): string {
+ if (op.kind === "removed") return renderOp(op, render);
+ if (op.kind === "changed" && op.atomic) return renderOp(op, render);
+ if (op.kind === "changed") return renderOp(op, render); // word-merged /
+ return `${colored(op.block.raw)}
`;
+}
+
+/**
+ * On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
+ * via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
+ * proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
+ * Resolved proposals append after the diff body; unresolved ones append as trailing
+ * cw-proposal-unanchored blocks (never dropped — INV-34).
+ */
+export function renderReview(
+ baselineText: string,
+ currentText: string,
+ authorSpans: AuthorSpan[],
+ proposals: ProposalView[],
+ opts: RenderOptions = {},
+): string {
+ const render = opts.render ?? defaultRender;
+ const ranges = splitBlocksWithRanges(currentText);
+ const ops = diffBlocks(baselineText, currentText);
+ const colored = (raw: string): string => {
+ const blk = ranges.find((r) => r.raw === raw);
+ if (!blk) return render(raw);
+ return colorByAuthor(raw, blk.start, authorSpans, render);
+ };
+ const bodyParts = ops.map((op) => renderReviewOp(op, render, colored));
+ const anchored = proposals.filter((p) => p.anchorStart !== null);
+ const unanchored = proposals.filter((p) => p.anchorStart === null);
+ const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render));
+ return [...bodyParts, ...proposalParts].join("\n");
+}
+
/** Pure entry point: annotated HTML body for the preview (INV-22). */
export function renderTrackChanges(
baselineText: string,
diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts
index 6476723..0a27195 100644
--- a/test/trackChangesModel.test.ts
+++ b/test/trackChangesModel.test.ts
@@ -257,6 +257,46 @@ describe("renderPlain", () => {
});
});
+import { renderReview, type ProposalView } from "../src/trackChangesModel";
+
+describe("renderReview", () => {
+ test("renderReview: human addition since baseline renders green ins / cw-by-human", () => {
+ const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
+ expect(html).toMatch(/[^<]*world[^<]*<\/ins>|cw-by-human/);
+ });
+ test("renderReview: deletion since baseline renders struck del/cw-del", () => {
+ const html = renderReview("hello world", "hello", [], []);
+ expect(html).toMatch(/|cw-del/);
+ });
+ test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => {
+ const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
+ const html = renderReview("hello", "hello", [], proposals);
+ expect(html).toContain('class="cw-proposal"');
+ expect(html).toContain('data-proposal-id="p1"');
+ expect(html).toContain("cw-actions");
+ expect(html).toContain("goodbye");
+ expect(html).toMatch(/]*>[^<]*hello[^<]*<\/del>|cw-del/);
+ });
+ test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => {
+ const proposals: ProposalView[] = [{ id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }];
+ const html = renderReview("a", "a", [], proposals);
+ expect(html).toContain('data-proposal-id="p2"');
+ expect(html).toContain("cw-proposal-unanchored");
+ });
+ test("renderReview is deterministic (same inputs → identical HTML)", () => {
+ const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
+ const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
+ expect(a).toBe(b);
+ });
+ test("renderReview: an atomic mermaid change is diffed whole (no inner author sentinels)", () => {
+ const base = "```mermaid\nflowchart LR\n A --> B\n```";
+ const cur = "```mermaid\nflowchart LR\n A --> C\n```";
+ const html = renderReview(base, cur, [], []);
+ expect(html).toContain("mermaid");
+ expect(html).not.toContain("cw-by-");
+ });
+});
+
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {