diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts
index 30c25d7..1eb1765 100644
--- a/src/trackChangesModel.ts
+++ b/src/trackChangesModel.ts
@@ -266,6 +266,95 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
return `
${badge}${inner}
`;
}
+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 isCloseSentinel(m: string): boolean {
+ return m === SENT.claude.close || m === SENT.human.close;
+}
+
+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 {
+ 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 });
+ }
+ // Apply high offset → low so earlier offsets stay valid. At an equal offset
+ // (one span's close == the next's open), apply opens BEFORE closes so the
+ // close ends up left of the open: "……" for adjacent spans.
+ inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(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;
+}
+
+/** 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");
+}
+
/** 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 f97ea06..ddfc255 100644
--- a/test/trackChangesModel.test.ts
+++ b/test/trackChangesModel.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges } from "../src/trackChangesModel";
+import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -156,3 +156,79 @@ describe("splitBlocksWithRanges — block offsets align with the source string",
expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw);
});
});
+
+describe("renderAuthorship", () => {
+ const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => {
+ const start = text.indexOf(sub);
+ return { start, end: start + sub.length, author };
+ };
+
+ 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("handles two ADJACENT spans (one's end == next's start) in order", () => {
+ const text = "ONETWO\n";
+ const html = renderAuthorship(text, [
+ { start: 0, end: 3, author: "human" }, // ONE
+ { start: 3, end: 6, author: "claude" }, // TWO
+ ]);
+ expect(html).toContain('ONETWO');
+ });
+
+ it("clips a span to its block (does not bleed across blocks)", () => {
+ const text = "Para one.\n\nPara two.\n";
+ 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.toMatch(/[\uE000-\uE003]/); // no sentinel leaked
+ 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("never leaks a raw sentinel into prose output", () => {
+ const text = "Some mixed text here.\n";
+ const html = renderAuthorship(text, [spanAt(text, "mixed", "claude")]);
+ expect(html).not.toMatch(/[\uE000-\uE003]/);
+ });
+
+ it("is deterministic", () => {
+ const text = "Stable input paragraph.\n";
+ const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
+ expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
+ });
+});