@cueloop/client 0.1.0-alpha.45 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.45",
3
+ "version": "0.1.0-alpha.46",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,16 +26,17 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.45",
30
- "@cueloop/extension-api": "0.1.0-alpha.45",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.45",
32
- "@cueloop/schema": "0.1.0-alpha.45",
29
+ "@cueloop/daemon": "0.1.0-alpha.46",
30
+ "@cueloop/extension-api": "0.1.0-alpha.46",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.46",
32
+ "@cueloop/schema": "0.1.0-alpha.46",
33
33
  "@opentui/core": "^0.5.2",
34
34
  "@opentui/keymap": "0.5.2",
35
35
  "@opentui/react": "^0.5.2",
36
36
  "@opentui/ssh": "0.5.2",
37
37
  "@pierre/diffs": "^1.3.5",
38
38
  "diff": "^8",
39
+ "puppeteer-core": "^25.8.0",
39
40
  "react": "^19.2.0"
40
41
  },
41
42
  "devDependencies": {
package/src/App.tsx CHANGED
@@ -58,6 +58,7 @@ import { CLIENT_VERSION } from "./version";
58
58
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
59
59
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
60
60
  import { DiffSheet } from "./components/DiffSheet";
61
+ import { PrototypeSheet } from "./components/PrototypeSheet";
61
62
  import { type ReviewRailHandle } from "./components/ReviewRail";
62
63
  import type { AgentTerminalHandle } from "./components/agent-launcher";
63
64
  import { ReviewPanel } from "./components/ReviewPanel";
@@ -251,6 +252,7 @@ export function App({
251
252
  }, [marks]);
252
253
  const resolved = session?.status === "resolved";
253
254
  const isDiff = session?.artifact.type === "diff";
255
+ const isPrototype = session?.artifact.type === "prototype";
254
256
  // sort position per annotation so the rail interleaves annotation and removal
255
257
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
256
258
  // annotation resolves to the display index it marked
@@ -1025,7 +1027,16 @@ export function App({
1025
1027
  <box style={{ width: railFootprint }} />
1026
1028
  </box>
1027
1029
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1028
- {isDiff ? (
1030
+ {isPrototype ? (
1031
+ <PrototypeSheet
1032
+ prototypePath={activeSession.artifact.meta.prototypePath ?? ""}
1033
+ quickActions={quickActions}
1034
+ canComment={isOwner && !resolved}
1035
+ onCommentElement={(element, body) =>
1036
+ controller.annotatePrototype(element.selector, element.quote, body)
1037
+ }
1038
+ />
1039
+ ) : isDiff ? (
1029
1040
  // the sheet dims to reading-quiet colors while the wizard has focus
1030
1041
  <DiffSheet
1031
1042
  rows={rows}
@@ -1061,7 +1072,7 @@ export function App({
1061
1072
  session: activeSession,
1062
1073
  authorNames,
1063
1074
  selectedId: focusedAnnotationId,
1064
- resolvedIds: isDiff ? null : resolvedIds,
1075
+ resolvedIds: isDiff || isPrototype ? null : resolvedIds,
1065
1076
  curationItems,
1066
1077
  selectedCurationId,
1067
1078
  annotationPositions,
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { PrototypeSheet } from "./PrototypeSheet";
5
+
6
+ export const meta: StoryMeta = { title: "PrototypeSheet" };
7
+
8
+ // The story renderer reports no kitty graphics, so the sheet shows its fallback.
9
+ export const Unsupported: Story = {
10
+ render: () => (
11
+ <PrototypeSheet
12
+ prototypePath="/tmp/example.html"
13
+ quickActions={[]}
14
+ canComment={true}
15
+ onCommentElement={() => {}}
16
+ />
17
+ ),
18
+ expectedColors: [DARK.textDim],
19
+ size: { width: 80, height: 24 },
20
+ };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Prototype review surface: renders the HTML as an image and turns a click into
3
+ * a DOM-element selection, so the shared marker actions bar and compose card
4
+ * annotate an element rather than a text span.
5
+ */
6
+
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import type { ImageRenderable } from "@opentui/core";
9
+ import { useRenderer } from "@opentui/react";
10
+ import type { Theme } from "../theme";
11
+ import { quickActionBody, type QuickAction } from "../config";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
14
+ import { MarkerPopover } from "./MarkerPopover";
15
+ import { AnnotationCard } from "./AnnotationCard";
16
+ import {
17
+ cssBoxToCell,
18
+ imageCellToCss,
19
+ launchPrototypeRenderer,
20
+ type PrototypeElement,
21
+ type PrototypeRenderer,
22
+ type PrototypeViewport,
23
+ } from "../prototype-browser";
24
+
25
+ export interface PrototypeSheetProps {
26
+ prototypePath: string;
27
+ quickActions: QuickAction[];
28
+ canComment: boolean;
29
+ onCommentElement: (element: PrototypeElement, body: string) => void;
30
+ theme?: Theme;
31
+ }
32
+
33
+ type SheetStatus = "loading" | "ready" | "unsupported" | "error";
34
+
35
+ const DEFAULT_CELL_ASPECT = 2;
36
+ const POPOVER_ROWS = 3;
37
+
38
+ export function PrototypeSheet({
39
+ prototypePath,
40
+ quickActions,
41
+ canComment,
42
+ onCommentElement,
43
+ theme,
44
+ }: PrototypeSheetProps): React.ReactNode {
45
+ const tokens = useComponentTheme(theme);
46
+ const renderer = useRenderer();
47
+ const imageRef = useRef<ImageRenderable | null>(null);
48
+ const browserRef = useRef<PrototypeRenderer | null>(null);
49
+ const launchedRef = useRef(false);
50
+ const viewportRef = useRef<PrototypeViewport>({ width: 1280, height: 800 });
51
+
52
+ const [status, setStatus] = useState<SheetStatus>("loading");
53
+ const [errorMessage, setErrorMessage] = useState<string>("");
54
+ const [png, setPng] = useState<Uint8Array | null>(null);
55
+ const [selected, setSelected] = useState<PrototypeElement | null>(null);
56
+ const [overlayCell, setOverlayCell] = useState<{ left: number; top: number } | null>(null);
57
+ const [actionsOpen, setActionsOpen] = useState(false);
58
+ const [composing, setComposing] = useState(false);
59
+ const [draftText, setDraftText] = useState("");
60
+
61
+ useEffect(() => {
62
+ return () => {
63
+ void browserRef.current?.close();
64
+ };
65
+ }, []);
66
+
67
+ const viewportFor = (columns: number, rows: number): PrototypeViewport => {
68
+ const resolution = renderer?.resolution ?? null;
69
+ const cellWidth = resolution ? resolution.width / renderer!.terminalWidth : 1;
70
+ const cellHeight = resolution
71
+ ? resolution.height / renderer!.terminalHeight
72
+ : DEFAULT_CELL_ASPECT;
73
+ return {
74
+ width: Math.max(320, Math.round(columns * cellWidth)),
75
+ height: Math.max(240, Math.round(rows * cellHeight)),
76
+ };
77
+ };
78
+
79
+ const launch = (): void => {
80
+ const image = imageRef.current;
81
+ if (launchedRef.current || !image || image.width < 1 || image.height < 1) return;
82
+ if (renderer?.capabilities && renderer.capabilities.kitty_graphics === false) {
83
+ setStatus("unsupported");
84
+ return;
85
+ }
86
+ launchedRef.current = true;
87
+ const viewport = viewportFor(image.width, image.height);
88
+ viewportRef.current = viewport;
89
+ void (async () => {
90
+ try {
91
+ const browser = await launchPrototypeRenderer({ filePath: prototypePath, viewport });
92
+ browserRef.current = browser;
93
+ setPng(await browser.screenshot());
94
+ setStatus("ready");
95
+ } catch (error) {
96
+ setErrorMessage(error instanceof Error ? error.message : String(error));
97
+ setStatus("error");
98
+ }
99
+ })();
100
+ };
101
+
102
+ const refresh = async (): Promise<void> => {
103
+ const browser = browserRef.current;
104
+ if (browser) setPng(await browser.screenshot());
105
+ };
106
+
107
+ const clearSelection = (): void => {
108
+ setSelected(null);
109
+ setOverlayCell(null);
110
+ setActionsOpen(false);
111
+ setComposing(false);
112
+ setDraftText("");
113
+ void browserRef.current
114
+ ?.highlight(null)
115
+ .then(refresh)
116
+ .catch(() => undefined);
117
+ };
118
+
119
+ const onImageMouseDown = (event: { x: number; y: number }): void => {
120
+ const browser = browserRef.current;
121
+ const image = imageRef.current;
122
+ if (status !== "ready" || !browser || !image) return;
123
+ const geometry = { x: image.x, y: image.y, width: image.width, height: image.height };
124
+ const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
125
+ if (!cssPoint) return;
126
+ void (async () => {
127
+ const element = await browser.elementAt(cssPoint.x, cssPoint.y);
128
+ if (!element) return;
129
+ const cell = cssBoxToCell(element.box, geometry, viewportRef.current);
130
+ const topRaw = cell.row - geometry.y;
131
+ setSelected(element);
132
+ setOverlayCell({
133
+ left: Math.max(0, cell.column - geometry.x),
134
+ top: Math.max(0, topRaw < POPOVER_ROWS ? topRaw + 1 : topRaw - POPOVER_ROWS),
135
+ });
136
+ setActionsOpen(false);
137
+ setComposing(false);
138
+ await browser.highlight(element.selector);
139
+ await refresh();
140
+ })().catch((error) => {
141
+ setErrorMessage(error instanceof Error ? error.message : String(error));
142
+ setStatus("error");
143
+ });
144
+ };
145
+
146
+ const commit = (body: string): void => {
147
+ if (!canComment) return;
148
+ if (selected && body.trim()) onCommentElement(selected, body.trim());
149
+ clearSelection();
150
+ };
151
+
152
+ const overlayLeft = overlayCell?.left ?? 0;
153
+ const overlayTop = Math.max(0, overlayCell?.top ?? 0);
154
+ const overlayStyle = {
155
+ position: "absolute" as const,
156
+ left: overlayLeft,
157
+ top: overlayTop,
158
+ flexDirection: "column" as const,
159
+ };
160
+
161
+ return (
162
+ <box
163
+ style={{
164
+ flexGrow: 1,
165
+ flexDirection: "column",
166
+ border: true,
167
+ borderStyle: FRAME_BORDER_STYLE,
168
+ borderColor: tokens.text,
169
+ }}
170
+ >
171
+ <image
172
+ ref={(instance: ImageRenderable | null) => {
173
+ imageRef.current = instance;
174
+ }}
175
+ {...(png ? { source: png } : {})}
176
+ fit="fill"
177
+ protocol="auto"
178
+ style={{ flexGrow: 1 }}
179
+ onSizeChange={launch}
180
+ onMouseDown={onImageMouseDown}
181
+ />
182
+ {status !== "ready" ? (
183
+ <box style={{ position: "absolute", left: 2, top: 1 }}>
184
+ <text fg={tokens.textDim}>{statusLine(status, errorMessage)}</text>
185
+ </box>
186
+ ) : null}
187
+ {selected && !composing ? (
188
+ <box style={overlayStyle}>
189
+ <MarkerPopover
190
+ view={actionsOpen ? "actions" : "toolbar"}
191
+ actions={quickActions}
192
+ actionIndex={0}
193
+ canCut={false}
194
+ onComment={() => (canComment ? setComposing(true) : undefined)}
195
+ onCut={() => undefined}
196
+ onOpenActions={() => setActionsOpen(true)}
197
+ onClose={clearSelection}
198
+ onPickAction={(index) => commit(quickActionBody(quickActions[index]!))}
199
+ onBack={() => setActionsOpen(false)}
200
+ theme={theme}
201
+ />
202
+ </box>
203
+ ) : null}
204
+ {selected && composing ? (
205
+ <box style={overlayStyle}>
206
+ <AnnotationCard
207
+ kind="comment"
208
+ quote={selected.quote}
209
+ draft={{
210
+ text: draftText,
211
+ onInput: setDraftText,
212
+ onSave: () => commit(draftText),
213
+ onCancel: clearSelection,
214
+ }}
215
+ theme={theme}
216
+ />
217
+ </box>
218
+ ) : null}
219
+ </box>
220
+ );
221
+ }
222
+
223
+ function statusLine(status: SheetStatus, errorMessage: string): string {
224
+ if (status === "loading") return "rendering prototype…";
225
+ if (status === "unsupported")
226
+ return "prototype preview needs a graphics terminal (kitty or ghostty)";
227
+ return `prototype preview failed: ${errorMessage}`;
228
+ }
@@ -1825,5 +1825,33 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
1825
1825
 
1826
1826
 
1827
1827
 
1828
+ "
1829
+ `;
1830
+
1831
+ exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
1832
+ "╭──────────────────────────────────────────────────────────────────────────────╮
1833
+ │ │
1834
+ │ rendering prototype… │
1835
+ │ │
1836
+ │ │
1837
+ │ │
1838
+ │ │
1839
+ │ │
1840
+ │ │
1841
+ │ │
1842
+ │ │
1843
+ │ │
1844
+ │ │
1845
+ │ │
1846
+ │ │
1847
+ │ │
1848
+ │ │
1849
+ │ │
1850
+ │ │
1851
+ │ │
1852
+ │ │
1853
+ │ │
1854
+ │ │
1855
+ ╰──────────────────────────────────────────────────────────────────────────────╯
1828
1856
  "
1829
1857
  `;
@@ -32,6 +32,7 @@ function baseController() {
32
32
  curationItems: mock(() => []),
33
33
  restoreCuration: mock(),
34
34
  annotate: mock(),
35
+ annotatePrototype: mock(),
35
36
  updateAnnotation: mock(),
36
37
  removeAnnotation: mock(),
37
38
  walkStart: mock(),
@@ -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;