From 95eaf78891baeca448b4550c97e7e33af302480b Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:36:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(f9):=20renderAuthorship=20=E2=80=94=20inli?= =?UTF-8?q?ne=20author=20spans=20+=20atomic=20fence=20badges=20(INV-26/27/?= =?UTF-8?q?28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUA sentinel injection through markdown-it; adjacent-span ordering handled; code/mermaid fences atomic with a block-level author badge. Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 89 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 78 ++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) 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)); + }); +});