@cueloop/client 0.1.0-alpha.44 → 0.1.0-alpha.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
3
+
4
+ const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
5
+ const VIEWPORT = { width: 1280, height: 720 };
6
+
7
+ describe("imageCellToCss", () => {
8
+ test("maps a cell inside the image to the center of its viewport pixel band", () => {
9
+ // Act - the image's top-left cell (1,1)
10
+ const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
11
+
12
+ // Assert - half a cell into the first band
13
+ expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
14
+ expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
15
+ });
16
+
17
+ test("a click below the image's origin lands deeper in the page", () => {
18
+ // Act
19
+ const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
20
+ const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
21
+
22
+ // Assert
23
+ expect(bottom.y).toBeGreaterThan(top.y);
24
+ });
25
+
26
+ test("a cell outside the image returns null", () => {
27
+ // Assert - the gutter left of and the row above the image
28
+ expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
29
+ expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
30
+ expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe("cssBoxToCell", () => {
35
+ test("maps a viewport rect back to the image cells covering it", () => {
36
+ // Arrange - a rect covering the right half, lower half of the page
37
+ const box = { x: 640, y: 360, width: 320, height: 180 };
38
+
39
+ // Act
40
+ const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
41
+
42
+ // Assert - x at half width -> 40 cells in from the image origin (1)
43
+ expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
44
+ expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
45
+ expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
46
+ expect(cell.rows).toBe(Math.round((180 / 720) * 40));
47
+ });
48
+
49
+ test("a zero-size rect still reserves at least one cell", () => {
50
+ // Assert
51
+ const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
52
+ expect(cell.columns).toBe(1);
53
+ expect(cell.rows).toBe(1);
54
+ });
55
+ });
@@ -0,0 +1,218 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Headless-Chromium backing for prototype review: render an HTML file to a PNG
4
+ * and resolve a click coordinate to a DOM element. puppeteer-core is imported
5
+ * lazily so plan/diff review and the test suite never load it.
6
+ */
7
+
8
+ export interface PrototypeElement {
9
+ /** Stable CSS selector - the annotation anchor's authority. */
10
+ selector: string;
11
+ /** Short human label for the rail card (element text, else the selector). */
12
+ quote: string;
13
+ /** Element rectangle in CSS pixels within the captured viewport. */
14
+ box: ElementBox;
15
+ }
16
+
17
+ export interface ElementBox {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ export interface PrototypeViewport {
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ export interface PrototypeRenderer {
30
+ readonly viewport: PrototypeViewport;
31
+ screenshot(): Promise<Uint8Array>;
32
+ elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
33
+ highlight(selector: string | null): Promise<void>;
34
+ close(): Promise<void>;
35
+ }
36
+
37
+ export interface LaunchOptions {
38
+ filePath: string;
39
+ viewport: PrototypeViewport;
40
+ /** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
41
+ executablePath?: string;
42
+ }
43
+
44
+ /** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
45
+ export function imageCellToCss(
46
+ event: { x: number; y: number },
47
+ image: { x: number; y: number; width: number; height: number },
48
+ viewport: PrototypeViewport,
49
+ ): { x: number; y: number } | null {
50
+ const columnInImage = event.x - image.x;
51
+ const rowInImage = event.y - image.y;
52
+ if (
53
+ columnInImage < 0 ||
54
+ rowInImage < 0 ||
55
+ columnInImage >= image.width ||
56
+ rowInImage >= image.height
57
+ ) {
58
+ return null;
59
+ }
60
+ return {
61
+ x: ((columnInImage + 0.5) / image.width) * viewport.width,
62
+ y: ((rowInImage + 0.5) / image.height) * viewport.height,
63
+ };
64
+ }
65
+
66
+ /** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
67
+ export function cssBoxToCell(
68
+ box: ElementBox,
69
+ image: { x: number; y: number; width: number; height: number },
70
+ viewport: PrototypeViewport,
71
+ ): { column: number; row: number; columns: number; rows: number } {
72
+ const scaleColumn = image.width / viewport.width;
73
+ const scaleRow = image.height / viewport.height;
74
+ return {
75
+ column: image.x + Math.floor(box.x * scaleColumn),
76
+ row: image.y + Math.floor(box.y * scaleRow),
77
+ columns: Math.max(1, Math.round(box.width * scaleColumn)),
78
+ rows: Math.max(1, Math.round(box.height * scaleRow)),
79
+ };
80
+ }
81
+
82
+ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
83
+ const puppeteer = (await import("puppeteer-core")).default;
84
+ const { pathToFileURL } = await import("node:url");
85
+ const browser = await puppeteer.launch({
86
+ executablePath: options.executablePath ?? chromeExecutable(),
87
+ headless: true,
88
+ args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
89
+ defaultViewport: {
90
+ width: options.viewport.width,
91
+ height: options.viewport.height,
92
+ deviceScaleFactor: 2,
93
+ },
94
+ });
95
+ // close the browser if page setup fails, so a launch error never leaks Chromium
96
+ const page = await browser.newPage().catch(async (error) => {
97
+ await browser.close();
98
+ throw error;
99
+ });
100
+ await page
101
+ .goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
102
+ .catch(async (error) => {
103
+ await browser.close();
104
+ throw error;
105
+ });
106
+
107
+ return {
108
+ viewport: options.viewport,
109
+ async screenshot() {
110
+ const buffer = await page.screenshot({ type: "png", encoding: "binary" });
111
+ return new Uint8Array(buffer as Buffer);
112
+ },
113
+ async elementAt(cssX, cssY) {
114
+ return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
115
+ },
116
+ async highlight(selector) {
117
+ await page.evaluate(highlightScript, selector);
118
+ },
119
+ async close() {
120
+ await browser.close();
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
126
+ function elementAtScript(x: number, y: number): unknown {
127
+ const SEMANTIC = new Set([
128
+ "SECTION",
129
+ "ARTICLE",
130
+ "LI",
131
+ "NAV",
132
+ "HEADER",
133
+ "FOOTER",
134
+ "ASIDE",
135
+ "FORM",
136
+ ]);
137
+ const isNamed = (element: Element): boolean =>
138
+ element.classList.length > 0 || SEMANTIC.has(element.tagName);
139
+ // a card wraps several children: climb to the nearest named container holding
140
+ // more than one child, else the nearest named ancestor, else the clicked leaf
141
+ const componentRoot = (start: Element): Element => {
142
+ let namedFallback: Element | null = null;
143
+ let current: Element | null = start;
144
+ while (current && current !== document.body) {
145
+ if (isNamed(current)) {
146
+ if (current.childElementCount > 1) return current;
147
+ namedFallback = namedFallback ?? current;
148
+ }
149
+ current = current.parentElement;
150
+ }
151
+ return namedFallback ?? start;
152
+ };
153
+ const selectorFor = (start: Element): string => {
154
+ const parts: string[] = [];
155
+ let current: Element | null = start;
156
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
157
+ let part = current.tagName.toLowerCase();
158
+ if (current.id) {
159
+ parts.unshift(part + "#" + CSS.escape(current.id));
160
+ break;
161
+ }
162
+ const parent: Element | null = current.parentElement;
163
+ if (parent) {
164
+ const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
165
+ if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
166
+ }
167
+ parts.unshift(part);
168
+ current = current.parentElement;
169
+ }
170
+ return parts.join(" > ");
171
+ };
172
+ const hit = document.elementFromPoint(x, y);
173
+ if (!(hit instanceof Element)) return null;
174
+ const node = componentRoot(hit);
175
+ const selector = selectorFor(node);
176
+ if (!selector) return null;
177
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
178
+ const tag = node.tagName.toLowerCase();
179
+ const quote = text
180
+ ? text.slice(0, 80)
181
+ : node.id
182
+ ? tag + "#" + node.id
183
+ : node.classList.length
184
+ ? tag + "." + node.classList[0]
185
+ : tag;
186
+ const rect = node.getBoundingClientRect();
187
+ return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
188
+ }
189
+
190
+ function highlightScript(selector: string | null): void {
191
+ const ATTRIBUTE = "data-cueloop-selected";
192
+ const STYLE_ID = "cueloop-highlight-style";
193
+ document
194
+ .querySelectorAll("[" + ATTRIBUTE + "]")
195
+ .forEach((node) => node.removeAttribute(ATTRIBUTE));
196
+ if (!document.getElementById(STYLE_ID)) {
197
+ const style = document.createElement("style");
198
+ style.id = STYLE_ID;
199
+ style.textContent =
200
+ "[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
201
+ document.head.appendChild(style);
202
+ }
203
+ if (selector) {
204
+ const node = document.querySelector(selector);
205
+ if (node) node.setAttribute(ATTRIBUTE, "");
206
+ }
207
+ }
208
+
209
+ /** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
210
+ function chromeExecutable(): string {
211
+ if (process.platform === "darwin") {
212
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
213
+ }
214
+ if (process.platform === "win32") {
215
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
216
+ }
217
+ return "/usr/bin/google-chrome";
218
+ }
@@ -197,6 +197,8 @@ export interface ReviewController {
197
197
  end: number,
198
198
  body: string,
199
199
  ): string | undefined;
200
+ /** Anchor a prototype comment to a DOM element by its selector. */
201
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined;
200
202
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
201
203
  updateAnnotation(id: string, body: string): void;
202
204
  removeAnnotation(id: string): void;
@@ -679,6 +681,18 @@ class Controller implements ReviewController {
679
681
  return wire.id;
680
682
  }
681
683
 
684
+ annotatePrototype(selector: string, quote: string, body: string): string | undefined {
685
+ const session = this.snapshot.session;
686
+ if (!session) return undefined;
687
+ const anchor = { quote, prefix: "", suffix: "", selector };
688
+ const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
689
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
690
+ this.apply(persisted);
691
+ this.mirrorAnnotation(persisted, wire);
692
+ this.setStatus("comment added");
693
+ return wire.id;
694
+ }
695
+
682
696
  updateAnnotation(id: string, body: string): void {
683
697
  const session = this.snapshot.session;
684
698
  if (!session) return;
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ renderedOffsetFor,
7
+ workRangeForRendered,
8
+ } from "./view-plan";
9
+
10
+ // M0 (#232): lock the renderer's current StyleRun output and offset round-trips
11
+ // before the inline-tokenizer milestones (M1-M5) change blockRuns. The snapshots
12
+ // capture today's runs, so a later milestone's diff is explicit and reviewable.
13
+ // The round-trip assertions are the durable invariant: a visible word's rendered
14
+ // selection must always recover that word's *source* (block.text) offsets, even
15
+ // once inline markers are concealed - this is what keeps quote anchors exact.
16
+
17
+ const DOC = `# Heading with **bold**
18
+
19
+ A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).
20
+
21
+ - a list item with *emphasis*
22
+ `;
23
+
24
+ const blocks = buildDisplay(DOC);
25
+
26
+ describe("view-plan regression harness (M0)", () => {
27
+ test("blockRuns output is stable across the representative blocks", () => {
28
+ // Arrange / Act
29
+ const runsByBlock = blocks.map((block) => ({
30
+ type: block.type,
31
+ text: displayText(block),
32
+ runs: blockRuns(block, true),
33
+ }));
34
+
35
+ // Assert
36
+ expect(runsByBlock).toMatchSnapshot();
37
+ });
38
+
39
+ // A visible word's source offsets survive the rendered round-trip. Holds at M0
40
+ // (1:1 today) and must keep holding once M2 conceals markers and the rendered
41
+ // offset shifts - the recovered work range stays the word's block.text span.
42
+ const paragraph = blocks.find((block) => displayText(block).includes("strong"))!;
43
+ const paragraphRuns = blockRuns(paragraph, true);
44
+ const paragraphText = displayText(paragraph);
45
+
46
+ for (const word of ["paragraph", "strong", "strike", "label"]) {
47
+ test(`rendered selection of "${word}" recovers its block.text offsets`, () => {
48
+ // Arrange
49
+ const start = paragraphText.indexOf(word);
50
+ const rendered = renderedOffsetFor(paragraphRuns, start);
51
+
52
+ // Assert
53
+ expect(rendered).not.toBeNull();
54
+ expect(workRangeForRendered(paragraphRuns, rendered!, rendered! + word.length)).toEqual({
55
+ start,
56
+ end: start + word.length,
57
+ });
58
+ });
59
+ }
60
+ });
@@ -6,7 +6,10 @@ import {
6
6
  marksByDisplay,
7
7
  nextWorkBlock,
8
8
  overlayMarks,
9
+ renderedOffsetAtOrAfter,
10
+ renderedOffsetAtOrBefore,
9
11
  renderedOffsetFor,
12
+ safeLinkHref,
10
13
  spanFromRange,
11
14
  spanKey,
12
15
  startSpan,
@@ -125,6 +128,86 @@ describe("blockRuns + overlayMarks", () => {
125
128
  });
126
129
  });
127
130
 
131
+ describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
132
+ test("trusts http(s) and mailto, drops everything else", () => {
133
+ // Assert
134
+ expect(safeLinkHref("https://example.com")).toBe("https://example.com");
135
+ expect(safeLinkHref("http://x")).toBe("http://x");
136
+ expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
137
+ expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
138
+ expect(safeLinkHref("./relative/path")).toBeUndefined();
139
+ expect(safeLinkHref(undefined)).toBeUndefined();
140
+ });
141
+
142
+ test("a control byte or space in the target drops the link", () => {
143
+ // Assert - ESC and BEL could splice into the OSC 8 escape stream
144
+ expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
145
+ expect(safeLinkHref("https://x\x07")).toBeUndefined();
146
+ expect(safeLinkHref("https://x y")).toBeUndefined();
147
+ });
148
+
149
+ test("blockRuns carries the link href through to the link run", () => {
150
+ // Act
151
+ const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
152
+ const link = blockRuns(block, true).find((run) => run.role === "link")!;
153
+
154
+ // Assert
155
+ expect(link.text).toBe("docs");
156
+ expect(link.href).toBe("https://example.com");
157
+ });
158
+ });
159
+
160
+ describe("M3: inline emphasis composes with word-diff (#235)", () => {
161
+ test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
162
+ // Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
163
+ const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
164
+
165
+ // Act
166
+ const runs = blockRuns(block, true);
167
+
168
+ // Assert - no marker text renders anywhere
169
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
170
+ // the unchanged emphasized word keeps its strong role (context, not diffed)
171
+ expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
172
+ // the added word is green (ins wins over any emphasis), the removed one opaque
173
+ expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
174
+ expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
175
+ });
176
+
177
+ test("markers inside added text conceal while the run stays a positioned ins", () => {
178
+ // Arrange - an emphasized word is added into an otherwise unchanged line
179
+ const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
180
+
181
+ // Act
182
+ const runs = blockRuns(block, true);
183
+ const added = runs.find((run) => run.text === "very")!;
184
+
185
+ // Assert - concealed markers, green role, and an exact work offset back into work text
186
+ expect(added.role).toBe("ins");
187
+ expect(added.start).not.toBeNull();
188
+ expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
189
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
190
+ });
191
+
192
+ test("an anchor over an emphasized context word round-trips to its work offsets", () => {
193
+ // Arrange
194
+ const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
195
+ const runs = blockRuns(block, true);
196
+ const work = block.work!.text;
197
+ const start = work.indexOf("steady");
198
+
199
+ // Act
200
+ const rendered = renderedOffsetFor(runs, start);
201
+
202
+ // Assert
203
+ expect(rendered).not.toBeNull();
204
+ expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
205
+ start,
206
+ end: start + "steady".length,
207
+ });
208
+ });
209
+ });
210
+
128
211
  describe("marksByDisplay", () => {
129
212
  test("annotations resolve into display coordinates", () => {
130
213
  // Arrange
@@ -217,6 +300,28 @@ describe("rendered/work offset mapping", () => {
217
300
  expect(renderedOffsetFor(runs, 99)).toBeNull();
218
301
  expect(workRangeForRendered(runs, 90, 99)).toBeNull();
219
302
  });
303
+
304
+ test("a span starting/ending on concealed markers snaps to the visible word", () => {
305
+ // Arrange - the word span over "**very**" covers the markers, which have
306
+ // no rendered cells; the clamped lookups land on "very" itself
307
+ const text = "keep it **very** safe\n";
308
+ const runs = blockRuns(buildDisplay(text)[0]!, true);
309
+ const spanStart = text.indexOf("**very**");
310
+ const spanEnd = spanStart + "**very**".length;
311
+
312
+ // Act
313
+ const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
314
+ const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
315
+
316
+ // Assert
317
+ const renderedText = runs.map((run) => run.text).join("");
318
+ expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
319
+ // exact hits behave like renderedOffsetFor
320
+ expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
321
+ expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
322
+ // nothing positioned beyond the text end
323
+ expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
324
+ });
220
325
  });
221
326
 
222
327
  describe("span mode", () => {
package/src/view-plan.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {
11
+ inlineRuns,
11
12
  isAddressed,
12
13
  lcsDiff,
13
14
  parseBlocks,
@@ -141,7 +142,18 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
141
142
 
142
143
  // ── marks (annotation highlights + keyboard span) ──
143
144
 
144
- export type RunRole = "plain" | "ins" | "del" | "mark-comment" | "kspan" | "mark-focus";
145
+ export type RunRole =
146
+ | "plain"
147
+ | "ins"
148
+ | "del"
149
+ | "mark-comment"
150
+ | "kspan"
151
+ | "mark-focus"
152
+ | "strong"
153
+ | "em"
154
+ | "code"
155
+ | "strike"
156
+ | "link";
145
157
 
146
158
  export interface StyleRun {
147
159
  text: string;
@@ -149,6 +161,8 @@ export interface StyleRun {
149
161
  /** Offset of this run in the block's working text; null for del runs. */
150
162
  start: number | null;
151
163
  annotationId?: string;
164
+ /** Link target for `link` runs. */
165
+ href?: string;
152
166
  }
153
167
 
154
168
  export interface Mark {
@@ -195,6 +209,7 @@ export function marksByDisplay(
195
209
 
196
210
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
197
211
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
212
+ const literal = block.kind === "code" || block.kind === "hr";
198
213
  if (block.type === "mod" && markup) {
199
214
  const changes = wordLevelChanges(block.base!.text, block.work!.text);
200
215
  const runs: StyleRun[] = [];
@@ -202,18 +217,57 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
202
217
  for (const change of changes) {
203
218
  if (change.kind === "removed") {
204
219
  runs.push({ text: change.text, role: "del", start: null });
205
- } else {
206
- runs.push({
207
- text: change.text,
208
- role: change.kind === "added" ? "ins" : "plain",
209
- start: workOffset,
210
- });
211
- workOffset += change.text.length;
220
+ continue;
212
221
  }
222
+ // A code/rule block stays literal; prose segments inline-tokenize so their
223
+ // markers conceal while their work offsets stay intact. Added text keeps
224
+ // the green diff role (it wins over emphasis); context text shows emphasis.
225
+ const segment = literal
226
+ ? [{ text: change.text, role: "plain" as const, start: workOffset }]
227
+ : inlineStyleRuns(change.text, workOffset);
228
+ for (const run of segment) {
229
+ runs.push(change.kind === "added" ? { ...run, role: "ins" } : run);
230
+ }
231
+ workOffset += change.text.length;
213
232
  }
214
233
  return runs;
215
234
  }
216
- return [{ text: displayText(block), role: "plain", start: 0 }];
235
+ const text = displayText(block);
236
+ // Prose carries inline markup; code fences and rules are literal. Concealed
237
+ // markers are dropped, so every rendered cell still maps to a work offset.
238
+ if (markup && !literal) {
239
+ return inlineStyleRuns(text, 0);
240
+ }
241
+ return [{ text, role: "plain", start: 0 }];
242
+ }
243
+
244
+ /** A link href safe to hand a terminal as an OSC 8 target, or undefined. Only
245
+ * http(s) and mailto are trusted; a scheme like javascript: is dropped. The
246
+ * target must stay printable ASCII: a control byte (ESC, BEL) in a reviewed
247
+ * document could otherwise splice into the terminal escape stream. */
248
+ export function safeLinkHref(href: string | undefined): string | undefined {
249
+ if (href === undefined) return undefined;
250
+ if (!/^(https?:|mailto:)/i.test(href)) return undefined;
251
+ return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
252
+ }
253
+
254
+ /**
255
+ * Map the schema inline tokens to positioned StyleRuns, dropping the concealed
256
+ * markers. `base` is the run's offset within the block's working text (0 for a
257
+ * whole prose block; a segment's start for a word-diff piece).
258
+ */
259
+ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
260
+ const runs: StyleRun[] = [];
261
+ for (const run of inlineRuns(text)) {
262
+ if (run.role === "marker" || run.start === null) continue;
263
+ runs.push({
264
+ text: run.text,
265
+ role: run.role === "text" ? "plain" : run.role,
266
+ start: base + run.start,
267
+ ...(run.href !== undefined ? { href: run.href } : {}),
268
+ });
269
+ }
270
+ return runs;
217
271
  }
218
272
 
219
273
  /** Split runs at mark boundaries; marks only bind to runs with offsets. */
@@ -274,6 +328,39 @@ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number
274
328
  return null;
275
329
  }
276
330
 
331
+ /**
332
+ * Rendered offset of the first positioned character at or after `workOffset`.
333
+ * A word span may start on a concealed marker (the `**` of an emphasized
334
+ * word), which has no rendered cell; the span visuals snap to the first
335
+ * character that does. Positioned runs appear in ascending work order.
336
+ */
337
+ export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): number | null {
338
+ let rendered = 0;
339
+ for (const run of runs) {
340
+ if (run.start !== null && run.start + run.text.length > workOffset) {
341
+ return rendered + Math.max(0, workOffset - run.start);
342
+ }
343
+ rendered += run.text.length;
344
+ }
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * Rendered offset of the last positioned character at or before `workOffset` -
350
+ * the closing-marker counterpart of `renderedOffsetAtOrAfter`.
351
+ */
352
+ export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number): number | null {
353
+ let rendered = 0;
354
+ let best: number | null = null;
355
+ for (const run of runs) {
356
+ if (run.start !== null && run.start <= workOffset) {
357
+ best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
358
+ }
359
+ rendered += run.text.length;
360
+ }
361
+ return best;
362
+ }
363
+
277
364
  /**
278
365
  * Work-text range covered by a rendered-text selection [start, end). Del runs
279
366
  * and other unpositioned text inside the selection contribute nothing; the